Wrap $TARGET in braces in disconnect().
[dotfiles-framework.git] / dotfiles.sh
1 #!/bin/bash
2 #
3 # Dotfiles management script.  For details, run
4 #   $ dotfiles.sh --help
5 #
6 # Copyright (C) 2011-2012 W. Trevor King <wking@tremily.us>
7 #
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.
12 #
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.
17 #
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/>.
20
21 VERSION='0.2'
22 DOTFILES_DIR="${PWD}"
23 TARGET=~
24 CHECK_WGET_TYPE_AND_ENCODING='no'
25
26 #####
27 # External utilities
28
29 DIFF=$(which diff)
30 GIT=$(which git)
31 LN=$(which ln)
32 MV=$(which mv)
33 PATCH=$(which patch)
34 SED=$(which sed)
35 RM=$(which rm)
36 RSYNC=$(which rsync)
37 TAR=$(which tar)
38 TOUCH=$(which touch)
39 WGET=$(which wget)
40
41 #####
42 # Compatibility checks
43
44 BASH="${BASH_VERSION%.*}"
45 BASH_MAJOR="${BASH%.*}"
46 BASH_MINOR="${BASH#*.}"
47
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
51         exit 1
52 fi
53
54 #####
55 # Utility functions
56
57 # usage: nonempty_option LOC NAME VALUE
58 function nonempty_option()
59 {
60         LOC="${1}"
61         NAME="${2}"
62         VALUE="${3}"
63         if [ -z "${VALUE}" ]; then
64                 echo "ERROR: empty value for ${NAME} in ${LOC}" >&2
65                 return 1
66         fi
67         echo "${VALUE}"
68 }
69
70 # usage: maxargs LOC MAX "${@}"
71 #
72 # Print and error and return 1 if there are more than MAX arguments.
73 function maxargs()
74 {
75         LOC="${1}"
76         MAX="${2}"
77         shift 2
78         if [ "${#}" -gt "${MAX}" ]; then
79                 echo "ERROR: too many arguments (${#} > ${MAX}) in ${LOC}" >&2
80                 return 1
81         fi
82 }
83
84 # usage: get_selection CHOICE OPTION ...
85 #
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()
90 {
91         CHOICE="${1}"
92         shift
93         for OPT in "${@}"; do
94         if [ "${OPT}" = "${CHOICE}" ]; then
95                 echo "${OPT}"
96                 return 0
97         fi
98         done
99         echo "ERROR: invalid selection (${CHOICE})" >&2
100         echo "valid choices: ${@}" >&2
101         return 1
102 }
103
104 function run_on_all_repos()
105 {
106         COMMAND="${1}"
107         shift
108         if [ -z "${REPO}" ]; then  # run on all repositories
109                 for REPO in *; do
110                         if [ "${REPO}" = '*' ]; then
111                                 break  # no known repositories
112                         elif [ -f "${REPO}" ]; then
113                                 continue  # repositories are directories
114                         fi
115                         "${COMMAND}" "${@}" "${REPO}" || return 1
116                 done
117                 return
118         fi
119 }
120
121 function list_files()
122 {
123         DIR=$(nonempty_option 'list_files' 'DIR' "${1}") || return 1
124         while read FILE; do
125                 if [ "${FILE}" = '.' ]; then
126                         continue
127                 fi
128                 FILE="${FILE:2}"  # strip the leading './'
129                 echo "${FILE}"
130         done < <(cd "${DIR}" && find .)
131 }
132
133 # Global variable to allow passing associative arrats between functions
134
135 if [ "${BASH_MAJOR}" -ge 4 ]; then
136         declare -A REPO_SOURCE_DATA
137 fi
138
139 function set_repo_source()
140 {
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
144                 return 1
145         fi
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
150         done
151 }
152
153 # usage: get_repo_source REPO
154 function get_repo_source()
155 {
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
159                 return 1
160         fi
161         REPO=$(nonempty_option 'get_repo_source' 'REPO' "${1}") || return 1
162         REPO_SOURCE_DATA=()
163         if [ -f "${REPO}/source_cache" ]; then
164                 while read LINE; do
165                         KEY="${LINE%%=*}"
166                         VALUE="${LINE#*=}"
167                         REPO_SOURCE_DATA["${KEY}"]="${VALUE}"
168                 done < "${REPO}/source_cache"
169         else
170                 # autodetect verson control system
171                 REPO_SOURCE_DATA=()
172                 REPO_SOURCE_DATA['repo']="${REPO}"
173                 if [ -d "${REPO}/.git" ]; then
174                         REPO_SOURCE_DATA['transfer']='git'
175                 else
176                         echo "ERROR: no source location found for ${REPO}" >&2
177                         return 1
178                 fi
179                 # no need to get further fields for these transfer mechanisms
180         fi
181 }
182
183 function git_fetch()
184 {
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
189         else
190                 echo "no remote repositories found for ${REPO}"
191         fi
192 }
193
194 function wget_fetch()
195 {
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']}"
201         BUNDLE="${REPO}.tgz"
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
210                         return 1
211                 fi
212         fi
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
222                 fi
223                 echo "extracting ${BUNDLE} to ${REPO}"
224                 "${TAR}" -xf "${BUNDLE}" -C "${REPO}" --strip-components 1 --overwrite || return 1
225                 "${RM}" -f "${BUNDLE}" || return 1
226         else
227                 echo "already downloaded the ETag=${ETAG} version of ${URL}"
228         fi
229 }
230
231
232 # usage: link_file REPO FILE
233 #
234 # Create the symbolic link to the version of FILE in the REPO
235 # repository, overriding the target if it exists.
236 function link_file()
237 {
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"
244                         else
245                                 echo -n 'move '
246                                 mv -v "${TARGET}/${FILE}" "${TARGET}/${FILE}.bak" || return 1
247                         fi
248                 fi
249         else
250                 if [ "${DRY_RUN}" = 'yes' ]; then
251                         echo "rm ${TARGET}/${FILE}"
252                 else
253                         "${RM}" -fv "${TARGET}/${FILE}"
254                 fi
255         fi
256         if [ "${DRY_RUN}" = 'yes' ]; then
257                 echo "link ${TARGET}/${FILE} to ${DOTFILES_DIR}/${REPO}/patched-src/${FILE}"
258         else
259                 echo -n 'link '
260                 "${LN}" -rsv "${DOTFILES_DIR}/${REPO}/patched-src/${FILE}" "${TARGET}/${FILE}" || return 1
261         fi
262 }
263
264 #####
265 # Top-level commands
266
267 # An array of available commands
268 COMMANDS=()
269
270 ###
271 # clone command
272
273 COMMANDS+=('clone')
274
275 CLONE_TRANSFERS=('git' 'wget')
276
277 function clone_help()
278 {
279         echo 'Create a new dotfiles repository.'
280         if [ "${1}" = '--one-line' ]; then return; fi
281
282         cat <<-EOF
283
284                 usage: $0 ${COMMAND} REPO TRANSFER URL
285
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:
289
290                   ${CLONE_TRANSFERS[@]}
291
292                 Examples:
293
294                   $0 clone public wget http://example.com/public-dotfiles.tar.gz
295                   $0 clone private git ssh://example.com/~/private-dotfiles.git
296         EOF
297 }
298
299 function clone()
300 {
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
308                 return 1
309         fi
310         CACHE_SOURCE='yes'
311         FETCH='yes'
312         case "${TRANSFER}" in
313                 'git')
314                         CACHE_SOURCE='no'
315                         FETCH='no'
316                         "${GIT}" clone "${URL}" "${REPO}" || return 1
317                         ;;
318                 'wget')
319                         mkdir -p "${REPO}"
320                         ;;
321                 *)
322                         echo "PROGRAMMING ERROR: add ${TRANSFER} support to clone command" >&2
323                         return 1
324         esac
325         if [ "${CACHE_SOURCE}" = 'yes' ]; then
326                 REPO_SOURCE_DATA=(['transfer']="${TRANSFER}" ['url']="${URL}")
327                 set_repo_source "${REPO}" || return 1
328         fi
329         if [ "${FETCH}" = 'yes' ]; then
330                 fetch "${REPO}" || return 1
331         fi
332 }
333
334 ###
335 # fetch command
336
337 COMMANDS+=('fetch')
338
339 function fetch_help()
340 {
341         echo 'Get the current dotfiles from the server.'
342         if [ "${1}" = '--one-line' ]; then return; fi
343
344         cat <<-EOF
345
346                 usage: $0 ${COMMAND} [REPO]
347
348                 Where 'REPO' is the name the dotfiles repository to fetch.  If it
349                 is not given, all repositories will be fetched.
350         EOF
351 }
352
353 function fetch()
354 {
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
361         else
362                 echo "WARNING: Bash version < 4.0, assuming all repos use git transfer" >&2
363                 TRANSFER='git'
364         fi
365         if [ "${TRANSFER}" = 'git' ]; then
366                 git_fetch "${REPO}" || return 1
367         elif [ "${TRANSFER}" = 'wget' ]; then
368                 wget_fetch "${REPO}" || return 1
369         else
370                 echo "PROGRAMMING ERROR: add ${TRANSFER} support to fetch command" >&2
371                 return 1
372         fi
373 }
374
375 ###
376 # fetch command
377
378 COMMANDS+=('diff')
379
380 function diff_help()
381 {
382         echo 'Show differences between targets and dotfiles repositories.'
383         if [ "${1}" = '--one-line' ]; then return; fi
384
385         cat <<-EOF
386
387                 usage: $0 ${COMMAND} [--removed|--local-patch] [REPO]
388
389                 Where 'REPO' is the name the dotfiles repository to query.  If it
390                 is not given, all repositories will be queried.
391
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).
395
396                 With the '--removed' option, ${COMMAND} will list files that
397                 should be removed from the dotfiles source in order to match the
398                 target.
399
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.
403         EOF
404 }
405
406 function diff()
407 {
408         MODE='standard'
409         while [ "${1::2}" = '--' ]; do
410                 case "${1}" in
411                         '--removed')
412                                 MODE='removed'
413                                 ;;
414                         '--local-patch')
415                                 MODE='local-patch'
416                                 ;;
417                         *)
418                                 echo "ERROR: invalid option to diff (${1})" >&2
419                                 return 1
420                         esac
421                 shift
422         done
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
426
427         if [ "${MODE}" = 'local-patch' ]; then
428                 mkdir -p "${REPO}/local-patch" || return 1
429
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
433                 diff "${REPO}"
434                 exec 1<&3     # restore old stdout
435                 exec 3<&-     # close temporary fd 3
436
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
443                 return
444         fi
445
446         while read FILE; do
447                 if [ "${MODE}" = 'removed' ]; then
448                         if [ ! -e "${TARGET}/${FILE}" ]; then
449                                 echo "${FILE}"
450                         fi
451                 elif [ -f "${TARGET}/${FILE}" ]; then
452                         (cd "${REPO}/src" && "${DIFF}" -u "${FILE}" "${TARGET}/${FILE}")
453                 fi
454         done <<-EOF
455                 $(list_files "${REPO}/src")
456         EOF
457 }
458
459 ###
460 # patch command
461
462 COMMANDS+=('patch')
463
464 function patch_help()
465 {
466         echo 'Patch a fresh checkout with local adjustments.'
467         if [ "${1}" = '--one-line' ]; then return; fi
468
469         cat <<-EOF
470
471                 usage: $0 ${COMMAND} [REPO]
472
473                 Where 'REPO' is the name the dotfiles repository to patch.  If it
474                 is not given, all repositories will be patched.
475         EOF
476 }
477
478 function patch()
479 {
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
483
484         echo "copy clean checkout into ${REPO}/patched-src"
485         "${RSYNC}" -avz --delete "${REPO}/src/" "${REPO}/patched-src/" || return 1
486
487         # apply all the patches in local-patch/
488         for FILE in "${REPO}/local-patch"/*.patch; do
489                 if [ -f "${FILE}" ]; then
490                         echo "apply ${FILE}"
491                         pushd "${REPO}/patched-src/" > /dev/null || return 1
492                         "${PATCH}" -p1 < "../../${FILE}" || return 1
493                         popd > /dev/null || return 1
494                 fi
495         done
496
497         # remove any files marked for removal in local-patch
498         for REMOVE in "${REPO}/local-patch"/*.remove; do
499                 if [ -f "${REMOVE}" ]; then
500                         echo "apply ${FILE}"
501                         while read LINE; do
502                                 if [ -z "${LINE}" ] || [ "${LINE:0:1}" = '#' ]; then
503                                         continue  # ignore blank lines and comments
504                                 fi
505                                 if [ -e "${REPO}/patched-src/${LINE}" ]; then
506                                         echo "remove ${LINE}"
507                                         "${RM}" -rf "${REPO}/patched-src/${LINE}"
508                                 fi
509                         done < "${REMOVE}"
510                 fi
511         done
512 }
513
514 ###
515 # link command
516
517 COMMANDS+=('link')
518
519 function link_help()
520 {
521         echo 'Link a fresh checkout with local adjustments.'
522         if [ "${1}" = '--one-line' ]; then return; fi
523
524         cat <<-EOF
525
526                 usage: $0 ${COMMAND} [--force|--force-file] [--dry-run] [--no-backup] [REPO]
527
528                 Where 'REPO' is the name the dotfiles repository to link.  If it
529                 is not given, all repositories will be linked.
530
531                 By default, link.sh only replaces missing files and simlinks.  You
532                 can optionally overwrite any local files by passing the --force
533                 option.
534         EOF
535 }
536
537 function link()
538 {
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
542         BACKUP='yes'
543         while [ "${1::2}" = '--' ]; do
544                 case "${1}" in
545                         '--force')
546                                 FORCE='yes'
547                                 ;;
548                         '--force-file')
549                                 FORCE='file'
550                                 ;;
551                         '--dry-run')
552                                 DRY_RUN='yes'
553                                 ;;
554                         '--no-backup')
555                                 BACKUP='no'
556                                 ;;
557                         *)
558                                 echo "ERROR: invalid option to link (${1})" >&2
559                                 return 1
560                 esac
561                 shift
562         done
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"
567
568         while read FILE; do
569                 if [ "${DOTFILES_SRC}/${FILE}" -ef "${TARGET}/${FILE}" ]; then
570                         continue  # already simlinked
571                 fi
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
576                 fi
577                 if [ -e "$TARGET/${FILE}" ] && [ "${FORCE}" = 'no' ]; then
578                         echo "use --force to override the existing target: ${TARGET}/${FILE}"
579                         continue  # target already exists
580                 fi
581                 link_file "${REPO}" "${FILE}" || return 1
582         done <<-EOF
583                 $(list_files "${DOTFILES_SRC}")
584         EOF
585 }
586
587 ###
588 # disconnect command
589
590 COMMANDS+=('disconnect')
591
592 function disconnect_help()
593 {
594         echo 'Freeze dotfiles at their current state.'
595         if [ "${1}" = '--one-line' ]; then return; fi
596
597         cat <<-EOF
598
599                 usage: $0 ${COMMAND} [REPO]
600
601                 Where 'REPO' is the name the dotfiles repository to disconnect.
602                 If it is not given, all repositories will be disconnected.
603
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.
609         EOF
610 }
611
612 function disconnect()
613 {
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"
618
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'
623                 return
624         fi
625
626         # See if the bashrc file is involved with dotfiles at all
627         BASHRC='no'
628
629         while read FILE; do
630                 if [ "${FILE}" = '.bashrc' ] && [ "${TARGET}" -ef "${HOME}" ]; then
631                         BASHRC='yes'
632                 fi
633                 if [ "${DOTFILES_SRC}/${FILE}" -ef "${TARGET}/${FILE}" ] && [ -h "${TARGET}/${FILE}" ]; then
634                         # break simlink
635                         echo "de-symlink ${TARGET}/${FILE}"
636                         "${RM}" -f "${TARGET}/${FILE}"
637                         "${MV}" "${DOTFILES_SRC}/${FILE}" "${TARGET}/${FILE}"
638                 fi
639         done <<-EOF
640                 $(list_files "${REPO}/patched-src")
641         EOF
642
643         if [ "${BASHRC}" == 'yes' ]; then
644                 echo 'strip dotfiles section from ~/.bashrc'
645                 "${SED}" '/DOTFILES_DIR/d' ~/.bashrc > bashrc_stripped
646
647                 # see if the stripped file is any different
648                 DIFF_OUTPUT=$("${DIFF}" ~/.bashrc bashrc_stripped)
649                 DIFF_RC="${?}"
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"
655                         "${RM}" -f ~/.bashrc
656                         "${MV}" bashrc_stripped ~/.bashrc
657                 else
658                         return 1  # diff failed, bail
659                 fi
660         fi
661
662         if [ -d "${DOTFILES_DIR}/${REPO}" ]; then
663                 echo "remove the ${REPO} repository"
664                 "${RM}" -rf "${DOTFILES_DIR}/${REPO}"
665         fi
666 }
667
668 ###
669 # update command
670
671 COMMANDS+=('update')
672
673 function update_help()
674 {
675         echo 'Utility command that runs fetch, patch, and link.'
676         if [ "${1}" = '--one-line' ]; then return; fi
677
678         cat <<-EOF
679
680                 usage: $0 ${COMMAND} [REPO]
681
682                 Where 'REPO' is the name the dotfiles repository to update.
683                 If it is not given, all repositories will be updateed.
684
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
688                 week.
689         EOF
690 }
691
692 function update()
693 {
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
697
698         # Update once a week from our remote repository.  Mark updates by
699         # touching this file.
700         UPDATE_FILE="${REPO}/updated.$(date +%U)"
701
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"
710         fi
711 }
712
713 #####
714 # Main entry-point
715
716 function main_help()
717 {
718         echo 'Dotfiles management script.'
719         if [ "${1}" = '--one-line' ]; then return; fi
720
721         cat <<-EOF
722
723                 usage: $0 [OPTIONS] COMMAND [ARGS]
724
725                 Options:
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 '~'.
730
731                 Commands:
732         EOF
733         for COMMAND in "${COMMANDS[@]}"; do
734                         echo -en "${COMMAND}\t"
735                         "${COMMAND}_help" --one-line
736         done
737         cat <<-EOF
738
739                 To get help on any command, pass the '--help' as the first option
740                 to the command.  For example:
741
742                   ${0} ${COMMANDS[0]} --help
743         EOF
744 }
745
746 function main()
747 {
748         COMMAND=''
749         while [ "${1::2}" = '--' ]; do
750                 case "${1}" in
751                         '--help')
752                                 main_help || return 1
753                                 return
754                                 ;;
755                         '--version')
756                                 echo "${VERSION}"
757                                 return
758                                 ;;
759                         '--dotfiles-dir')
760                                 DOTFILES_DIR="${2}"
761                                 shift
762                                 ;;
763                         '--target')
764                                 TARGET="${2}"
765                                 shift
766                                 ;;
767                         *)
768                                 echo "ERROR: invalid option to ${0} (${1})" >&2
769                                 return 1
770                 esac
771                 shift
772         done
773         COMMAND=$(get_selection "${1}" "${COMMANDS[@]}") || return 1
774         shift
775
776         cd "${DOTFILES_DIR}" || return 1
777
778         if [ "${1}" = '--help' ]; then
779                 "${COMMAND}_help" || return 1
780         elif [ "${COMMAND}" = 'clone' ]; then
781                 "${COMMAND}" "${@}" || return 1
782         else
783                 OPTIONS=()
784                 while [ "${1::2}" = '--' ]; do
785                         OPTIONS+=("${1}")
786                         shift
787                 done
788                 if [ "${#}" -eq 0 ]; then
789                         run_on_all_repos "${COMMAND}" "${OPTIONS[@]}" || return 1
790                 else
791                         maxargs "${0}" 1 "${@}" || return 1
792                         "${COMMAND}" "${OPTIONS[@]}" "${1}" || return 1
793                 fi
794         fi
795 }
796
797 main "${@}" || exit 1