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