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