Use '-p1' instead of '-p0' for patching.
[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         if [ "${BASH_MAJOR}" -lt 4 ]; then
125                 echo "ERROR: ${0}'s set_repo_source requires Bash version >= 4.0" >&2
126                 echo "you're running ${BASH}, which doesn't support associative arrays" >&2
127                 return 1
128         fi
129         REPO=$(nonempty_option 'set_repo_source' 'REPO' "${1}") || return 1
130         > "${REPO}/source_cache" || return 1
131         for KEY in "${!REPO_SOURCE_DATA[@]}"; do
132                 echo "${KEY}=${REPO_SOURCE_DATA[${KEY}]}" >> "${REPO}/source_cache" || return 1
133         done
134 }
135
136 # usage: get_repo_source REPO
137 function get_repo_source()
138 {
139         if [ "${BASH_MAJOR}" -lt 4 ]; then
140                 echo "ERROR: ${0}'s get_repo_source() requires Bash version >= 4.0" >&2
141                 echo "you're running ${BASH}, which doesn't support associative arrays" >&2
142                 return 1
143         fi
144         REPO=$(nonempty_option 'get_repo_source' 'REPO' "${1}") || return 1
145         REPO_SOURCE_DATA=()
146         if [ -f "${REPO}/source_cache" ]; then
147                 while read LINE; do
148                         KEY="${LINE%%=*}"
149                         VALUE="${LINE#*=}"
150                         REPO_SOURCE_DATA["${KEY}"]="${VALUE}"
151                 done < "${REPO}/source_cache"
152         else
153                 # autodetect verson control system
154                 REPO_SOURCE_DATA=()
155                 REPO_SOURCE_DATA['repo']="${REPO}"
156                 if [ -d "${REPO}/.git" ]; then
157                         REPO_SOURCE_DATA['transfer']='git'
158                 else
159                         echo "ERROR: no source location found for ${REPO}" >&2
160                         return 1
161                 fi
162                 # no need to get further fields for these transfer mechanisms
163         fi
164 }
165
166 function git_fetch()
167 {
168         REPO=$(nonempty_option 'git_fetch' 'REPO' "${1}") || return 1
169         REMOTES=$(cd "${REPO}" && "${GIT}" remote) || return 1
170         if [ -n "${REMOTES}" ]; then
171                 (cd "${REPO}" && "${GIT}" pull) || return 1
172         else
173                 echo "no remote repositories found for ${REPO}"
174         fi
175 }
176
177 function wget_fetch()
178 {
179         REPO=$(nonempty_option 'wget_fetch' 'REPO' "${1}") || return 1
180         # get_repo_source() was just called on this repo in fetch()
181         TRANSFER=$(nonempty_option 'wget_fetch' 'TRANSFER' "${REPO_SOURCE_DATA['transfer']}") || return 1
182         URL=$(nonempty_option 'wget_fetch' 'URL' "${REPO_SOURCE_DATA['url']}") || return 1
183         ETAG="${REPO_SOURCE_DATA['etag']}"
184         BUNDLE="${REPO}.tgz"
185         HEAD=$("${WGET}" --server-response --spider "${URL}" 2>&1) || return 1
186         SERVER_ETAG=$(echo "${HEAD}" | "${SED}" -n 's/^ *etag: *"\(.*\)"/\1/ip') || return 1
187         if [ "${CHECK_WGET_TYPE_AND_ENCODING}" = 'yes' ]; then
188                 TYPE=$(echo "${HEAD}" | "${SED}" -n 's/^ *content-type: *//ip') || return 1
189                 ENCODING=$(echo "${HEAD}" | "${SED}" -n 's/^ *content-encoding: *//ip') || return 1
190                 if [ "${TYPE}" != 'application/x-gzip' ] || [ "${ENCODING}" != 'x-gzip' ]; then
191                         echo "ERROR: invalid content type (${TYPE}) or encoding (${ENCODING})." >&2
192                         echo "while fetching ${URL}" >&2
193                         return 1
194                 fi
195         fi
196         if [ -z "${ETAG}" ] || [ "${SERVER_ETAG}" != "${ETAG}" ]; then
197                 # Previous ETag not known, or ETag changed.  Download new copy.
198                 "${WGET}" --output-document "${BUNDLE}" "${URL}" || return 1
199                 if [ -n "${SERVER_ETAG}" ]; then  # store new ETag
200                         REPO_SOURCE_DATA['etag']="${SERVER_ETAG}"
201                         set_repo_source "${REPO}" || return 1
202                 else
203                         if [ -n "${ETAG}" ]; then  # clear old ETag
204                                 unset "${REPO_SOURCE_DATA['etag']}"
205                                 set_repo_source "${REPO}" || return 1
206                         fi
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                 else
437                         if [ -f "${TARGET}/${FILE}" ]; then
438                                 (cd "${REPO}/src" && "${DIFF}" -u "${FILE}" "${TARGET}/${FILE}")
439                         fi
440                 fi
441         done <<-EOF
442                 $(list_files "${REPO}/src")
443         EOF
444 }
445
446 ###
447 # patch command
448
449 COMMANDS+=('patch')
450
451 function patch_help()
452 {
453         echo 'Patch a fresh checkout with local adjustments.'
454         if [ "${1}" = '--one-line' ]; then return; fi
455
456         cat <<-EOF
457
458                 usage: $0 ${COMMAND} [REPO]
459
460                 Where 'REPO' is the name the dotfiles repository to patch.  If it
461                 is not given, all repositories will be patched.
462         EOF
463 }
464
465 function patch()
466 {
467         # multi-repo case handled in main() by run_on_all_repos()
468         REPO=$(nonempty_option 'patch' 'REPO' "${1}") || return 1
469         maxargs 'patch' 1 "${@}" || return 1
470
471         echo "copy clean checkout into ${REPO}/patched-src"
472         "${RSYNC}" -avz --delete "${REPO}/src/" "${REPO}/patched-src/" || return 1
473
474         # apply all the patches in local-patch/
475         for FILE in "${REPO}/local-patch"/*.patch; do
476                 if [ -f "${FILE}" ]; then
477                         echo "apply ${FILE}"
478                         pushd "${REPO}/patched-src/" > /dev/null || return 1
479                         "${PATCH}" -p1 < "../../${FILE}" || return 1
480                         popd > /dev/null || return 1
481                 fi
482         done
483
484         # remove any files marked for removal in local-patch
485         for REMOVE in "${REPO}/local-patch"/*.remove; do
486                 if [ -f "${REMOVE}" ]; then
487                         echo "apply ${FILE}"
488                         while read LINE; do
489                                 if [ -z "${LINE}" ] || [ "${LINE:0:1}" = '#' ]; then
490                                         continue  # ignore blank lines and comments
491                                 fi
492                                 if [ -e "${REPO}/patched-src/${LINE}" ]; then
493                                         echo "remove ${LINE}"
494                                         "${RM}" -rf "${REPO}/patched-src/${LINE}"
495                                 fi
496                         done < "${REMOVE}"
497                 fi
498         done
499 }
500
501 ###
502 # link command
503
504 COMMANDS+=('link')
505
506 function link_help()
507 {
508         echo 'Link a fresh checkout with local adjustments.'
509         if [ "${1}" = '--one-line' ]; then return; fi
510
511         cat <<-EOF
512
513                 usage: $0 ${COMMAND} [--force|--force-file] [--dry-run] [--no-backup] [REPO]
514
515                 Where 'REPO' is the name the dotfiles repository to link.  If it
516                 is not given, all repositories will be linked.
517
518                 By default, link.sh only replaces missing files and simlinks.  You
519                 can optionally overwrite any local files by passing the --force
520                 option.
521         EOF
522 }
523
524 function link()
525 {
526         FORCE='no'   # If 'file', overwrite existing files.
527                      # If 'yes', overwrite existing files and dirs.
528         DRY_RUN='no' # If 'yes', disable any actions that change the filesystem
529         BACKUP='yes'
530         while [ "${1::2}" = '--' ]; do
531                 case "${1}" in
532                         '--force')
533                                 FORCE='yes'
534                                 ;;
535                         '--force-file')
536                                 FORCE='file'
537                                 ;;
538                         '--dry-run')
539                                 DRY_RUN='yes'
540                                 ;;
541                         '--no-backup')
542                                 BACKUP='no'
543                                 ;;
544                         *)
545                                 echo "ERROR: invalid option to link (${1})" >&2
546                                 return 1
547                 esac
548                 shift
549         done
550         # multi-repo case handled in main() by run_on_all_repos()
551         REPO=$(nonempty_option 'link' 'REPO' "${1}") || return 1
552         maxargs 'link' 1 "${@}" || return 1
553         DOTFILES_SRC="${DOTFILES_DIR}/${REPO}/patched-src"
554
555         while read FILE; do
556                 if [ "${DOTFILES_SRC}/${FILE}" -ef "${TARGET}/${FILE}" ]; then
557                         continue  # already simlinked
558                 fi
559                 if [ -d "${DOTFILES_SRC}/${FILE}" ] && [ -d "${TARGET}/${FILE}" ] && \
560                         [ "${FORCE}" != 'yes' ]; then
561                         echo "use --force to override the existing directory: ${TARGET}/${FILE}"
562                         continue  # allow unlinked directories
563                 fi
564                 if [ -e "$TARGET/${FILE}" ] && [ "${FORCE}" = 'no' ]; then
565                         echo "use --force to override the existing target: ${TARGET}/${FILE}"
566                         continue  # target already exists
567                 fi
568                 link_file "${REPO}" "${FILE}" || return 1
569         done <<-EOF
570                 $(list_files "${DOTFILES_SRC}")
571         EOF
572 }
573
574 ###
575 # disconnect command
576
577 COMMANDS+=('disconnect')
578
579 function disconnect_help()
580 {
581         echo 'Freeze dotfiles at their current state.'
582         if [ "${1}" = '--one-line' ]; then return; fi
583
584         cat <<-EOF
585
586                 usage: $0 ${COMMAND} [REPO]
587
588                 Where 'REPO' is the name the dotfiles repository to disconnect.
589                 If it is not given, all repositories will be disconnected.
590
591                 You're about to give your sysadmin account to some newbie, and
592                 they'd just be confused by all this efficiency.  This script
593                 freezes your dotfiles in their current state and makes everthing
594                 look normal.  Note that this will delete your dotfiles repository
595                 and strip the dotfiles portion from your ~/.bashrc file.
596         EOF
597 }
598
599 function disconnect()
600 {
601         # multi-repo case handled in main() by run_on_all_repos()
602         REPO=$(nonempty_option 'link' 'REPO' "${1}") || return 1
603         maxargs 'disconnect' 1 "${@}" || return 1
604         DOTFILES_SRC="${DOTFILES_DIR}/${REPO}/patched-src"
605
606         # See if we've constructed any patched source files that might be
607         # possible link targets
608         if [ ! -d "${DOTFILES_SRC}" ]; then
609                 echo 'no installed dotfiles to disconnect'
610                 return
611         fi
612
613         # See if the bashrc file is involved with dotfiles at all
614         BASHRC='no'
615
616         while read FILE; do
617                 if [ "${FILE}" = '.bashrc' ] && [ "$TARGET" -ef "${HOME}" ]; then
618                         BASHRC='yes'
619                 fi
620                 if [ "${DOTFILES_SRC}/${FILE}" -ef "${TARGET}/${FILE}" ] && [ -h "${TARGET}/${FILE}" ]; then
621                         # break simlink
622                         echo "de-symlink ${TARGET}/${FILE}"
623                         "${RM}" -f "${TARGET}/${FILE}"
624                         "${MV}" "${DOTFILES_SRC}/${FILE}" "${TARGET}/${FILE}"
625                 fi
626         done <<-EOF
627                 $(list_files "${REPO}/patched-src")
628         EOF
629
630         if [ "${BASHRC}" == 'yes' ]; then
631                 echo 'strip dotfiles section from ~/.bashrc'
632                 "${SED}" '/DOTFILES_DIR/d' ~/.bashrc > bashrc_stripped
633
634                 # see if the stripped file is any different
635                 DIFF_OUTPUT=$("${DIFF}" ~/.bashrc bashrc_stripped)
636                 DIFF_RC="${?}"
637                 if [ "${DIFF_RC}" -eq 0 ]; then
638                         echo "no dotfiles section found in ~/.bashrc"
639                         "${RM}" -f bashrc_stripped
640                 elif [ "${DIFF_RC}" -eq 1 ]; then
641                         echo "replace ~/.bashrc with stripped version"
642                         "${RM}" -f ~/.bashrc
643                         "${MV}" bashrc_stripped ~/.bashrc
644                 else
645                         return 1  # diff failed, bail
646                 fi
647         fi
648
649         if [ -d "${DOTFILES_DIR}/${REPO}" ]; then
650                 echo "remove the ${REPO} repository"
651                 "${RM}" -rf "${DOTFILES_DIR}/${REPO}"
652         fi
653 }
654
655 ###
656 # update command
657
658 COMMANDS+=('update')
659
660 function update_help()
661 {
662         echo 'Utility command that runs fetch, patch, and link.'
663         if [ "${1}" = '--one-line' ]; then return; fi
664
665         cat <<-EOF
666
667                 usage: $0 ${COMMAND} [REPO]
668
669                 Where 'REPO' is the name the dotfiles repository to update.
670                 If it is not given, all repositories will be updateed.
671
672                 Run 'fetch', 'patch', and 'link' sequentially on each repository
673                 to bring them in sync with the central repositories.  Keeps track
674                 of the last update time to avoid multiple fetches in the same
675                 week.
676         EOF
677 }
678
679 function update()
680 {
681         # multi-repo case handled in main() by run_on_all_repos()
682         REPO=$(nonempty_option 'link' 'REPO' "${1}") || return 1
683         maxargs 'disconnect' 1 "${@}" || return 1
684
685         # Update once a week from our remote repository.  Mark updates by
686         # touching this file.
687         UPDATE_FILE="${REPO}/updated.$(date +%U)"
688
689         if [ ! -e "${UPDATE_FILE}" ]; then
690                 echo "update ${REPO} dotfiles"
691                 "${RM}" -f "${REPO}"/updated.* || return 1
692                 "${TOUCH}" "${UPDATE_FILE}" || return 1
693                 fetch "${REPO}" || return 1
694                 patch "${REPO}" || return 1
695                 link "${REPO}" || return 1
696                 echo "${REPO} dotfiles updated"
697         fi
698 }
699
700 #####
701 # Main entry-point
702
703 function main_help()
704 {
705         echo 'Dotfiles management script.'
706         if [ "${1}" = '--one-line' ]; then return; fi
707
708         cat <<-EOF
709
710                 usage: $0 [OPTIONS] COMMAND [ARGS]
711
712                 Options:
713                 --help  Print this help message and exit.
714                 --version       Print the $0 version and exit.
715                 --dotfiles-dir DIR      Directory containing the dotfiles reposotories.  Defaults to '.'.
716                 --target DIR    Directory to install dotfiles into.  Defaults to '~'.
717
718                 Commands:
719         EOF
720         for COMMAND in "${COMMANDS[@]}"; do
721                         echo -en "${COMMAND}\t"
722                         "${COMMAND}_help" --one-line
723         done
724         cat <<-EOF
725
726                 To get help on any command, pass the '--help' as the first option
727                 to the command.  For example:
728
729                   ${0} ${COMMANDS[0]} --help
730         EOF
731 }
732
733 function main()
734 {
735         COMMAND=''
736         while [ "${1::2}" = '--' ]; do
737                 case "${1}" in
738                         '--help')
739                                 main_help || return 1
740                                 return
741                                 ;;
742                         '--version')
743                                 echo "${VERSION}"
744                                 return
745                                 ;;
746                         '--dotfiles-dir')
747                                 DOTFILES_DIR="${2}"
748                                 shift
749                                 ;;
750                         '--target')
751                                 TARGET="${2}"
752                                 shift
753                                 ;;
754                         *)
755                                 echo "ERROR: invalid option to ${0} (${1})" >&2
756                                 return 1
757                 esac
758                 shift
759         done
760         COMMAND=$(get_selection "${1}" "${COMMANDS[@]}") || return 1
761         shift
762
763         cd "${DOTFILES_DIR}" || return 1
764
765         if [ "${1}" = '--help' ]; then
766                 "${COMMAND}_help" || return 1
767         elif [ "${COMMAND}" = 'clone' ]; then
768                 "${COMMAND}" "${@}" || return 1
769         else
770                 OPTIONS=()
771                 while [ "${1::2}" = '--' ]; do
772                         OPTIONS+=("${1}")
773                         shift
774                 done
775                 if [ "${#}" -eq 0 ]; then
776                         run_on_all_repos "${COMMAND}" "${OPTIONS[@]}" || return 1
777                 else
778                         maxargs "${0}" 1 "${@}" || return 1
779                         "${COMMAND}" "${OPTIONS[@]}" "${1}" || return 1
780                 fi
781         fi
782 }
783
784 main "${@}" || exit 1