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