Use `elif` instead of `else if` where possible to reduce nesting.
[dotfiles-framework.git] / dotfiles.sh
1 #!/bin/bash
2 #
3 # Dotfiles management script.  For details, run
4 #   $ dotfiles.sh --help
5
6 VERSION='0.2'
7 DOTFILES_DIR="${PWD}"
8 TARGET=~
9 CHECK_WGET_TYPE_AND_ENCODING='no'
10
11 #####
12 # External utilities
13
14 DIFF=$(which diff)
15 GIT=$(which git)
16 LN=$(which ln)
17 MV=$(which mv)
18 PATCH=$(which patch)
19 SED=$(which sed)
20 RM=$(which rm)
21 RSYNC=$(which rsync)
22 TAR=$(which tar)
23 TOUCH=$(which touch)
24 WGET=$(which wget)
25
26 #####
27 # Compatibility checks
28
29 BASH="${BASH_VERSION%.*}"
30 BASH_MAJOR="${BASH%.*}"
31 BASH_MINOR="${BASH#*.}"
32
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
36         exit 1
37 fi
38
39 #####
40 # Utility functions
41
42 # usage: nonempty_option LOC NAME VALUE
43 function nonempty_option()
44 {
45         LOC="${1}"
46         NAME="${2}"
47         VALUE="${3}"
48         if [ -z "${VALUE}" ]; then
49                 echo "ERROR: empty value for ${NAME} in ${LOC}" >&2
50                 return 1
51         fi
52         echo "${VALUE}"
53 }
54
55 # usage: maxargs LOC MAX "${@}"
56 #
57 # Print and error and return 1 if there are more than MAX arguments.
58 function maxargs()
59 {
60         LOC="${1}"
61         MAX="${2}"
62         shift 2
63         if [ "${#}" -gt "${MAX}" ]; then
64                 echo "ERROR: too many arguments (${#} > ${MAX}) in ${LOC}" >&2
65                 return 1
66         fi
67 }
68
69 # usage: get_selection CHOICE OPTION ...
70 #
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()
75 {
76         CHOICE="${1}"
77         shift
78         for OPT in "${@}"; do
79         if [ "${OPT}" = "${CHOICE}" ]; then
80                 echo "${OPT}"
81                 return 0
82         fi
83         done
84         echo "ERROR: invalid selection (${CHOICE})" >&2
85         echo "valid choices: ${@}" >&2
86         return 1
87 }
88
89 function run_on_all_repos()
90 {
91         COMMAND="${1}"
92         shift
93         if [ -z "${REPO}" ]; then  # run on all repositories
94                 for REPO in *; do
95                         if [ "${REPO}" = '*' ]; then
96                                 break  # no known repositories
97                         fi
98                         "${COMMAND}" "${@}" "${REPO}" || return 1
99                 done
100                 return
101         fi
102 }
103
104 function list_files()
105 {
106         DIR=$(nonempty_option 'list_files' 'DIR' "${1}") || return 1
107         while read FILE; do
108                 if [ "${FILE}" = '.' ]; then
109                         continue
110                 fi
111                 FILE="${FILE:2}"  # strip the leading './'
112                 echo "${FILE}"
113         done < <(cd "${DIR}" && find .)
114 }
115
116 # Global variable to allow passing associative arrats between functions
117
118 if [ "${BASH_MAJOR}" -ge 4 ]; then
119         declare -A REPO_SOURCE_DATA
120 fi
121
122 function set_repo_source()
123 {
124         if [ "${BASH_MAJOR}" -lt 4 ]; then
125                 echo "ERROR: ${0}'s set_repo_source requires Bash version >= 4.0" >&2
126                 echo "you're running ${BASH}, which doesn't support associative arrays" >&2
127                 return 1
128         fi
129         REPO=$(nonempty_option 'set_repo_source' 'REPO' "${1}") || return 1
130         > "${REPO}/source_cache" || return 1
131         for KEY in "${!REPO_SOURCE_DATA[@]}"; do
132                 echo "${KEY}=${REPO_SOURCE_DATA[${KEY}]}" >> "${REPO}/source_cache" || return 1
133         done
134 }
135
136 # usage: get_repo_source REPO
137 function get_repo_source()
138 {
139         if [ "${BASH_MAJOR}" -lt 4 ]; then
140                 echo "ERROR: ${0}'s get_repo_source() requires Bash version >= 4.0" >&2
141                 echo "you're running ${BASH}, which doesn't support associative arrays" >&2
142                 return 1
143         fi
144         REPO=$(nonempty_option 'get_repo_source' 'REPO' "${1}") || return 1
145         REPO_SOURCE_DATA=()
146         if [ -f "${REPO}/source_cache" ]; then
147                 while read LINE; do
148                         KEY="${LINE%%=*}"
149                         VALUE="${LINE#*=}"
150                         REPO_SOURCE_DATA["${KEY}"]="${VALUE}"
151                 done < "${REPO}/source_cache"
152         else
153                 # autodetect verson control system
154                 REPO_SOURCE_DATA=()
155                 REPO_SOURCE_DATA['repo']="${REPO}"
156                 if [ -d "${REPO}/.git" ]; then
157                         REPO_SOURCE_DATA['transfer']='git'
158                 else
159                         echo "ERROR: no source location found for ${REPO}" >&2
160                         return 1
161                 fi
162                 # no need to get further fields for these transfer mechanisms
163         fi
164 }
165
166 function git_fetch()
167 {
168         REPO=$(nonempty_option 'git_fetch' 'REPO' "${1}") || return 1
169         REMOTES=$(cd "${REPO}" && "${GIT}" remote) || return 1
170         if [ -n "${REMOTES}" ]; then
171                 (cd "${REPO}" && "${GIT}" pull) || return 1
172         else
173                 echo "no remote repositories found for ${REPO}"
174         fi
175 }
176
177 function wget_fetch()
178 {
179         REPO=$(nonempty_option 'wget_fetch' 'REPO' "${1}") || return 1
180         # get_repo_source() was just called on this repo in fetch()
181         TRANSFER=$(nonempty_option 'wget_fetch' 'TRANSFER' "${REPO_SOURCE_DATA['transfer']}") || return 1
182         URL=$(nonempty_option 'wget_fetch' 'URL' "${REPO_SOURCE_DATA['url']}") || return 1
183         ETAG="${REPO_SOURCE_DATA['etag']}"
184         BUNDLE="${REPO}.tgz"
185         HEAD=$("${WGET}" --server-response --spider "${URL}" 2>&1) || return 1
186         SERVER_ETAG=$(echo "${HEAD}" | "${SED}" -n 's/^ *etag: *"\(.*\)"/\1/ip') || return 1
187         if [ "${CHECK_WGET_TYPE_AND_ENCODING}" = 'yes' ]; then
188                 TYPE=$(echo "${HEAD}" | "${SED}" -n 's/^ *content-type: *//ip') || return 1
189                 ENCODING=$(echo "${HEAD}" | "${SED}" -n 's/^ *content-encoding: *//ip') || return 1
190                 if [ "${TYPE}" != 'application/x-gzip' ] || [ "${ENCODING}" != 'x-gzip' ]; then
191                         echo "ERROR: invalid content type (${TYPE}) or encoding (${ENCODING})." >&2
192                         echo "while fetching ${URL}" >&2
193                         return 1
194                 fi
195         fi
196         if [ -z "${ETAG}" ] || [ "${SERVER_ETAG}" != "${ETAG}" ]; then
197                 # Previous ETag not known, or ETag changed.  Download new copy.
198                 "${WGET}" --output-document "${BUNDLE}" "${URL}" || return 1
199                 if [ -n "${SERVER_ETAG}" ]; then  # store new ETag
200                         REPO_SOURCE_DATA['etag']="${SERVER_ETAG}"
201                         set_repo_source "${REPO}" || return 1
202                 elif [ -n "${ETAG}" ]; then  # clear old ETag
203                         unset "${REPO_SOURCE_DATA['etag']}"
204                         set_repo_source "${REPO}" || return 1
205                 fi
206                 echo "extracting ${BUNDLE} to ${REPO}"
207                 "${TAR}" -xf "${BUNDLE}" -C "${REPO}" --strip-components 1 --overwrite || return 1
208                 "${RM}" -f "${BUNDLE}" || return 1
209         else
210                 echo "already downloaded the ETag=${ETAG} version of ${URL}"
211         fi
212 }
213
214
215 # usage: link_file REPO FILE
216 #
217 # Create the symbolic link to the version of FILE in the REPO
218 # repository, overriding the target if it exists.
219 function link_file()
220 {
221         REPO=$(nonempty_option 'link_file' 'REPO' "${1}") || return 1
222         FILE=$(nonempty_option 'link_file' 'FILE' "${2}") || return 1
223         if [ "${BACKUP}" = 'yes' ]; then
224                 if [ -e "${TARGET}/${FILE}" ] || [ -h "${TARGET}/${FILE}" ]; then
225                         if [ "${DRY_RUN}" = 'yes' ]; then
226                                 echo "move ${TARGET}/${FILE} to ${TARGET}/${FILE}.bak"
227                         else
228                                 echo -n 'move '
229                                 mv -v "${TARGET}/${FILE}" "${TARGET}/${FILE}.bak" || return 1
230                         fi
231                 fi
232         else
233                 if [ "${DRY_RUN}" = 'yes' ]; then
234                         echo "rm ${TARGET}/${FILE}"
235                 else
236                         "${RM}" -fv "${TARGET}/${FILE}"
237                 fi
238         fi
239         if [ "${DRY_RUN}" = 'yes' ]; then
240                 echo "link ${TARGET}/${FILE} to ${DOTFILES_DIR}/${REPO}/patched-src/${FILE}"
241         else
242                 echo -n 'link '
243                 "${LN}" -sv "${DOTFILES_DIR}/${REPO}/patched-src/${FILE}" "${TARGET}/${FILE}" || return 1
244         fi
245 }
246
247 #####
248 # Top-level commands
249
250 # An array of available commands
251 COMMANDS=()
252
253 ###
254 # clone command
255
256 COMMANDS+=('clone')
257
258 CLONE_TRANSFERS=('git' 'wget')
259
260 function clone_help()
261 {
262         echo 'Create a new dotfiles repository.'
263         if [ "${1}" = '--one-line' ]; then return; fi
264
265         cat <<-EOF
266
267                 usage: $0 ${COMMAND} REPO TRANSFER URL
268
269                 Where 'REPO' is the name the dotfiles repository to create,
270                 'TRANSFER' is the transfer mechanism, and 'URL' is the URL for the
271                 remote repository.  Valid TRANSFERs are:
272
273                   ${CLONE_TRANSFERS[@]}
274
275                 Examples:
276
277                   $0 clone public wget http://example.com/public-dotfiles.tar.gz
278                   $0 clone private git ssh://example.com/~/private-dotfiles.git
279         EOF
280 }
281
282 function clone()
283 {
284         REPO=$(nonempty_option 'clone' 'REPO' "${1}") || return 1
285         TRANSFER=$(nonempty_option 'clone' 'TRANSFER' "${2}") || return 1
286         URL=$(nonempty_option 'clone' 'URL' "${3}") || return 1
287         maxargs 'clone' 3 "${@}" || return 1
288         TRANSFER=$(get_selection "${TRANSFER}" "${CLONE_TRANSFERS[@]}") || return 1
289         if [ -e "${REPO}" ]; then
290                 echo "ERROR: destination path (${REPO}) already exists." >&2
291                 return 1
292         fi
293         CACHE_SOURCE='yes'
294         FETCH='yes'
295         case "${TRANSFER}" in
296                 'git')
297                         CACHE_SOURCE='no'
298                         FETCH='no'
299                         "${GIT}" clone "${URL}" "${REPO}" || return 1
300                         ;;
301                 'wget')
302                         mkdir -p "${REPO}"
303                         ;;
304                 *)
305                         echo "PROGRAMMING ERROR: add ${TRANSFER} support to clone command" >&2
306                         return 1
307         esac
308         if [ "${CACHE_SOURCE}" = 'yes' ]; then
309                 REPO_SOURCE_DATA=(['transfer']="${TRANSFER}" ['url']="${URL}")
310                 set_repo_source "${REPO}" || return 1
311         fi
312         if [ "${FETCH}" = 'yes' ]; then
313                 fetch "${REPO}" || return 1
314         fi
315 }
316
317 ###
318 # fetch command
319
320 COMMANDS+=('fetch')
321
322 function fetch_help()
323 {
324         echo 'Get the current dotfiles from the server.'
325         if [ "${1}" = '--one-line' ]; then return; fi
326
327         cat <<-EOF
328
329                 usage: $0 ${COMMAND} [REPO]
330
331                 Where 'REPO' is the name the dotfiles repository to fetch.  If it
332                 is not given, all repositories will be fetched.
333         EOF
334 }
335
336 function fetch()
337 {
338         # multi-repo case handled in main() by run_on_all_repos()
339         REPO=$(nonempty_option 'fetch' 'REPO' "${1}") || return 1
340         maxargs 'fetch' 1 "${@}" || return 1
341         if [ "${BASH_MAJOR}" -ge 4 ]; then
342                 get_repo_source "${REPO}" || return 1
343                 TRANSFER=$(nonempty_option 'fetch' 'TRANSFER' "${REPO_SOURCE_DATA['transfer']}") || return 1
344         else
345                 echo "WARNING: Bash version < 4.0, assuming all repos use git transfer" >&2
346                 TRANSFER='git'
347         fi
348         if [ "${TRANSFER}" = 'git' ]; then
349                 git_fetch "${REPO}" || return 1
350         elif [ "${TRANSFER}" = 'wget' ]; then
351                 wget_fetch "${REPO}" || return 1
352         else
353                 echo "PROGRAMMING ERROR: add ${TRANSFER} support to fetch command" >&2
354                 return 1
355         fi
356 }
357
358 ###
359 # fetch command
360
361 COMMANDS+=('diff')
362
363 function diff_help()
364 {
365         echo 'Show differences between targets and dotfiles repositories.'
366         if [ "${1}" = '--one-line' ]; then return; fi
367
368         cat <<-EOF
369
370                 usage: $0 ${COMMAND} [--removed|--local-patch] [REPO]
371
372                 Where 'REPO' is the name the dotfiles repository to query.  If it
373                 is not given, all repositories will be queried.
374
375                 By default, ${COMMAND} will list differences between files that
376                 exist in both the target location and the dotfiles repository (as
377                 a patch that could be applied to the dotfiles source).
378
379                 With the '--removed' option, ${COMMAND} will list files that
380                 should be removed from the dotfiles source in order to match the
381                 target.
382
383                 With the '--local-patch' option, ${COMMAND} will create files in
384                 list files that should be removed from the dotfiles source in
385                 order to match the target.
386         EOF
387 }
388
389 function diff()
390 {
391         MODE='standard'
392         while [ "${1::2}" = '--' ]; do
393                 case "${1}" in
394                         '--removed')
395                                 MODE='removed'
396                                 ;;
397                         '--local-patch')
398                                 MODE='local-patch'
399                                 ;;
400                         *)
401                                 echo "ERROR: invalid option to diff (${1})" >&2
402                                 return 1
403                         esac
404                 shift
405         done
406         # multi-repo case handled in main() by run_on_all_repos()
407         REPO=$(nonempty_option 'diff' 'REPO' "${1}") || return 1
408         maxargs 'diff' 1 "${@}" || return 1
409
410         if [ "${MODE}" = 'local-patch' ]; then
411                 mkdir -p "${REPO}/local-patch" || return 1
412
413                 exec 3<&1     # save stdout to file descriptor 3
414                 echo "save local patches to ${REPO}/local-patch/000-local.patch"
415                 exec 1>"${REPO}/local-patch/000-local.patch"  # redirect stdout
416                 diff "${REPO}"
417                 exec 1<&3     # restore old stdout
418                 exec 3<&-     # close temporary fd 3
419
420                 exec 3<&1     # save stdout to file descriptor 3
421                 echo "save local removed to ${REPO}/local-patch/000-local.remove"
422                 exec 1>"${REPO}/local-patch/000-local.remove"  # redirect stdout
423                 diff --removed "${REPO}"
424                 exec 1<&3     # restore old stdout
425                 exec 3<&-     # close temporary fd 3
426                 return
427         fi
428
429         while read FILE; do
430                 if [ "${MODE}" = 'removed' ]; then
431                         if [ ! -e "${TARGET}/${FILE}" ]; then
432                                 echo "${FILE}"
433                         fi
434                 elif [ -f "${TARGET}/${FILE}" ]; then
435                         (cd "${REPO}/src" && "${DIFF}" -u "${FILE}" "${TARGET}/${FILE}")
436                 fi
437         done <<-EOF
438                 $(list_files "${REPO}/src")
439         EOF
440 }
441
442 ###
443 # patch command
444
445 COMMANDS+=('patch')
446
447 function patch_help()
448 {
449         echo 'Patch a fresh checkout with local adjustments.'
450         if [ "${1}" = '--one-line' ]; then return; fi
451
452         cat <<-EOF
453
454                 usage: $0 ${COMMAND} [REPO]
455
456                 Where 'REPO' is the name the dotfiles repository to patch.  If it
457                 is not given, all repositories will be patched.
458         EOF
459 }
460
461 function patch()
462 {
463         # multi-repo case handled in main() by run_on_all_repos()
464         REPO=$(nonempty_option 'patch' 'REPO' "${1}") || return 1
465         maxargs 'patch' 1 "${@}" || return 1
466
467         echo "copy clean checkout into ${REPO}/patched-src"
468         "${RSYNC}" -avz --delete "${REPO}/src/" "${REPO}/patched-src/" || return 1
469
470         # apply all the patches in local-patch/
471         for FILE in "${REPO}/local-patch"/*.patch; do
472                 if [ -f "${FILE}" ]; then
473                         echo "apply ${FILE}"
474                         pushd "${REPO}/patched-src/" > /dev/null || return 1
475                         "${PATCH}" -p1 < "../../${FILE}" || return 1
476                         popd > /dev/null || return 1
477                 fi
478         done
479
480         # remove any files marked for removal in local-patch
481         for REMOVE in "${REPO}/local-patch"/*.remove; do
482                 if [ -f "${REMOVE}" ]; then
483                         echo "apply ${FILE}"
484                         while read LINE; do
485                                 if [ -z "${LINE}" ] || [ "${LINE:0:1}" = '#' ]; then
486                                         continue  # ignore blank lines and comments
487                                 fi
488                                 if [ -e "${REPO}/patched-src/${LINE}" ]; then
489                                         echo "remove ${LINE}"
490                                         "${RM}" -rf "${REPO}/patched-src/${LINE}"
491                                 fi
492                         done < "${REMOVE}"
493                 fi
494         done
495 }
496
497 ###
498 # link command
499
500 COMMANDS+=('link')
501
502 function link_help()
503 {
504         echo 'Link a fresh checkout with local adjustments.'
505         if [ "${1}" = '--one-line' ]; then return; fi
506
507         cat <<-EOF
508
509                 usage: $0 ${COMMAND} [--force|--force-file] [--dry-run] [--no-backup] [REPO]
510
511                 Where 'REPO' is the name the dotfiles repository to link.  If it
512                 is not given, all repositories will be linked.
513
514                 By default, link.sh only replaces missing files and simlinks.  You
515                 can optionally overwrite any local files by passing the --force
516                 option.
517         EOF
518 }
519
520 function link()
521 {
522         FORCE='no'   # If 'file', overwrite existing files.
523                      # If 'yes', overwrite existing files and dirs.
524         DRY_RUN='no' # If 'yes', disable any actions that change the filesystem
525         BACKUP='yes'
526         while [ "${1::2}" = '--' ]; do
527                 case "${1}" in
528                         '--force')
529                                 FORCE='yes'
530                                 ;;
531                         '--force-file')
532                                 FORCE='file'
533                                 ;;
534                         '--dry-run')
535                                 DRY_RUN='yes'
536                                 ;;
537                         '--no-backup')
538                                 BACKUP='no'
539                                 ;;
540                         *)
541                                 echo "ERROR: invalid option to link (${1})" >&2
542                                 return 1
543                 esac
544                 shift
545         done
546         # multi-repo case handled in main() by run_on_all_repos()
547         REPO=$(nonempty_option 'link' 'REPO' "${1}") || return 1
548         maxargs 'link' 1 "${@}" || return 1
549         DOTFILES_SRC="${DOTFILES_DIR}/${REPO}/patched-src"
550
551         while read FILE; do
552                 if [ "${DOTFILES_SRC}/${FILE}" -ef "${TARGET}/${FILE}" ]; then
553                         continue  # already simlinked
554                 fi
555                 if [ -d "${DOTFILES_SRC}/${FILE}" ] && [ -d "${TARGET}/${FILE}" ] && \
556                         [ "${FORCE}" != 'yes' ]; then
557                         echo "use --force to override the existing directory: ${TARGET}/${FILE}"
558                         continue  # allow unlinked directories
559                 fi
560                 if [ -e "$TARGET/${FILE}" ] && [ "${FORCE}" = 'no' ]; then
561                         echo "use --force to override the existing target: ${TARGET}/${FILE}"
562                         continue  # target already exists
563                 fi
564                 link_file "${REPO}" "${FILE}" || return 1
565         done <<-EOF
566                 $(list_files "${DOTFILES_SRC}")
567         EOF
568 }
569
570 ###
571 # disconnect command
572
573 COMMANDS+=('disconnect')
574
575 function disconnect_help()
576 {
577         echo 'Freeze dotfiles at their current state.'
578         if [ "${1}" = '--one-line' ]; then return; fi
579
580         cat <<-EOF
581
582                 usage: $0 ${COMMAND} [REPO]
583
584                 Where 'REPO' is the name the dotfiles repository to disconnect.
585                 If it is not given, all repositories will be disconnected.
586
587                 You're about to give your sysadmin account to some newbie, and
588                 they'd just be confused by all this efficiency.  This script
589                 freezes your dotfiles in their current state and makes everthing
590                 look normal.  Note that this will delete your dotfiles repository
591                 and strip the dotfiles portion from your ~/.bashrc file.
592         EOF
593 }
594
595 function disconnect()
596 {
597         # multi-repo case handled in main() by run_on_all_repos()
598         REPO=$(nonempty_option 'link' 'REPO' "${1}") || return 1
599         maxargs 'disconnect' 1 "${@}" || return 1
600         DOTFILES_SRC="${DOTFILES_DIR}/${REPO}/patched-src"
601
602         # See if we've constructed any patched source files that might be
603         # possible link targets
604         if [ ! -d "${DOTFILES_SRC}" ]; then
605                 echo 'no installed dotfiles to disconnect'
606                 return
607         fi
608
609         # See if the bashrc file is involved with dotfiles at all
610         BASHRC='no'
611
612         while read FILE; do
613                 if [ "${FILE}" = '.bashrc' ] && [ "$TARGET" -ef "${HOME}" ]; then
614                         BASHRC='yes'
615                 fi
616                 if [ "${DOTFILES_SRC}/${FILE}" -ef "${TARGET}/${FILE}" ] && [ -h "${TARGET}/${FILE}" ]; then
617                         # break simlink
618                         echo "de-symlink ${TARGET}/${FILE}"
619                         "${RM}" -f "${TARGET}/${FILE}"
620                         "${MV}" "${DOTFILES_SRC}/${FILE}" "${TARGET}/${FILE}"
621                 fi
622         done <<-EOF
623                 $(list_files "${REPO}/patched-src")
624         EOF
625
626         if [ "${BASHRC}" == 'yes' ]; then
627                 echo 'strip dotfiles section from ~/.bashrc'
628                 "${SED}" '/DOTFILES_DIR/d' ~/.bashrc > bashrc_stripped
629
630                 # see if the stripped file is any different
631                 DIFF_OUTPUT=$("${DIFF}" ~/.bashrc bashrc_stripped)
632                 DIFF_RC="${?}"
633                 if [ "${DIFF_RC}" -eq 0 ]; then
634                         echo "no dotfiles section found in ~/.bashrc"
635                         "${RM}" -f bashrc_stripped
636                 elif [ "${DIFF_RC}" -eq 1 ]; then
637                         echo "replace ~/.bashrc with stripped version"
638                         "${RM}" -f ~/.bashrc
639                         "${MV}" bashrc_stripped ~/.bashrc
640                 else
641                         return 1  # diff failed, bail
642                 fi
643         fi
644
645         if [ -d "${DOTFILES_DIR}/${REPO}" ]; then
646                 echo "remove the ${REPO} repository"
647                 "${RM}" -rf "${DOTFILES_DIR}/${REPO}"
648         fi
649 }
650
651 ###
652 # update command
653
654 COMMANDS+=('update')
655
656 function update_help()
657 {
658         echo 'Utility command that runs fetch, patch, and link.'
659         if [ "${1}" = '--one-line' ]; then return; fi
660
661         cat <<-EOF
662
663                 usage: $0 ${COMMAND} [REPO]
664
665                 Where 'REPO' is the name the dotfiles repository to update.
666                 If it is not given, all repositories will be updateed.
667
668                 Run 'fetch', 'patch', and 'link' sequentially on each repository
669                 to bring them in sync with the central repositories.  Keeps track
670                 of the last update time to avoid multiple fetches in the same
671                 week.
672         EOF
673 }
674
675 function update()
676 {
677         # multi-repo case handled in main() by run_on_all_repos()
678         REPO=$(nonempty_option 'link' 'REPO' "${1}") || return 1
679         maxargs 'disconnect' 1 "${@}" || return 1
680
681         # Update once a week from our remote repository.  Mark updates by
682         # touching this file.
683         UPDATE_FILE="${REPO}/updated.$(date +%U)"
684
685         if [ ! -e "${UPDATE_FILE}" ]; then
686                 echo "update ${REPO} dotfiles"
687                 "${RM}" -f "${REPO}"/updated.* || return 1
688                 "${TOUCH}" "${UPDATE_FILE}" || return 1
689                 fetch "${REPO}" || return 1
690                 patch "${REPO}" || return 1
691                 link "${REPO}" || return 1
692                 echo "${REPO} dotfiles updated"
693         fi
694 }
695
696 #####
697 # Main entry-point
698
699 function main_help()
700 {
701         echo 'Dotfiles management script.'
702         if [ "${1}" = '--one-line' ]; then return; fi
703
704         cat <<-EOF
705
706                 usage: $0 [OPTIONS] COMMAND [ARGS]
707
708                 Options:
709                 --help  Print this help message and exit.
710                 --version       Print the $0 version and exit.
711                 --dotfiles-dir DIR      Directory containing the dotfiles reposotories.  Defaults to '.'.
712                 --target DIR    Directory to install dotfiles into.  Defaults to '~'.
713
714                 Commands:
715         EOF
716         for COMMAND in "${COMMANDS[@]}"; do
717                         echo -en "${COMMAND}\t"
718                         "${COMMAND}_help" --one-line
719         done
720         cat <<-EOF
721
722                 To get help on any command, pass the '--help' as the first option
723                 to the command.  For example:
724
725                   ${0} ${COMMANDS[0]} --help
726         EOF
727 }
728
729 function main()
730 {
731         COMMAND=''
732         while [ "${1::2}" = '--' ]; do
733                 case "${1}" in
734                         '--help')
735                                 main_help || return 1
736                                 return
737                                 ;;
738                         '--version')
739                                 echo "${VERSION}"
740                                 return
741                                 ;;
742                         '--dotfiles-dir')
743                                 DOTFILES_DIR="${2}"
744                                 shift
745                                 ;;
746                         '--target')
747                                 TARGET="${2}"
748                                 shift
749                                 ;;
750                         *)
751                                 echo "ERROR: invalid option to ${0} (${1})" >&2
752                                 return 1
753                 esac
754                 shift
755         done
756         COMMAND=$(get_selection "${1}" "${COMMANDS[@]}") || return 1
757         shift
758
759         cd "${DOTFILES_DIR}" || return 1
760
761         if [ "${1}" = '--help' ]; then
762                 "${COMMAND}_help" || return 1
763         elif [ "${COMMAND}" = 'clone' ]; then
764                 "${COMMAND}" "${@}" || return 1
765         else
766                 OPTIONS=()
767                 while [ "${1::2}" = '--' ]; do
768                         OPTIONS+=("${1}")
769                         shift
770                 done
771                 if [ "${#}" -eq 0 ]; then
772                         run_on_all_repos "${COMMAND}" "${OPTIONS[@]}" || return 1
773                 else
774                         maxargs "${0}" 1 "${@}" || return 1
775                         "${COMMAND}" "${OPTIONS[@]}" "${1}" || return 1
776                 fi
777         fi
778 }
779
780 main "${@}" || exit 1