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'
29 DIFF=${DOTFILES_DIFF:-$(which diff)}
30 GIT=${DOTFILES_GIT:-$(which git)}
31 LN=${DOTFILES_LN:-$(which ln)}
32 MV=${DOTFILES_MV:-$(which mv)}
33 PATCH=${DOTFILES_PATCH:-$(which patch)}
34 SED=${DOTFILES_SED:-$(which sed)}
35 RM=${DOTFILES_RM:-$(which rm)}
36 RSYNC=${DOTFILES_RSYNC:-$(which rsync)}
37 TAR=${DOTFILES_TAR:-$(which tar)}
38 TOUCH=${DOTFILES_TOUCH:-$(which touch)}
39 WGET=${DOTFILES_WGET:-$(which wget)}
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 arrays 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. If you want to
236 # override the options passed to ${LN}, set LINK_OPTS.
239 REPO=$(nonempty_option 'link_file' 'REPO' "${1}") || return 1
240 FILE=$(nonempty_option 'link_file' 'FILE' "${2}") || return 1
241 LINK_OPTS="${LINK_OPTS:--sv}" # default to `-sv`
242 if [ "${BACKUP}" = 'yes' ]; then
243 if [ -e "${TARGET}/${FILE}" ] || [ -h "${TARGET}/${FILE}" ]; then
244 if [ "${DRY_RUN}" = 'yes' ]; then
245 echo "move ${TARGET}/${FILE} to ${TARGET}/${FILE}.bak"
248 mv -v "${TARGET}/${FILE}" "${TARGET}/${FILE}.bak" || return 1
252 if [ "${DRY_RUN}" = 'yes' ]; then
253 echo "rm ${TARGET}/${FILE}"
255 "${RM}" -fv "${TARGET}/${FILE}"
258 if [ "${DRY_RUN}" = 'yes' ]; then
259 echo "link ${TARGET}/${FILE} to ${DOTFILES_DIR}/${REPO}/patched-src/${FILE}"
261 SOURCE="${DOTFILES_DIR}/${REPO}/patched-src/${FILE}"
263 "${LN}" ${LINK_OPTS} "${SOURCE}" "${TARGET}/${FILE}" || return 1
270 # An array of available commands
278 CLONE_TRANSFERS=('git' 'wget')
280 function clone_help()
282 echo 'Create a new dotfiles repository.'
283 if [ "${1}" = '--one-line' ]; then return; fi
287 usage: $0 ${COMMAND} REPO TRANSFER URL
289 Where 'REPO' is the name the dotfiles repository to create,
290 'TRANSFER' is the transfer mechanism, and 'URL' is the URL for the
291 remote repository. Valid TRANSFERs are:
293 ${CLONE_TRANSFERS[@]}
297 $0 clone public wget http://example.com/public-dotfiles.tar.gz
298 $0 clone private git ssh://example.com/~/private-dotfiles.git
304 REPO=$(nonempty_option 'clone' 'REPO' "${1}") || return 1
305 TRANSFER=$(nonempty_option 'clone' 'TRANSFER' "${2}") || return 1
306 URL=$(nonempty_option 'clone' 'URL' "${3}") || return 1
307 maxargs 'clone' 3 "${@}" || return 1
308 TRANSFER=$(get_selection "${TRANSFER}" "${CLONE_TRANSFERS[@]}") || return 1
309 if [ -e "${REPO}" ]; then
310 echo "ERROR: destination path (${REPO}) already exists." >&2
315 case "${TRANSFER}" in
319 "${GIT}" clone "${URL}" "${REPO}" || return 1
325 echo "PROGRAMMING ERROR: add ${TRANSFER} support to clone command" >&2
328 if [ "${CACHE_SOURCE}" = 'yes' ]; then
329 REPO_SOURCE_DATA=(['transfer']="${TRANSFER}" ['url']="${URL}")
330 set_repo_source "${REPO}" || return 1
332 if [ "${FETCH}" = 'yes' ]; then
333 fetch "${REPO}" || return 1
342 function fetch_help()
344 echo 'Get the current dotfiles from the server.'
345 if [ "${1}" = '--one-line' ]; then return; fi
349 usage: $0 ${COMMAND} [REPO]
351 Where 'REPO' is the name the dotfiles repository to fetch. If it
352 is not given, all repositories will be fetched.
358 # multi-repo case handled in main() by run_on_all_repos()
359 REPO=$(nonempty_option 'fetch' 'REPO' "${1}") || return 1
360 maxargs 'fetch' 1 "${@}" || return 1
361 if [ "${BASH_MAJOR}" -ge 4 ]; then
362 get_repo_source "${REPO}" || return 1
363 TRANSFER=$(nonempty_option 'fetch' 'TRANSFER' "${REPO_SOURCE_DATA['transfer']}") || return 1
365 echo "WARNING: Bash version < 4.0, assuming all repos use git transfer" >&2
368 if [ "${TRANSFER}" = 'git' ]; then
369 git_fetch "${REPO}" || return 1
370 elif [ "${TRANSFER}" = 'wget' ]; then
371 wget_fetch "${REPO}" || return 1
373 echo "PROGRAMMING ERROR: add ${TRANSFER} support to fetch command" >&2
385 echo 'Show differences between targets and dotfiles repositories.'
386 if [ "${1}" = '--one-line' ]; then return; fi
390 usage: $0 ${COMMAND} [--removed|--local-patch] [REPO]
392 Where 'REPO' is the name the dotfiles repository to query. If it
393 is not given, all repositories will be queried.
395 By default, ${COMMAND} will list differences between files that
396 exist in both the target location and the dotfiles repository (as
397 a patch that could be applied to the dotfiles source).
399 With the '--removed' option, ${COMMAND} will list files that
400 should be removed from the dotfiles source in order to match the
403 With the '--local-patch' option, ${COMMAND} will create files in
404 list files that should be removed from the dotfiles source in
405 order to match the target.
412 while [ "${1::2}" = '--' ]; do
421 echo "ERROR: invalid option to diff (${1})" >&2
426 # multi-repo case handled in main() by run_on_all_repos()
427 REPO=$(nonempty_option 'diff' 'REPO' "${1}") || return 1
428 maxargs 'diff' 1 "${@}" || return 1
430 if [ "${MODE}" = 'local-patch' ]; then
431 mkdir -p "${REPO}/local-patch" || return 1
433 exec 3<&1 # save stdout to file descriptor 3
434 echo "save local patches to ${REPO}/local-patch/000-local.patch"
435 exec 1>"${REPO}/local-patch/000-local.patch" # redirect stdout
437 exec 1<&3 # restore old stdout
438 exec 3<&- # close temporary fd 3
440 exec 3<&1 # save stdout to file descriptor 3
441 echo "save local removed to ${REPO}/local-patch/000-local.remove"
442 exec 1>"${REPO}/local-patch/000-local.remove" # redirect stdout
443 diff --removed "${REPO}"
444 exec 1<&3 # restore old stdout
445 exec 3<&- # close temporary fd 3
450 if [ "${MODE}" = 'removed' ]; then
451 if [ ! -e "${TARGET}/${FILE}" ]; then
454 elif [ -f "${TARGET}/${FILE}" ]; then
455 (cd "${REPO}/src" && "${DIFF}" -u "${FILE}" "${TARGET}/${FILE}")
458 $(list_files "${REPO}/src")
467 function patch_help()
469 echo 'Patch a fresh checkout with local adjustments.'
470 if [ "${1}" = '--one-line' ]; then return; fi
474 usage: $0 ${COMMAND} [REPO]
476 Where 'REPO' is the name the dotfiles repository to patch. If it
477 is not given, all repositories will be patched.
483 # multi-repo case handled in main() by run_on_all_repos()
484 REPO=$(nonempty_option 'patch' 'REPO' "${1}") || return 1
485 maxargs 'patch' 1 "${@}" || return 1
487 echo "copy clean checkout into ${REPO}/patched-src"
488 "${RSYNC}" -avz --delete "${REPO}/src/" "${REPO}/patched-src/" || return 1
490 # apply all the patches in local-patch/
491 for FILE in "${REPO}/local-patch"/*.patch; do
492 if [ -f "${FILE}" ]; then
494 pushd "${REPO}/patched-src/" > /dev/null || return 1
495 "${PATCH}" -p1 < "../../${FILE}" || return 1
496 popd > /dev/null || return 1
500 # remove any files marked for removal in local-patch
501 for REMOVE in "${REPO}/local-patch"/*.remove; do
502 if [ -f "${REMOVE}" ]; then
505 if [ -z "${LINE}" ] || [ "${LINE:0:1}" = '#' ]; then
506 continue # ignore blank lines and comments
508 if [ -e "${REPO}/patched-src/${LINE}" ]; then
509 echo "remove ${LINE}"
510 "${RM}" -rf "${REPO}/patched-src/${LINE}"
524 echo 'Link a fresh checkout with local adjustments.'
525 if [ "${1}" = '--one-line' ]; then return; fi
529 usage: $0 ${COMMAND} [--force] [--force-dir] [--force-file] [--force-link]
530 [--dry-run] [--no-backup] [--relative] [REPO]
532 Where 'REPO' is the name the dotfiles repository to link. If it
533 is not given, all repositories will be linked.
535 By default, ${COMMAND} only replaces missing directories, files,
536 simlinks. You can optionally overwrite any local stuff by passing
537 the --force option. If you only want to overwrite a particular
538 type, use the more granular --force-dir, etc.
540 If you have coreutils 8.16 (2012-03-26) or greater, you can set
541 the --relative option to create symlinks that use relative paths.
547 FORCE_DIR='no' # If 'yes', overwrite existing directories.
548 FORCE_FILE='no' # If 'yes', overwrite existing files.
549 FORCE_LINK='no' # If 'yes', overwrite existing symlinks.
550 DRY_RUN='no' # If 'yes', disable any actions that change the filesystem
553 while [ "${1::2}" = '--' ]; do
576 LINK_OPTS="${LINK_OPTS} --relative"
579 echo "ERROR: invalid option to link (${1})" >&2
584 # multi-repo case handled in main() by run_on_all_repos()
585 REPO=$(nonempty_option 'link' 'REPO' "${1}") || return 1
586 maxargs 'link' 1 "${@}" || return 1
587 DOTFILES_SRC="${DOTFILES_DIR}/${REPO}/patched-src"
590 BACKUP="${BACKUP_OPT}"
591 if [ "${DOTFILES_SRC}/${FILE}" -ef "${TARGET}/${FILE}" ]; then
592 if [ "${FORCE_LINK}" = 'no' ]; then
593 # don't prompt about --force-link, because this will happen a lot
594 continue # already simlinked
595 elif [ ! -h "${TARGET}/${FILE}" ]; then
596 # target file/dir underneath an already symlinked dir
599 # don't backup links that already point to the right place
603 if [ -d "${DOTFILES_SRC}/${FILE}" ] && [ -d "${TARGET}/${FILE}" ] && \
604 [ "${FORCE_DIR}" = 'no' ]; then
605 echo "use --force-dir to override the existing directory: ${TARGET}/${FILE}"
606 continue # allow unlinked directories
607 elif [ -f "${TARGET}/${FILE}" ] && [ "${FORCE_FILE}" = 'no' ]; then
608 echo "use --force-file to override the existing target: ${TARGET}/${FILE}"
609 continue # target already exists
612 link_file "${REPO}" "${FILE}" || return 1
614 $(list_files "${DOTFILES_SRC}")
621 COMMANDS+=('disconnect')
623 function disconnect_help()
625 echo 'Freeze dotfiles at their current state.'
626 if [ "${1}" = '--one-line' ]; then return; fi
630 usage: $0 ${COMMAND} [REPO]
632 Where 'REPO' is the name the dotfiles repository to disconnect.
633 If it is not given, all repositories will be disconnected.
635 You're about to give your sysadmin account to some newbie, and
636 they'd just be confused by all this efficiency. This script
637 freezes your dotfiles in their current state and makes everthing
638 look normal. Note that this will delete your dotfiles repository
639 and strip the dotfiles portion from your ~/.bashrc file.
643 function disconnect()
645 # multi-repo case handled in main() by run_on_all_repos()
646 REPO=$(nonempty_option 'disconnect' 'REPO' "${1}") || return 1
647 maxargs 'disconnect' 1 "${@}" || return 1
648 DOTFILES_SRC="${DOTFILES_DIR}/${REPO}/patched-src"
650 # See if we've constructed any patched source files that might be
651 # possible link targets
652 if [ ! -d "${DOTFILES_SRC}" ]; then
653 echo 'no installed dotfiles to disconnect'
657 # See if the bashrc file is involved with dotfiles at all
661 if [ "${FILE}" = '.bashrc' ] && [ "${TARGET}" -ef "${HOME}" ]; then
664 if [ "${DOTFILES_SRC}/${FILE}" -ef "${TARGET}/${FILE}" ] && [ -h "${TARGET}/${FILE}" ]; then
666 echo "de-symlink ${TARGET}/${FILE}"
667 "${RM}" -f "${TARGET}/${FILE}"
668 "${MV}" "${DOTFILES_SRC}/${FILE}" "${TARGET}/${FILE}"
671 $(list_files "${REPO}/patched-src")
674 if [ "${BASHRC}" == 'yes' ]; then
675 echo 'strip dotfiles section from ~/.bashrc'
676 "${SED}" '/DOTFILES_DIR/d' ~/.bashrc > bashrc_stripped
678 # see if the stripped file is any different
679 DIFF_OUTPUT=$("${DIFF}" ~/.bashrc bashrc_stripped)
681 if [ "${DIFF_RC}" -eq 0 ]; then
682 echo "no dotfiles section found in ~/.bashrc"
683 "${RM}" -f bashrc_stripped
684 elif [ "${DIFF_RC}" -eq 1 ]; then
685 echo "replace ~/.bashrc with stripped version"
687 "${MV}" bashrc_stripped ~/.bashrc
689 return 1 # diff failed, bail
693 if [ -d "${DOTFILES_DIR}/${REPO}" ]; then
694 echo "remove the ${REPO} repository"
695 "${RM}" -rf "${DOTFILES_DIR}/${REPO}"
704 function update_help()
706 echo 'Utility command that runs fetch, patch, and link.'
707 if [ "${1}" = '--one-line' ]; then return; fi
711 usage: $0 ${COMMAND} [options] [REPO]
713 Where 'REPO' is the name the dotfiles repository to update.
714 If it is not given, all repositories will be updateed.
716 Run 'fetch', 'patch', and 'link' sequentially on each repository
717 to bring them in sync with the central repositories. Keeps track
718 of the last update time to avoid multiple fetches in the same
721 ${COMMAND} passes any options it receives through to the link
729 while [ "${1::2}" = '--' ]; do
730 LINK_OPTS="${LINK_FN_OPTS} ${1}"
733 # multi-repo case handled in main() by run_on_all_repos()
734 REPO=$(nonempty_option 'update' 'REPO' "${1}") || return 1
735 maxargs 'disconnect' 1 "${@}" || return 1
737 # Update once a week from our remote repository. Mark updates by
738 # touching this file.
739 UPDATE_FILE="${REPO}/updated.$(date +%U)"
741 if [ ! -e "${UPDATE_FILE}" ]; then
742 echo "update ${REPO} dotfiles"
743 "${RM}" -f "${REPO}"/updated.* || return 1
744 "${TOUCH}" "${UPDATE_FILE}" || return 1
745 fetch "${REPO}" || return 1
746 patch "${REPO}" || return 1
747 link ${LINK_OPTS} "${REPO}" || return 1
748 echo "${REPO} dotfiles updated"
757 echo 'Dotfiles management script.'
758 if [ "${1}" = '--one-line' ]; then return; fi
762 usage: $0 [OPTIONS] COMMAND [ARGS]
765 --help Print this help message and exit.
766 --version Print the $0 version and exit.
767 --dotfiles-dir DIR Directory containing the dotfiles reposotories. Defaults to '.'.
768 --target DIR Directory to install dotfiles into. Defaults to '~'.
772 for COMMAND in "${COMMANDS[@]}"; do
773 echo -en "${COMMAND}\t"
774 "${COMMAND}_help" --one-line
778 To get help on any command, pass the '--help' as the first option
779 to the command. For example:
781 ${0} ${COMMANDS[0]} --help
788 while [ "${1::2}" = '--' ]; do
791 main_help || return 1
807 echo "ERROR: invalid option to ${0} (${1})" >&2
812 COMMAND=$(get_selection "${1}" "${COMMANDS[@]}") || return 1
815 cd "${DOTFILES_DIR}" || return 1
817 if [ "${1}" = '--help' ]; then
818 "${COMMAND}_help" || return 1
819 elif [ "${COMMAND}" = 'clone' ]; then
820 "${COMMAND}" "${@}" || return 1
823 while [ "${1::2}" = '--' ]; do
827 if [ "${#}" -eq 0 ]; then
828 run_on_all_repos "${COMMAND}" "${OPTIONS[@]}" || return 1
830 maxargs "${0}" 1 "${@}" || return 1
831 "${COMMAND}" "${OPTIONS[@]}" "${1}" || return 1
836 main "${@}" || exit 1