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-file] [--dry-run] [--no-backup] [REPO]
528 Where 'REPO' is the name the dotfiles repository to link. If it
529 is not given, all repositories will be linked.
531 By default, link.sh only replaces missing files and simlinks. You
532 can optionally overwrite any local files by passing the --force
539 FORCE='no' # If 'file', overwrite existing files.
540 # If 'yes', overwrite existing files and dirs.
541 DRY_RUN='no' # If 'yes', disable any actions that change the filesystem
543 while [ "${1::2}" = '--' ]; do
558 echo "ERROR: invalid option to link (${1})" >&2
563 # multi-repo case handled in main() by run_on_all_repos()
564 REPO=$(nonempty_option 'link' 'REPO' "${1}") || return 1
565 maxargs 'link' 1 "${@}" || return 1
566 DOTFILES_SRC="${DOTFILES_DIR}/${REPO}/patched-src"
569 if [ "${DOTFILES_SRC}/${FILE}" -ef "${TARGET}/${FILE}" ]; then
570 continue # already simlinked
572 if [ -d "${DOTFILES_SRC}/${FILE}" ] && [ -d "${TARGET}/${FILE}" ] && \
573 [ "${FORCE}" != 'yes' ]; then
574 echo "use --force to override the existing directory: ${TARGET}/${FILE}"
575 continue # allow unlinked directories
577 if [ -e "$TARGET/${FILE}" ] && [ "${FORCE}" = 'no' ]; then
578 echo "use --force to override the existing target: ${TARGET}/${FILE}"
579 continue # target already exists
581 link_file "${REPO}" "${FILE}" || return 1
583 $(list_files "${DOTFILES_SRC}")
590 COMMANDS+=('disconnect')
592 function disconnect_help()
594 echo 'Freeze dotfiles at their current state.'
595 if [ "${1}" = '--one-line' ]; then return; fi
599 usage: $0 ${COMMAND} [REPO]
601 Where 'REPO' is the name the dotfiles repository to disconnect.
602 If it is not given, all repositories will be disconnected.
604 You're about to give your sysadmin account to some newbie, and
605 they'd just be confused by all this efficiency. This script
606 freezes your dotfiles in their current state and makes everthing
607 look normal. Note that this will delete your dotfiles repository
608 and strip the dotfiles portion from your ~/.bashrc file.
612 function disconnect()
614 # multi-repo case handled in main() by run_on_all_repos()
615 REPO=$(nonempty_option 'link' 'REPO' "${1}") || return 1
616 maxargs 'disconnect' 1 "${@}" || return 1
617 DOTFILES_SRC="${DOTFILES_DIR}/${REPO}/patched-src"
619 # See if we've constructed any patched source files that might be
620 # possible link targets
621 if [ ! -d "${DOTFILES_SRC}" ]; then
622 echo 'no installed dotfiles to disconnect'
626 # See if the bashrc file is involved with dotfiles at all
630 if [ "${FILE}" = '.bashrc' ] && [ "${TARGET}" -ef "${HOME}" ]; then
633 if [ "${DOTFILES_SRC}/${FILE}" -ef "${TARGET}/${FILE}" ] && [ -h "${TARGET}/${FILE}" ]; then
635 echo "de-symlink ${TARGET}/${FILE}"
636 "${RM}" -f "${TARGET}/${FILE}"
637 "${MV}" "${DOTFILES_SRC}/${FILE}" "${TARGET}/${FILE}"
640 $(list_files "${REPO}/patched-src")
643 if [ "${BASHRC}" == 'yes' ]; then
644 echo 'strip dotfiles section from ~/.bashrc'
645 "${SED}" '/DOTFILES_DIR/d' ~/.bashrc > bashrc_stripped
647 # see if the stripped file is any different
648 DIFF_OUTPUT=$("${DIFF}" ~/.bashrc bashrc_stripped)
650 if [ "${DIFF_RC}" -eq 0 ]; then
651 echo "no dotfiles section found in ~/.bashrc"
652 "${RM}" -f bashrc_stripped
653 elif [ "${DIFF_RC}" -eq 1 ]; then
654 echo "replace ~/.bashrc with stripped version"
656 "${MV}" bashrc_stripped ~/.bashrc
658 return 1 # diff failed, bail
662 if [ -d "${DOTFILES_DIR}/${REPO}" ]; then
663 echo "remove the ${REPO} repository"
664 "${RM}" -rf "${DOTFILES_DIR}/${REPO}"
673 function update_help()
675 echo 'Utility command that runs fetch, patch, and link.'
676 if [ "${1}" = '--one-line' ]; then return; fi
680 usage: $0 ${COMMAND} [REPO]
682 Where 'REPO' is the name the dotfiles repository to update.
683 If it is not given, all repositories will be updateed.
685 Run 'fetch', 'patch', and 'link' sequentially on each repository
686 to bring them in sync with the central repositories. Keeps track
687 of the last update time to avoid multiple fetches in the same
694 # multi-repo case handled in main() by run_on_all_repos()
695 REPO=$(nonempty_option 'link' 'REPO' "${1}") || return 1
696 maxargs 'disconnect' 1 "${@}" || return 1
698 # Update once a week from our remote repository. Mark updates by
699 # touching this file.
700 UPDATE_FILE="${REPO}/updated.$(date +%U)"
702 if [ ! -e "${UPDATE_FILE}" ]; then
703 echo "update ${REPO} dotfiles"
704 "${RM}" -f "${REPO}"/updated.* || return 1
705 "${TOUCH}" "${UPDATE_FILE}" || return 1
706 fetch "${REPO}" || return 1
707 patch "${REPO}" || return 1
708 link "${REPO}" || return 1
709 echo "${REPO} dotfiles updated"
718 echo 'Dotfiles management script.'
719 if [ "${1}" = '--one-line' ]; then return; fi
723 usage: $0 [OPTIONS] COMMAND [ARGS]
726 --help Print this help message and exit.
727 --version Print the $0 version and exit.
728 --dotfiles-dir DIR Directory containing the dotfiles reposotories. Defaults to '.'.
729 --target DIR Directory to install dotfiles into. Defaults to '~'.
733 for COMMAND in "${COMMANDS[@]}"; do
734 echo -en "${COMMAND}\t"
735 "${COMMAND}_help" --one-line
739 To get help on any command, pass the '--help' as the first option
740 to the command. For example:
742 ${0} ${COMMANDS[0]} --help
749 while [ "${1::2}" = '--' ]; do
752 main_help || return 1
768 echo "ERROR: invalid option to ${0} (${1})" >&2
773 COMMAND=$(get_selection "${1}" "${COMMANDS[@]}") || return 1
776 cd "${DOTFILES_DIR}" || return 1
778 if [ "${1}" = '--help' ]; then
779 "${COMMAND}_help" || return 1
780 elif [ "${COMMAND}" = 'clone' ]; then
781 "${COMMAND}" "${@}" || return 1
784 while [ "${1::2}" = '--' ]; do
788 if [ "${#}" -eq 0 ]; then
789 run_on_all_repos "${COMMAND}" "${OPTIONS[@]}" || return 1
791 maxargs "${0}" 1 "${@}" || return 1
792 "${COMMAND}" "${OPTIONS[@]}" "${1}" || return 1
797 main "${@}" || exit 1