3 # Dotfiles management script. For details, run
6 # Copyright (C) 2011-2012 W. Trevor King <wking@tremily.us>
8 # This program is free software: you can redistribute it and/or modify
9 # it under the terms of the GNU General Public License as published by
10 # the Free Software Foundation, either version 3 of the License, or
11 # (at your option) any later version.
13 # This program is distributed in the hope that it will be useful,
14 # but WITHOUT ANY WARRANTY; without even the implied warranty of
15 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
16 # GNU General Public License for more details.
18 # You should have received a copy of the GNU General Public License
19 # along with this program. If not, see <http://www.gnu.org/licenses/>.
24 CHECK_WGET_TYPE_AND_ENCODING='no'
42 # Compatibility checks
44 BASH="${BASH_VERSION%.*}"
45 BASH_MAJOR="${BASH%.*}"
46 BASH_MINOR="${BASH#*.}"
48 if [ "${BASH_MAJOR}" -eq 3 ] && [ "${BASH_MINOR}" -eq 0 ]; then
49 echo "ERROR: ${0} requires Bash version >= 3.1" >&2
50 echo "you're running ${BASH}, which doesn't support += array assignment" >&2
57 # usage: nonempty_option LOC NAME VALUE
58 function nonempty_option()
63 if [ -z "${VALUE}" ]; then
64 echo "ERROR: empty value for ${NAME} in ${LOC}" >&2
70 # usage: maxargs LOC MAX "${@}"
72 # Print and error and return 1 if there are more than MAX arguments.
78 if [ "${#}" -gt "${MAX}" ]; then
79 echo "ERROR: too many arguments (${#} > ${MAX}) in ${LOC}" >&2
84 # usage: get_selection CHOICE OPTION ...
86 # Check that CHOICE is one of the valid options listed in OPTION. If
87 # it is, echo the choice and return 0, otherwise print an error to
88 # stderr and return 1.
89 function get_selection()
94 if [ "${OPT}" = "${CHOICE}" ]; then
99 echo "ERROR: invalid selection (${CHOICE})" >&2
100 echo "valid choices: ${@}" >&2
104 function run_on_all_repos()
108 if [ -z "${REPO}" ]; then # run on all repositories
110 if [ "${REPO}" = '*' ]; then
111 break # no known repositories
112 elif [ -f "${REPO}" ]; then
113 continue # repositories are directories
115 "${COMMAND}" "${@}" "${REPO}" || return 1
121 function list_files()
123 DIR=$(nonempty_option 'list_files' 'DIR' "${1}") || return 1
125 if [ "${FILE}" = '.' ]; then
128 FILE="${FILE:2}" # strip the leading './'
130 done < <(cd "${DIR}" && find .)
133 # Global variable to allow passing associative arrats between functions
135 if [ "${BASH_MAJOR}" -ge 4 ]; then
136 declare -A REPO_SOURCE_DATA
139 function set_repo_source()
141 if [ "${BASH_MAJOR}" -lt 4 ]; then
142 echo "ERROR: ${0}'s set_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 'set_repo_source' 'REPO' "${1}") || return 1
147 > "${REPO}/source_cache" || return 1
148 for KEY in "${!REPO_SOURCE_DATA[@]}"; do
149 echo "${KEY}=${REPO_SOURCE_DATA[${KEY}]}" >> "${REPO}/source_cache" || return 1
153 # usage: get_repo_source REPO
154 function get_repo_source()
156 if [ "${BASH_MAJOR}" -lt 4 ]; then
157 echo "ERROR: ${0}'s get_repo_source() requires Bash version >= 4.0" >&2
158 echo "you're running ${BASH}, which doesn't support associative arrays" >&2
161 REPO=$(nonempty_option 'get_repo_source' 'REPO' "${1}") || return 1
163 if [ -f "${REPO}/source_cache" ]; then
167 REPO_SOURCE_DATA["${KEY}"]="${VALUE}"
168 done < "${REPO}/source_cache"
170 # autodetect verson control system
172 REPO_SOURCE_DATA['repo']="${REPO}"
173 if [ -d "${REPO}/.git" ]; then
174 REPO_SOURCE_DATA['transfer']='git'
176 echo "ERROR: no source location found for ${REPO}" >&2
179 # no need to get further fields for these transfer mechanisms
185 REPO=$(nonempty_option 'git_fetch' 'REPO' "${1}") || return 1
186 REMOTES=$(cd "${REPO}" && "${GIT}" remote) || return 1
187 if [ -n "${REMOTES}" ]; then
188 (cd "${REPO}" && "${GIT}" pull) || return 1
190 echo "no remote repositories found for ${REPO}"
194 function wget_fetch()
196 REPO=$(nonempty_option 'wget_fetch' 'REPO' "${1}") || return 1
197 # get_repo_source() was just called on this repo in fetch()
198 TRANSFER=$(nonempty_option 'wget_fetch' 'TRANSFER' "${REPO_SOURCE_DATA['transfer']}") || return 1
199 URL=$(nonempty_option 'wget_fetch' 'URL' "${REPO_SOURCE_DATA['url']}") || return 1
200 ETAG="${REPO_SOURCE_DATA['etag']}"
202 HEAD=$("${WGET}" --server-response --spider "${URL}" 2>&1) || return 1
203 SERVER_ETAG=$(echo "${HEAD}" | "${SED}" -n 's/^ *etag: *"\(.*\)"/\1/ip') || return 1
204 if [ "${CHECK_WGET_TYPE_AND_ENCODING}" = 'yes' ]; then
205 TYPE=$(echo "${HEAD}" | "${SED}" -n 's/^ *content-type: *//ip') || return 1
206 ENCODING=$(echo "${HEAD}" | "${SED}" -n 's/^ *content-encoding: *//ip') || return 1
207 if [ "${TYPE}" != 'application/x-gzip' ] || [ "${ENCODING}" != 'x-gzip' ]; then
208 echo "ERROR: invalid content type (${TYPE}) or encoding (${ENCODING})." >&2
209 echo "while fetching ${URL}" >&2
213 if [ -z "${ETAG}" ] || [ "${SERVER_ETAG}" != "${ETAG}" ]; then
214 # Previous ETag not known, or ETag changed. Download new copy.
215 "${WGET}" --output-document "${BUNDLE}" "${URL}" || return 1
216 if [ -n "${SERVER_ETAG}" ]; then # store new ETag
217 REPO_SOURCE_DATA['etag']="${SERVER_ETAG}"
218 set_repo_source "${REPO}" || return 1
219 elif [ -n "${ETAG}" ]; then # clear old ETag
220 unset "${REPO_SOURCE_DATA['etag']}"
221 set_repo_source "${REPO}" || return 1
223 echo "extracting ${BUNDLE} to ${REPO}"
224 "${TAR}" -xf "${BUNDLE}" -C "${REPO}" --strip-components 1 --overwrite || return 1
225 "${RM}" -f "${BUNDLE}" || return 1
227 echo "already downloaded the ETag=${ETAG} version of ${URL}"
232 # usage: link_file REPO FILE
234 # Create the symbolic link to the version of FILE in the REPO
235 # repository, overriding the target if it exists.
238 REPO=$(nonempty_option 'link_file' 'REPO' "${1}") || return 1
239 FILE=$(nonempty_option 'link_file' 'FILE' "${2}") || return 1
240 if [ "${BACKUP}" = 'yes' ]; then
241 if [ -e "${TARGET}/${FILE}" ] || [ -h "${TARGET}/${FILE}" ]; then
242 if [ "${DRY_RUN}" = 'yes' ]; then
243 echo "move ${TARGET}/${FILE} to ${TARGET}/${FILE}.bak"
246 mv -v "${TARGET}/${FILE}" "${TARGET}/${FILE}.bak" || return 1
250 if [ "${DRY_RUN}" = 'yes' ]; then
251 echo "rm ${TARGET}/${FILE}"
253 "${RM}" -fv "${TARGET}/${FILE}"
256 if [ "${DRY_RUN}" = 'yes' ]; then
257 echo "link ${TARGET}/${FILE} to ${DOTFILES_DIR}/${REPO}/patched-src/${FILE}"
260 "${LN}" -rsv "${DOTFILES_DIR}/${REPO}/patched-src/${FILE}" "${TARGET}/${FILE}" || return 1
267 # An array of available commands
275 CLONE_TRANSFERS=('git' 'wget')
277 function clone_help()
279 echo 'Create a new dotfiles repository.'
280 if [ "${1}" = '--one-line' ]; then return; fi
284 usage: $0 ${COMMAND} REPO TRANSFER URL
286 Where 'REPO' is the name the dotfiles repository to create,
287 'TRANSFER' is the transfer mechanism, and 'URL' is the URL for the
288 remote repository. Valid TRANSFERs are:
290 ${CLONE_TRANSFERS[@]}
294 $0 clone public wget http://example.com/public-dotfiles.tar.gz
295 $0 clone private git ssh://example.com/~/private-dotfiles.git
301 REPO=$(nonempty_option 'clone' 'REPO' "${1}") || return 1
302 TRANSFER=$(nonempty_option 'clone' 'TRANSFER' "${2}") || return 1
303 URL=$(nonempty_option 'clone' 'URL' "${3}") || return 1
304 maxargs 'clone' 3 "${@}" || return 1
305 TRANSFER=$(get_selection "${TRANSFER}" "${CLONE_TRANSFERS[@]}") || return 1
306 if [ -e "${REPO}" ]; then
307 echo "ERROR: destination path (${REPO}) already exists." >&2
312 case "${TRANSFER}" in
316 "${GIT}" clone "${URL}" "${REPO}" || return 1
322 echo "PROGRAMMING ERROR: add ${TRANSFER} support to clone command" >&2
325 if [ "${CACHE_SOURCE}" = 'yes' ]; then
326 REPO_SOURCE_DATA=(['transfer']="${TRANSFER}" ['url']="${URL}")
327 set_repo_source "${REPO}" || return 1
329 if [ "${FETCH}" = 'yes' ]; then
330 fetch "${REPO}" || return 1
339 function fetch_help()
341 echo 'Get the current dotfiles from the server.'
342 if [ "${1}" = '--one-line' ]; then return; fi
346 usage: $0 ${COMMAND} [REPO]
348 Where 'REPO' is the name the dotfiles repository to fetch. If it
349 is not given, all repositories will be fetched.
355 # multi-repo case handled in main() by run_on_all_repos()
356 REPO=$(nonempty_option 'fetch' 'REPO' "${1}") || return 1
357 maxargs 'fetch' 1 "${@}" || return 1
358 if [ "${BASH_MAJOR}" -ge 4 ]; then
359 get_repo_source "${REPO}" || return 1
360 TRANSFER=$(nonempty_option 'fetch' 'TRANSFER' "${REPO_SOURCE_DATA['transfer']}") || return 1
362 echo "WARNING: Bash version < 4.0, assuming all repos use git transfer" >&2
365 if [ "${TRANSFER}" = 'git' ]; then
366 git_fetch "${REPO}" || return 1
367 elif [ "${TRANSFER}" = 'wget' ]; then
368 wget_fetch "${REPO}" || return 1
370 echo "PROGRAMMING ERROR: add ${TRANSFER} support to fetch command" >&2
382 echo 'Show differences between targets and dotfiles repositories.'
383 if [ "${1}" = '--one-line' ]; then return; fi
387 usage: $0 ${COMMAND} [--removed|--local-patch] [REPO]
389 Where 'REPO' is the name the dotfiles repository to query. If it
390 is not given, all repositories will be queried.
392 By default, ${COMMAND} will list differences between files that
393 exist in both the target location and the dotfiles repository (as
394 a patch that could be applied to the dotfiles source).
396 With the '--removed' option, ${COMMAND} will list files that
397 should be removed from the dotfiles source in order to match the
400 With the '--local-patch' option, ${COMMAND} will create files in
401 list files that should be removed from the dotfiles source in
402 order to match the target.
409 while [ "${1::2}" = '--' ]; do
418 echo "ERROR: invalid option to diff (${1})" >&2
423 # multi-repo case handled in main() by run_on_all_repos()
424 REPO=$(nonempty_option 'diff' 'REPO' "${1}") || return 1
425 maxargs 'diff' 1 "${@}" || return 1
427 if [ "${MODE}" = 'local-patch' ]; then
428 mkdir -p "${REPO}/local-patch" || return 1
430 exec 3<&1 # save stdout to file descriptor 3
431 echo "save local patches to ${REPO}/local-patch/000-local.patch"
432 exec 1>"${REPO}/local-patch/000-local.patch" # redirect stdout
434 exec 1<&3 # restore old stdout
435 exec 3<&- # close temporary fd 3
437 exec 3<&1 # save stdout to file descriptor 3
438 echo "save local removed to ${REPO}/local-patch/000-local.remove"
439 exec 1>"${REPO}/local-patch/000-local.remove" # redirect stdout
440 diff --removed "${REPO}"
441 exec 1<&3 # restore old stdout
442 exec 3<&- # close temporary fd 3
447 if [ "${MODE}" = 'removed' ]; then
448 if [ ! -e "${TARGET}/${FILE}" ]; then
451 elif [ -f "${TARGET}/${FILE}" ]; then
452 (cd "${REPO}/src" && "${DIFF}" -u "${FILE}" "${TARGET}/${FILE}")
455 $(list_files "${REPO}/src")
464 function patch_help()
466 echo 'Patch a fresh checkout with local adjustments.'
467 if [ "${1}" = '--one-line' ]; then return; fi
471 usage: $0 ${COMMAND} [REPO]
473 Where 'REPO' is the name the dotfiles repository to patch. If it
474 is not given, all repositories will be patched.
480 # multi-repo case handled in main() by run_on_all_repos()
481 REPO=$(nonempty_option 'patch' 'REPO' "${1}") || return 1
482 maxargs 'patch' 1 "${@}" || return 1
484 echo "copy clean checkout into ${REPO}/patched-src"
485 "${RSYNC}" -avz --delete "${REPO}/src/" "${REPO}/patched-src/" || return 1
487 # apply all the patches in local-patch/
488 for FILE in "${REPO}/local-patch"/*.patch; do
489 if [ -f "${FILE}" ]; then
491 pushd "${REPO}/patched-src/" > /dev/null || return 1
492 "${PATCH}" -p1 < "../../${FILE}" || return 1
493 popd > /dev/null || return 1
497 # remove any files marked for removal in local-patch
498 for REMOVE in "${REPO}/local-patch"/*.remove; do
499 if [ -f "${REMOVE}" ]; then
502 if [ -z "${LINE}" ] || [ "${LINE:0:1}" = '#' ]; then
503 continue # ignore blank lines and comments
505 if [ -e "${REPO}/patched-src/${LINE}" ]; then
506 echo "remove ${LINE}"
507 "${RM}" -rf "${REPO}/patched-src/${LINE}"
521 echo 'Link a fresh checkout with local adjustments.'
522 if [ "${1}" = '--one-line' ]; then return; fi
526 usage: $0 ${COMMAND} [--force] [--force-dir] [--force-file] [--force-link]
527 [--dry-run] [--no-backup] [REPO]
529 Where 'REPO' is the name the dotfiles repository to link. If it
530 is not given, all repositories will be linked.
532 By default, ${COMMAND} only replaces missing directories, files,
533 simlinks. You can optionally overwrite any local stuff by passing
534 the --force option. If you only want to overwrite a particular
535 type, use the more granular --force-dir, etc.
541 FORCE_DIR='no' # If 'yes', overwrite existing directories.
542 FORCE_FILE='no' # If 'yes', overwrite existing files.
543 FORCE_LINK='no' # If 'yes', overwrite existing symlinks.
544 DRY_RUN='no' # If 'yes', disable any actions that change the filesystem
546 while [ "${1::2}" = '--' ]; do
569 echo "ERROR: invalid option to link (${1})" >&2
574 # multi-repo case handled in main() by run_on_all_repos()
575 REPO=$(nonempty_option 'link' 'REPO' "${1}") || return 1
576 maxargs 'link' 1 "${@}" || return 1
577 DOTFILES_SRC="${DOTFILES_DIR}/${REPO}/patched-src"
580 BACKUP="${BACKUP_OPT}"
581 if [ "${DOTFILES_SRC}/${FILE}" -ef "${TARGET}/${FILE}" ]; then
582 if [ "${FORCE_LINK}" = 'no' ]; then
583 # don't prompt about --force-link, because this will happen a lot
584 continue # already simlinked
586 # don't backup links that already point to the right place
590 if [ -d "${DOTFILES_SRC}/${FILE}" ] && [ -d "${TARGET}/${FILE}" ] && \
591 [ "${FORCE_DIR}" = 'no' ]; then
592 echo "use --force-dir to override the existing directory: ${TARGET}/${FILE}"
593 continue # allow unlinked directories
594 elif [ -f "${TARGET}/${FILE}" ] && [ "${FORCE_FILE}" = 'no' ]; then
595 echo "use --force-file to override the existing target: ${TARGET}/${FILE}"
596 continue # target already exists
599 link_file "${REPO}" "${FILE}" || return 1
601 $(list_files "${DOTFILES_SRC}")
608 COMMANDS+=('disconnect')
610 function disconnect_help()
612 echo 'Freeze dotfiles at their current state.'
613 if [ "${1}" = '--one-line' ]; then return; fi
617 usage: $0 ${COMMAND} [REPO]
619 Where 'REPO' is the name the dotfiles repository to disconnect.
620 If it is not given, all repositories will be disconnected.
622 You're about to give your sysadmin account to some newbie, and
623 they'd just be confused by all this efficiency. This script
624 freezes your dotfiles in their current state and makes everthing
625 look normal. Note that this will delete your dotfiles repository
626 and strip the dotfiles portion from your ~/.bashrc file.
630 function disconnect()
632 # multi-repo case handled in main() by run_on_all_repos()
633 REPO=$(nonempty_option 'link' 'REPO' "${1}") || return 1
634 maxargs 'disconnect' 1 "${@}" || return 1
635 DOTFILES_SRC="${DOTFILES_DIR}/${REPO}/patched-src"
637 # See if we've constructed any patched source files that might be
638 # possible link targets
639 if [ ! -d "${DOTFILES_SRC}" ]; then
640 echo 'no installed dotfiles to disconnect'
644 # See if the bashrc file is involved with dotfiles at all
648 if [ "${FILE}" = '.bashrc' ] && [ "${TARGET}" -ef "${HOME}" ]; then
651 if [ "${DOTFILES_SRC}/${FILE}" -ef "${TARGET}/${FILE}" ] && [ -h "${TARGET}/${FILE}" ]; then
653 echo "de-symlink ${TARGET}/${FILE}"
654 "${RM}" -f "${TARGET}/${FILE}"
655 "${MV}" "${DOTFILES_SRC}/${FILE}" "${TARGET}/${FILE}"
658 $(list_files "${REPO}/patched-src")
661 if [ "${BASHRC}" == 'yes' ]; then
662 echo 'strip dotfiles section from ~/.bashrc'
663 "${SED}" '/DOTFILES_DIR/d' ~/.bashrc > bashrc_stripped
665 # see if the stripped file is any different
666 DIFF_OUTPUT=$("${DIFF}" ~/.bashrc bashrc_stripped)
668 if [ "${DIFF_RC}" -eq 0 ]; then
669 echo "no dotfiles section found in ~/.bashrc"
670 "${RM}" -f bashrc_stripped
671 elif [ "${DIFF_RC}" -eq 1 ]; then
672 echo "replace ~/.bashrc with stripped version"
674 "${MV}" bashrc_stripped ~/.bashrc
676 return 1 # diff failed, bail
680 if [ -d "${DOTFILES_DIR}/${REPO}" ]; then
681 echo "remove the ${REPO} repository"
682 "${RM}" -rf "${DOTFILES_DIR}/${REPO}"
691 function update_help()
693 echo 'Utility command that runs fetch, patch, and link.'
694 if [ "${1}" = '--one-line' ]; then return; fi
698 usage: $0 ${COMMAND} [REPO]
700 Where 'REPO' is the name the dotfiles repository to update.
701 If it is not given, all repositories will be updateed.
703 Run 'fetch', 'patch', and 'link' sequentially on each repository
704 to bring them in sync with the central repositories. Keeps track
705 of the last update time to avoid multiple fetches in the same
712 # multi-repo case handled in main() by run_on_all_repos()
713 REPO=$(nonempty_option 'link' 'REPO' "${1}") || return 1
714 maxargs 'disconnect' 1 "${@}" || return 1
716 # Update once a week from our remote repository. Mark updates by
717 # touching this file.
718 UPDATE_FILE="${REPO}/updated.$(date +%U)"
720 if [ ! -e "${UPDATE_FILE}" ]; then
721 echo "update ${REPO} dotfiles"
722 "${RM}" -f "${REPO}"/updated.* || return 1
723 "${TOUCH}" "${UPDATE_FILE}" || return 1
724 fetch "${REPO}" || return 1
725 patch "${REPO}" || return 1
726 link "${REPO}" || return 1
727 echo "${REPO} dotfiles updated"
736 echo 'Dotfiles management script.'
737 if [ "${1}" = '--one-line' ]; then return; fi
741 usage: $0 [OPTIONS] COMMAND [ARGS]
744 --help Print this help message and exit.
745 --version Print the $0 version and exit.
746 --dotfiles-dir DIR Directory containing the dotfiles reposotories. Defaults to '.'.
747 --target DIR Directory to install dotfiles into. Defaults to '~'.
751 for COMMAND in "${COMMANDS[@]}"; do
752 echo -en "${COMMAND}\t"
753 "${COMMAND}_help" --one-line
757 To get help on any command, pass the '--help' as the first option
758 to the command. For example:
760 ${0} ${COMMANDS[0]} --help
767 while [ "${1::2}" = '--' ]; do
770 main_help || return 1
786 echo "ERROR: invalid option to ${0} (${1})" >&2
791 COMMAND=$(get_selection "${1}" "${COMMANDS[@]}") || return 1
794 cd "${DOTFILES_DIR}" || return 1
796 if [ "${1}" = '--help' ]; then
797 "${COMMAND}_help" || return 1
798 elif [ "${COMMAND}" = 'clone' ]; then
799 "${COMMAND}" "${@}" || return 1
802 while [ "${1::2}" = '--' ]; do
806 if [ "${#}" -eq 0 ]; then
807 run_on_all_repos "${COMMAND}" "${OPTIONS[@]}" || return 1
809 maxargs "${0}" 1 "${@}" || return 1
810 "${COMMAND}" "${OPTIONS[@]}" "${1}" || return 1
815 main "${@}" || exit 1