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