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