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