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