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