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