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
117 declare -A REPO_SOURCE_DATA
119 function set_repo_source()
121 REPO=$(nonempty_option 'set_repo_source' 'REPO' "${1}") || return 1
122 > "${REPO}/source_cache" || return 1
123 for KEY in "${!REPO_SOURCE_DATA[@]}"; do
124 echo "${KEY}=${REPO_SOURCE_DATA[${KEY}]}" >> "${REPO}/source_cache" || return 1
128 # usage: get_repo_source REPO
129 function get_repo_source()
131 REPO=$(nonempty_option 'get_repo_source' 'REPO' "${1}") || return 1
133 if [ -f "${REPO}/source_cache" ]; then
137 REPO_SOURCE_DATA["${KEY}"]="${VALUE}"
138 done < "${REPO}/source_cache"
140 # autodetect verson control system
142 REPO_SOURCE_DATA['repo']="${REPO}"
143 if [ -d "${REPO}/.git" ]; then
144 REPO_SOURCE_DATA['transfer']='git'
146 echo "ERROR: no source location found for ${REPO}" >&2
149 # no need to get further fields for these transfer mechanisms
153 function wget_fetch()
155 if [ "${BASH_MAJOR}" -lt 4 ]; then
156 echo "ERROR: ${0} requires Bash version >= 4.0 for wget support" >&2
157 echo "you're running ${BASH}, which doesn't support associative arrays" >&2
161 REPO=$(nonempty_option 'wget_fetch' 'REPO' "${1}") || return 1
162 # get_repo_source() was just called on this repo in fetch()
163 TRANSFER=$(nonempty_option 'wget_fetch' 'TRANSFER' "${REPO_SOURCE_DATA['transfer']}") || return 1
164 URL=$(nonempty_option 'wget_fetch' 'URL' "${REPO_SOURCE_DATA['url']}") || return 1
165 ETAG="${REPO_SOURCE_DATA['etag']}"
167 HEAD=$("${WGET}" --server-response --spider "${URL}" 2>&1) || return 1
168 SERVER_ETAG=$(echo "${HEAD}" | "${SED}" -n 's/^ *etag: *"\(.*\)"/\1/ip') || return 1
169 if [ "${CHECK_WGET_TYPE_AND_ENCODING}" = 'yes' ]; then
170 TYPE=$(echo "${HEAD}" | "${SED}" -n 's/^ *content-type: *//ip') || return 1
171 ENCODING=$(echo "${HEAD}" | "${SED}" -n 's/^ *content-encoding: *//ip') || return 1
172 if [ "${TYPE}" != 'application/x-gzip' ] || [ "${ENCODING}" != 'x-gzip' ]; then
173 echo "ERROR: invalid content type (${TYPE}) or encoding (${ENCODING})." >&2
174 echo "while fetching ${URL}" >&2
178 if [ -z "${ETAG}" ] || [ "${SERVER_ETAG}" != "${ETAG}" ]; then
179 # Previous ETag not known, or ETag changed. Download new copy.
180 "${WGET}" --output-document "${BUNDLE}" "${URL}" || return 1
181 if [ -n "${SERVER_ETAG}" ]; then # store new ETag
182 REPO_SOURCE_DATA['etag']="${SERVER_ETAG}"
183 set_repo_source "${REPO}" || return 1
185 if [ -n "${ETAG}" ]; then # clear old ETag
186 unset "${REPO_SOURCE_DATA['etag']}"
187 set_repo_source "${REPO}" || return 1
190 echo "extracting ${BUNDLE} to ${REPO}"
191 "${TAR}" -xf "${BUNDLE}" -C "${REPO}" --strip-components 1 --overwrite || return 1
192 "${RM}" -f "${BUNDLE}" || return 1
194 echo "already downloaded the ETag=${ETAG} version of ${URL}"
198 # usage: link_file REPO FILE
200 # Create the symbolic link to the version of FILE in the REPO
201 # repository, overriding the target if it exists.
204 REPO=$(nonempty_option 'link_file' 'REPO' "${1}") || return 1
205 FILE=$(nonempty_option 'link_file' 'FILE' "${2}") || return 1
206 if [ "${BACKUP}" = 'yes' ]; then
207 if [ -e "${TARGET}/${FILE}" ] || [ -h "${TARGET}/${FILE}" ]; then
208 if [ "${DRY_RUN}" = 'yes' ]; then
209 echo "move ${TARGET}/${FILE} to ${TARGET}/${FILE}.bak"
212 mv -v "${TARGET}/${FILE}" "${TARGET}/${FILE}.bak" || return 1
216 if [ "${DRY_RUN}" = 'yes' ]; then
217 echo "rm ${TARGET}/${FILE}"
219 "${RM}" -fv "${TARGET}/${FILE}"
222 if [ "${DRY_RUN}" = 'yes' ]; then
223 echo "link ${TARGET}/${FILE} to ${DOTFILES_DIR}/${REPO}/patched-src/${FILE}"
226 "${LN}" -sv "${DOTFILES_DIR}/${REPO}/patched-src/${FILE}" "${TARGET}/${FILE}" || return 1
233 # An array of available commands
241 CLONE_TRANSFERS=('git' 'wget')
243 function clone_help()
245 echo 'Create a new dotfiles repository.'
246 if [ "${1}" = '--one-line' ]; then return; fi
250 usage: $0 ${COMMAND} REPO TRANSFER URL
252 Where 'REPO' is the name the dotfiles repository to create,
253 'TRANSFER' is the transfer mechanism, and 'URL' is the URL for the
254 remote repository. Valid TRANSFERs are:
256 ${CLONE_TRANSFERS[@]}
260 $0 clone public wget http://example.com/public-dotfiles.tar.gz
261 $0 clone private git ssh://example.com/~/private-dotfiles.git
267 REPO=$(nonempty_option 'clone' 'REPO' "${1}") || return 1
268 TRANSFER=$(nonempty_option 'clone' 'TRANSFER' "${2}") || return 1
269 URL=$(nonempty_option 'clone' 'URL' "${3}") || return 1
270 maxargs 'clone' 3 "${@}" || return 1
271 TRANSFER=$(get_selection "${TRANSFER}" "${CLONE_TRANSFERS[@]}") || return 1
272 if [ -e "${REPO}" ]; then
273 echo "ERROR: destination path (${REPO}) already exists." >&2
278 case "${TRANSFER}" in
282 "${GIT}" clone "${URL}" "${REPO}" || return 1
288 echo "PROGRAMMING ERROR: add ${TRANSFER} support to clone command" >&2
291 if [ "${CACHE_SOURCE}" = 'yes' ]; then
292 REPO_SOURCE_DATA=(['transfer']="${TRANSFER}" ['url']="${URL}")
293 set_repo_source "${REPO}" || return 1
295 if [ "${FETCH}" = 'yes' ]; then
296 fetch "${REPO}" || return 1
305 function fetch_help()
307 echo 'Get the current dotfiles from the server.'
308 if [ "${1}" = '--one-line' ]; then return; fi
312 usage: $0 ${COMMAND} [REPO]
314 Where 'REPO' is the name the dotfiles repository to fetch. If it
315 is not given, all repositories will be fetched.
321 # multi-repo case handled in main() by run_on_all_repos()
322 REPO=$(nonempty_option 'fetch' 'REPO' "${1}") || return 1
323 maxargs 'fetch' 1 "${@}" || return 1
324 get_repo_source "${REPO}" || return 1
325 TRANSFER=$(nonempty_option 'fetch' 'TRANSFER' "${REPO_SOURCE_DATA['transfer']}") || return 1
326 if [ "${TRANSFER}" = 'git' ]; then
327 (cd "${REPO}" && "${GIT}" pull) || return 1
328 elif [ "${TRANSFER}" = 'wget' ]; then
329 wget_fetch "${REPO}" || return 1
331 echo "PROGRAMMING ERROR: add ${TRANSFER} support to fetch command" >&2
343 echo 'Show differences between targets and dotfiles repositories.'
344 if [ "${1}" = '--one-line' ]; then return; fi
348 usage: $0 ${COMMAND} [--removed|--local-patch] [REPO]
350 Where 'REPO' is the name the dotfiles repository to query. If it
351 is not given, all repositories will be queried.
353 By default, ${COMMAND} will list differences between files that
354 exist in both the target location and the dotfiles repository (as
355 a patch that could be applied to the dotfiles source).
357 With the '--removed' option, ${COMMAND} will list files that
358 should be removed from the dotfiles source in order to match the
361 With the '--local-patch' option, ${COMMAND} will create files in
362 list files that should be removed from the dotfiles source in
363 order to match the target.
370 while [ "${1::2}" = '--' ]; do
379 echo "ERROR: invalid option to diff (${1})" >&2
384 # multi-repo case handled in main() by run_on_all_repos()
385 REPO=$(nonempty_option 'diff' 'REPO' "${1}") || return 1
386 maxargs 'diff' 1 "${@}" || return 1
388 if [ "${MODE}" = 'local-patch' ]; then
389 mkdir -p "${REPO}/local-patch" || return 1
391 exec 3<&1 # save stdout to file descriptor 3
392 echo "save local patches to ${REPO}/local-patch/000-local.patch"
393 exec 1>"${REPO}/local-patch/000-local.patch" # redirect stdout
395 exec 1<&3 # restore old stdout
396 exec 3<&- # close temporary fd 3
398 exec 3<&1 # save stdout to file descriptor 3
399 echo "save local removed to ${REPO}/local-patch/000-local.remove"
400 exec 1>"${REPO}/local-patch/000-local.remove" # redirect stdout
401 diff --removed "${REPO}"
402 exec 1<&3 # restore old stdout
403 exec 3<&- # close temporary fd 3
408 if [ "${MODE}" = 'removed' ]; then
409 if [ ! -e "${TARGET}/${FILE}" ]; then
413 if [ -f "${TARGET}/${FILE}" ]; then
414 (cd "${REPO}/src" && "${DIFF}" -u "${FILE}" "${TARGET}/${FILE}")
418 $(list_files "${REPO}/src")
427 function patch_help()
429 echo 'Patch a fresh checkout with local adjustments.'
430 if [ "${1}" = '--one-line' ]; then return; fi
434 usage: $0 ${COMMAND} [REPO]
436 Where 'REPO' is the name the dotfiles repository to patch. If it
437 is not given, all repositories will be patched.
443 # multi-repo case handled in main() by run_on_all_repos()
444 REPO=$(nonempty_option 'patch' 'REPO' "${1}") || return 1
445 maxargs 'patch' 1 "${@}" || return 1
447 echo "copy clean checkout into ${REPO}/patched-src"
448 "${RSYNC}" -avz --delete "${REPO}/src/" "${REPO}/patched-src/" || return 1
450 # apply all the patches in local-patch/
451 for FILE in "${REPO}/local-patch"/*.patch; do
452 if [ -f "${FILE}" ]; then
454 pushd "${REPO}/patched-src/" > /dev/null || return 1
455 "${PATCH}" -p0 < "../../${FILE}" || return 1
456 popd > /dev/null || return 1
460 # remove any files marked for removal in local-patch
461 for REMOVE in "${REPO}/local-patch"/*.remove; do
462 if [ -f "${REMOVE}" ]; then
464 if [ -z "${LINE}" ] || [ "${LINE:0:1}" = '#' ]; then
465 continue # ignore blank lines and comments
467 if [ -e "${REPO}/patched-src/${LINE}" ]; then
468 echo "remove ${LINE}"
469 "${RM}" -rf "${REPO}/patched-src/${LINE}"
483 echo 'Link a fresh checkout with local adjustments.'
484 if [ "${1}" = '--one-line' ]; then return; fi
488 usage: $0 ${COMMAND} [--force|--force-file] [--dry-run] [--no-backup] [REPO]
490 Where 'REPO' is the name the dotfiles repository to link. If it
491 is not given, all repositories will be linked.
493 By default, link.sh only replaces missing files and simlinks. You
494 can optionally overwrite any local files by passing the --force
501 FORCE='no' # If 'file', overwrite existing files.
502 # If 'yes', overwrite existing files and dirs.
503 DRY_RUN='no' # If 'yes', disable any actions that change the filesystem
505 while [ "${1::2}" = '--' ]; do
520 echo "ERROR: invalid option to link (${1})" >&2
525 # multi-repo case handled in main() by run_on_all_repos()
526 REPO=$(nonempty_option 'link' 'REPO' "${1}") || return 1
527 maxargs 'link' 1 "${@}" || return 1
528 DOTFILES_SRC="${DOTFILES_DIR}/${REPO}/patched-src"
531 if [ "${DOTFILES_SRC}/${FILE}" -ef "${TARGET}/${FILE}" ]; then
532 continue # already simlinked
534 if [ -d "${DOTFILES_SRC}/${FILE}" ] && [ -d "${TARGET}/${FILE}" ] && \
535 [ "${FORCE}" != 'yes' ]; then
536 echo "use --force to override the existing directory: ${TARGET}/${FILE}"
537 continue # allow unlinked directories
539 if [ -e "$TARGET/${FILE}" ] && [ "${FORCE}" = 'no' ]; then
540 echo "use --force to override the existing target: ${TARGET}/${FILE}"
541 continue # target already exists
543 link_file "${REPO}" "${FILE}" || return 1
545 $(list_files "${DOTFILES_SRC}")
552 COMMANDS+=('disconnect')
554 function disconnect_help()
556 echo 'Freeze dotfiles at their current state.'
557 if [ "${1}" = '--one-line' ]; then return; fi
561 usage: $0 ${COMMAND} [REPO]
563 Where 'REPO' is the name the dotfiles repository to disconnect.
564 If it is not given, all repositories will be disconnected.
566 You're about to give your sysadmin account to some newbie, and
567 they'd just be confused by all this efficiency. This script
568 freezes your dotfiles in their current state and makes everthing
569 look normal. Note that this will delete your dotfiles repository
570 and strip the dotfiles portion from your ~/.bashrc file.
574 function disconnect()
576 # multi-repo case handled in main() by run_on_all_repos()
577 REPO=$(nonempty_option 'link' 'REPO' "${1}") || return 1
578 maxargs 'disconnect' 1 "${@}" || return 1
579 DOTFILES_SRC="${DOTFILES_DIR}/${REPO}/patched-src"
581 # See if we've constructed any patched source files that might be
582 # possible link targets
583 if [ ! -d "${DOTFILES_SRC}" ]; then
584 echo 'no installed dotfiles to disconnect'
588 # See if the bashrc file is involved with dotfiles at all
592 if [ "${FILE}" = '.bashrc' ] && [ "$TARGET" -ef "${HOME}" ]; then
595 if [ "${DOTFILES_SRC}/${FILE}" -ef "${TARGET}/${FILE}" ] && [ -h "${TARGET}/${FILE}" ]; then
597 echo "de-symlink ${TARGET}/${FILE}"
598 "${RM}" -f "${TARGET}/${FILE}"
599 "${MV}" "${DOTFILES_SRC}/${FILE}" "${TARGET}/${FILE}"
602 $(list_files "${REPO}/patched-src")
605 if [ "${BASHRC}" == 'yes' ]; then
606 echo 'strip dotfiles section from ~/.bashrc'
607 "${SED}" '/DOTFILES_DIR/d' ~/.bashrc > bashrc_stripped
609 # see if the stripped file is any different
610 DIFF_OUTPUT=$("${DIFF}" ~/.bashrc bashrc_stripped)
612 if [ "${DIFF_RC}" -eq 0 ]; then
613 echo "no dotfiles section found in ~/.bashrc"
614 "${RM}" -f bashrc_stripped
615 elif [ "${DIFF_RC}" -eq 1 ]; then
616 echo "replace ~/.bashrc with stripped version"
618 "${MV}" bashrc_stripped ~/.bashrc
620 return 1 # diff failed, bail
624 if [ -d "${DOTFILES_DIR}/${REPO}" ]; then
625 echo "remove the ${REPO} repository"
626 "${RM}" -rf "${DOTFILES_DIR}/${REPO}"
635 function update_help()
637 echo 'Utility command that runs fetch, patch, and link.'
638 if [ "${1}" = '--one-line' ]; then return; fi
642 usage: $0 ${COMMAND} [REPO]
644 Where 'REPO' is the name the dotfiles repository to update.
645 If it is not given, all repositories will be updateed.
647 Run 'fetch', 'patch', and 'link' sequentially on each repository
648 to bring them in sync with the central repositories. Keeps track
649 of the last update time to avoid multiple fetches in the same
656 # multi-repo case handled in main() by run_on_all_repos()
657 REPO=$(nonempty_option 'link' 'REPO' "${1}") || return 1
658 maxargs 'disconnect' 1 "${@}" || return 1
660 # Update once a week from our remote repository. Mark updates by
661 # touching this file.
662 UPDATE_FILE="${REPO}/updated.$(date +%U)"
664 if [ ! -e "${UPDATE_FILE}" ]; then
665 echo "update ${REPO} dotfiles"
666 "${RM}" -f "${REPO}"/updated.* || return 1
667 "${TOUCH}" "${UPDATE_FILE}" || return 1
668 fetch "${REPO}" || return 1
669 patch "${REPO}" || return 1
670 link "${REPO}" || return 1
671 echo "${REPO} dotfiles updated"
680 echo 'Dotfiles management script.'
681 if [ "${1}" = '--one-line' ]; then return; fi
685 usage: $0 [OPTIONS] COMMAND [ARGS]
688 --help Print this help message and exit.
689 --version Print the $0 version and exit.
690 --dotfiles-dir DIR Directory containing the dotfiles reposotories. Defaults to '.'.
691 --target DIR Directory to install dotfiles into. Defaults to '~'.
695 for COMMAND in "${COMMANDS[@]}"; do
696 echo -en "${COMMAND}\t"
697 "${COMMAND}_help" --one-line
701 To get help on any command, pass the '--help' as the first option
702 to the command. For example:
704 ${0} ${COMMANDS[0]} --help
711 while [ "${1::2}" = '--' ]; do
714 main_help || return 1
730 echo "ERROR: invalid option to ${0} (${1})" >&2
735 COMMAND=$(get_selection "${1}" "${COMMANDS[@]}") || return 1
738 cd "${DOTFILES_DIR}" || return 1
740 if [ "${1}" = '--help' ]; then
741 "${COMMAND}_help" || return 1
742 elif [ "${COMMAND}" = 'clone' ]; then
743 "${COMMAND}" "${@}" || return 1
746 while [ "${1::2}" = '--' ]; do
750 if [ "${#}" -eq 0 ]; then
751 run_on_all_repos "${COMMAND}" "${OPTIONS[@]}" || return 1
753 maxargs "${0}" 1 "${@}" || return 1
754 "${COMMAND}" "${OPTIONS[@]}" "${1}" || return 1
759 main "${@}" || exit 1