Don't force loopy local symlinks if we've already linked a parent.
[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.  If you want to
236 # override the options passed to ${LN}, set LINK_OPTS.
237 function link_file()
238 {
239         REPO=$(nonempty_option 'link_file' 'REPO' "${1}") || return 1
240         FILE=$(nonempty_option 'link_file' 'FILE' "${2}") || return 1
241         LINK_OPTS="${LINK_OPTS:--sv}"  # default to `-sv`
242         if [ "${BACKUP}" = 'yes' ]; then
243                 if [ -e "${TARGET}/${FILE}" ] || [ -h "${TARGET}/${FILE}" ]; then
244                         if [ "${DRY_RUN}" = 'yes' ]; then
245                                 echo "move ${TARGET}/${FILE} to ${TARGET}/${FILE}.bak"
246                         else
247                                 echo -n 'move '
248                                 mv -v "${TARGET}/${FILE}" "${TARGET}/${FILE}.bak" || return 1
249                         fi
250                 fi
251         else
252                 if [ "${DRY_RUN}" = 'yes' ]; then
253                         echo "rm ${TARGET}/${FILE}"
254                 else
255                         "${RM}" -fv "${TARGET}/${FILE}"
256                 fi
257         fi
258         if [ "${DRY_RUN}" = 'yes' ]; then
259                 echo "link ${TARGET}/${FILE} to ${DOTFILES_DIR}/${REPO}/patched-src/${FILE}"
260         else
261                 SOURCE="${DOTFILES_DIR}/${REPO}/patched-src/${FILE}"
262                 echo -n 'link '
263                 "${LN}" ${LINK_OPTS} "${SOURCE}" "${TARGET}/${FILE}" || return 1
264         fi
265 }
266
267 #####
268 # Top-level commands
269
270 # An array of available commands
271 COMMANDS=()
272
273 ###
274 # clone command
275
276 COMMANDS+=('clone')
277
278 CLONE_TRANSFERS=('git' 'wget')
279
280 function clone_help()
281 {
282         echo 'Create a new dotfiles repository.'
283         if [ "${1}" = '--one-line' ]; then return; fi
284
285         cat <<-EOF
286
287                 usage: $0 ${COMMAND} REPO TRANSFER URL
288
289                 Where 'REPO' is the name the dotfiles repository to create,
290                 'TRANSFER' is the transfer mechanism, and 'URL' is the URL for the
291                 remote repository.  Valid TRANSFERs are:
292
293                   ${CLONE_TRANSFERS[@]}
294
295                 Examples:
296
297                   $0 clone public wget http://example.com/public-dotfiles.tar.gz
298                   $0 clone private git ssh://example.com/~/private-dotfiles.git
299         EOF
300 }
301
302 function clone()
303 {
304         REPO=$(nonempty_option 'clone' 'REPO' "${1}") || return 1
305         TRANSFER=$(nonempty_option 'clone' 'TRANSFER' "${2}") || return 1
306         URL=$(nonempty_option 'clone' 'URL' "${3}") || return 1
307         maxargs 'clone' 3 "${@}" || return 1
308         TRANSFER=$(get_selection "${TRANSFER}" "${CLONE_TRANSFERS[@]}") || return 1
309         if [ -e "${REPO}" ]; then
310                 echo "ERROR: destination path (${REPO}) already exists." >&2
311                 return 1
312         fi
313         CACHE_SOURCE='yes'
314         FETCH='yes'
315         case "${TRANSFER}" in
316                 'git')
317                         CACHE_SOURCE='no'
318                         FETCH='no'
319                         "${GIT}" clone "${URL}" "${REPO}" || return 1
320                         ;;
321                 'wget')
322                         mkdir -p "${REPO}"
323                         ;;
324                 *)
325                         echo "PROGRAMMING ERROR: add ${TRANSFER} support to clone command" >&2
326                         return 1
327         esac
328         if [ "${CACHE_SOURCE}" = 'yes' ]; then
329                 REPO_SOURCE_DATA=(['transfer']="${TRANSFER}" ['url']="${URL}")
330                 set_repo_source "${REPO}" || return 1
331         fi
332         if [ "${FETCH}" = 'yes' ]; then
333                 fetch "${REPO}" || return 1
334         fi
335 }
336
337 ###
338 # fetch command
339
340 COMMANDS+=('fetch')
341
342 function fetch_help()
343 {
344         echo 'Get the current dotfiles from the server.'
345         if [ "${1}" = '--one-line' ]; then return; fi
346
347         cat <<-EOF
348
349                 usage: $0 ${COMMAND} [REPO]
350
351                 Where 'REPO' is the name the dotfiles repository to fetch.  If it
352                 is not given, all repositories will be fetched.
353         EOF
354 }
355
356 function fetch()
357 {
358         # multi-repo case handled in main() by run_on_all_repos()
359         REPO=$(nonempty_option 'fetch' 'REPO' "${1}") || return 1
360         maxargs 'fetch' 1 "${@}" || return 1
361         if [ "${BASH_MAJOR}" -ge 4 ]; then
362                 get_repo_source "${REPO}" || return 1
363                 TRANSFER=$(nonempty_option 'fetch' 'TRANSFER' "${REPO_SOURCE_DATA['transfer']}") || return 1
364         else
365                 echo "WARNING: Bash version < 4.0, assuming all repos use git transfer" >&2
366                 TRANSFER='git'
367         fi
368         if [ "${TRANSFER}" = 'git' ]; then
369                 git_fetch "${REPO}" || return 1
370         elif [ "${TRANSFER}" = 'wget' ]; then
371                 wget_fetch "${REPO}" || return 1
372         else
373                 echo "PROGRAMMING ERROR: add ${TRANSFER} support to fetch command" >&2
374                 return 1
375         fi
376 }
377
378 ###
379 # fetch command
380
381 COMMANDS+=('diff')
382
383 function diff_help()
384 {
385         echo 'Show differences between targets and dotfiles repositories.'
386         if [ "${1}" = '--one-line' ]; then return; fi
387
388         cat <<-EOF
389
390                 usage: $0 ${COMMAND} [--removed|--local-patch] [REPO]
391
392                 Where 'REPO' is the name the dotfiles repository to query.  If it
393                 is not given, all repositories will be queried.
394
395                 By default, ${COMMAND} will list differences between files that
396                 exist in both the target location and the dotfiles repository (as
397                 a patch that could be applied to the dotfiles source).
398
399                 With the '--removed' option, ${COMMAND} will list files that
400                 should be removed from the dotfiles source in order to match the
401                 target.
402
403                 With the '--local-patch' option, ${COMMAND} will create files in
404                 list files that should be removed from the dotfiles source in
405                 order to match the target.
406         EOF
407 }
408
409 function diff()
410 {
411         MODE='standard'
412         while [ "${1::2}" = '--' ]; do
413                 case "${1}" in
414                         '--removed')
415                                 MODE='removed'
416                                 ;;
417                         '--local-patch')
418                                 MODE='local-patch'
419                                 ;;
420                         *)
421                                 echo "ERROR: invalid option to diff (${1})" >&2
422                                 return 1
423                         esac
424                 shift
425         done
426         # multi-repo case handled in main() by run_on_all_repos()
427         REPO=$(nonempty_option 'diff' 'REPO' "${1}") || return 1
428         maxargs 'diff' 1 "${@}" || return 1
429
430         if [ "${MODE}" = 'local-patch' ]; then
431                 mkdir -p "${REPO}/local-patch" || return 1
432
433                 exec 3<&1     # save stdout to file descriptor 3
434                 echo "save local patches to ${REPO}/local-patch/000-local.patch"
435                 exec 1>"${REPO}/local-patch/000-local.patch"  # redirect stdout
436                 diff "${REPO}"
437                 exec 1<&3     # restore old stdout
438                 exec 3<&-     # close temporary fd 3
439
440                 exec 3<&1     # save stdout to file descriptor 3
441                 echo "save local removed to ${REPO}/local-patch/000-local.remove"
442                 exec 1>"${REPO}/local-patch/000-local.remove"  # redirect stdout
443                 diff --removed "${REPO}"
444                 exec 1<&3     # restore old stdout
445                 exec 3<&-     # close temporary fd 3
446                 return
447         fi
448
449         while read FILE; do
450                 if [ "${MODE}" = 'removed' ]; then
451                         if [ ! -e "${TARGET}/${FILE}" ]; then
452                                 echo "${FILE}"
453                         fi
454                 elif [ -f "${TARGET}/${FILE}" ]; then
455                         (cd "${REPO}/src" && "${DIFF}" -u "${FILE}" "${TARGET}/${FILE}")
456                 fi
457         done <<-EOF
458                 $(list_files "${REPO}/src")
459         EOF
460 }
461
462 ###
463 # patch command
464
465 COMMANDS+=('patch')
466
467 function patch_help()
468 {
469         echo 'Patch a fresh checkout with local adjustments.'
470         if [ "${1}" = '--one-line' ]; then return; fi
471
472         cat <<-EOF
473
474                 usage: $0 ${COMMAND} [REPO]
475
476                 Where 'REPO' is the name the dotfiles repository to patch.  If it
477                 is not given, all repositories will be patched.
478         EOF
479 }
480
481 function patch()
482 {
483         # multi-repo case handled in main() by run_on_all_repos()
484         REPO=$(nonempty_option 'patch' 'REPO' "${1}") || return 1
485         maxargs 'patch' 1 "${@}" || return 1
486
487         echo "copy clean checkout into ${REPO}/patched-src"
488         "${RSYNC}" -avz --delete "${REPO}/src/" "${REPO}/patched-src/" || return 1
489
490         # apply all the patches in local-patch/
491         for FILE in "${REPO}/local-patch"/*.patch; do
492                 if [ -f "${FILE}" ]; then
493                         echo "apply ${FILE}"
494                         pushd "${REPO}/patched-src/" > /dev/null || return 1
495                         "${PATCH}" -p1 < "../../${FILE}" || return 1
496                         popd > /dev/null || return 1
497                 fi
498         done
499
500         # remove any files marked for removal in local-patch
501         for REMOVE in "${REPO}/local-patch"/*.remove; do
502                 if [ -f "${REMOVE}" ]; then
503                         echo "apply ${FILE}"
504                         while read LINE; do
505                                 if [ -z "${LINE}" ] || [ "${LINE:0:1}" = '#' ]; then
506                                         continue  # ignore blank lines and comments
507                                 fi
508                                 if [ -e "${REPO}/patched-src/${LINE}" ]; then
509                                         echo "remove ${LINE}"
510                                         "${RM}" -rf "${REPO}/patched-src/${LINE}"
511                                 fi
512                         done < "${REMOVE}"
513                 fi
514         done
515 }
516
517 ###
518 # link command
519
520 COMMANDS+=('link')
521
522 function link_help()
523 {
524         echo 'Link a fresh checkout with local adjustments.'
525         if [ "${1}" = '--one-line' ]; then return; fi
526
527         cat <<-EOF
528
529                 usage: $0 ${COMMAND} [--force] [--force-dir] [--force-file] [--force-link]
530                            [--dry-run] [--no-backup] [--relative] [REPO]
531
532                 Where 'REPO' is the name the dotfiles repository to link.  If it
533                 is not given, all repositories will be linked.
534
535                 By default, ${COMMAND} only replaces missing directories, files,
536                 simlinks.  You can optionally overwrite any local stuff by passing
537                 the --force option.  If you only want to overwrite a particular
538                 type, use the more granular --force-dir, etc.
539
540                 If you have coreutils 8.16 (2012-03-26) or greater, you can set
541                 the --relative option to create symlinks that use relative paths.
542         EOF
543 }
544
545 function link()
546 {
547         FORCE_DIR='no'    # If 'yes', overwrite existing directories.
548         FORCE_FILE='no'   # If 'yes', overwrite existing files.
549         FORCE_LINK='no'   # If 'yes', overwrite existing symlinks.
550         DRY_RUN='no' # If 'yes', disable any actions that change the filesystem
551         BACKUP_OPT='yes'
552         LINK_OPTS='-sv'
553         while [ "${1::2}" = '--' ]; do
554                 case "${1}" in
555                         '--force')
556                                 FORCE_DIR='yes'
557                                 FORCE_FILE='yes'
558                                 FORCE_LINK='yes'
559                                 ;;
560                         '--force-dir')
561                                 FORCE_DIR='yes'
562                                 ;;
563                         '--force-file')
564                                 FORCE_FILE='yes'
565                                 ;;
566                         '--force-link')
567                                 FORCE_LINK='yes'
568                                 ;;
569                         '--dry-run')
570                                 DRY_RUN='yes'
571                                 ;;
572                         '--no-backup')
573                                 BACKUP_OPT='no'
574                                 ;;
575                         '--relative')
576                                 LINK_OPTS="${LINK_OPTS} --relative"
577                                 ;;
578                         *)
579                                 echo "ERROR: invalid option to link (${1})" >&2
580                                 return 1
581                 esac
582                 shift
583         done
584         # multi-repo case handled in main() by run_on_all_repos()
585         REPO=$(nonempty_option 'link' 'REPO' "${1}") || return 1
586         maxargs 'link' 1 "${@}" || return 1
587         DOTFILES_SRC="${DOTFILES_DIR}/${REPO}/patched-src"
588
589         while read FILE; do
590                 BACKUP="${BACKUP_OPT}"
591                 if [ "${DOTFILES_SRC}/${FILE}" -ef "${TARGET}/${FILE}" ]; then
592                         if [ "${FORCE_LINK}" = 'no' ]; then
593                                 # don't prompt about --force-link, because this will happen a lot
594                                 continue  # already simlinked
595                         elif [ ! -h "${TARGET}/${FILE}" ]; then
596                                 # target file/dir underneath an already symlinked dir
597                                 continue
598                         else
599                                 # don't backup links that already point to the right place
600                                 BACKUP='no'
601                         fi
602                 else
603                         if [ -d "${DOTFILES_SRC}/${FILE}" ] && [ -d "${TARGET}/${FILE}" ] && \
604                                 [ "${FORCE_DIR}" = 'no' ]; then
605                                 echo "use --force-dir to override the existing directory: ${TARGET}/${FILE}"
606                                 continue  # allow unlinked directories
607                         elif [ -f "${TARGET}/${FILE}" ] && [ "${FORCE_FILE}" = 'no' ]; then
608                                 echo "use --force-file to override the existing target: ${TARGET}/${FILE}"
609                                 continue  # target already exists
610                         fi
611                 fi
612                 link_file "${REPO}" "${FILE}" || return 1
613         done <<-EOF
614                 $(list_files "${DOTFILES_SRC}")
615         EOF
616 }
617
618 ###
619 # disconnect command
620
621 COMMANDS+=('disconnect')
622
623 function disconnect_help()
624 {
625         echo 'Freeze dotfiles at their current state.'
626         if [ "${1}" = '--one-line' ]; then return; fi
627
628         cat <<-EOF
629
630                 usage: $0 ${COMMAND} [REPO]
631
632                 Where 'REPO' is the name the dotfiles repository to disconnect.
633                 If it is not given, all repositories will be disconnected.
634
635                 You're about to give your sysadmin account to some newbie, and
636                 they'd just be confused by all this efficiency.  This script
637                 freezes your dotfiles in their current state and makes everthing
638                 look normal.  Note that this will delete your dotfiles repository
639                 and strip the dotfiles portion from your ~/.bashrc file.
640         EOF
641 }
642
643 function disconnect()
644 {
645         # multi-repo case handled in main() by run_on_all_repos()
646         REPO=$(nonempty_option 'link' 'REPO' "${1}") || return 1
647         maxargs 'disconnect' 1 "${@}" || return 1
648         DOTFILES_SRC="${DOTFILES_DIR}/${REPO}/patched-src"
649
650         # See if we've constructed any patched source files that might be
651         # possible link targets
652         if [ ! -d "${DOTFILES_SRC}" ]; then
653                 echo 'no installed dotfiles to disconnect'
654                 return
655         fi
656
657         # See if the bashrc file is involved with dotfiles at all
658         BASHRC='no'
659
660         while read FILE; do
661                 if [ "${FILE}" = '.bashrc' ] && [ "${TARGET}" -ef "${HOME}" ]; then
662                         BASHRC='yes'
663                 fi
664                 if [ "${DOTFILES_SRC}/${FILE}" -ef "${TARGET}/${FILE}" ] && [ -h "${TARGET}/${FILE}" ]; then
665                         # break simlink
666                         echo "de-symlink ${TARGET}/${FILE}"
667                         "${RM}" -f "${TARGET}/${FILE}"
668                         "${MV}" "${DOTFILES_SRC}/${FILE}" "${TARGET}/${FILE}"
669                 fi
670         done <<-EOF
671                 $(list_files "${REPO}/patched-src")
672         EOF
673
674         if [ "${BASHRC}" == 'yes' ]; then
675                 echo 'strip dotfiles section from ~/.bashrc'
676                 "${SED}" '/DOTFILES_DIR/d' ~/.bashrc > bashrc_stripped
677
678                 # see if the stripped file is any different
679                 DIFF_OUTPUT=$("${DIFF}" ~/.bashrc bashrc_stripped)
680                 DIFF_RC="${?}"
681                 if [ "${DIFF_RC}" -eq 0 ]; then
682                         echo "no dotfiles section found in ~/.bashrc"
683                         "${RM}" -f bashrc_stripped
684                 elif [ "${DIFF_RC}" -eq 1 ]; then
685                         echo "replace ~/.bashrc with stripped version"
686                         "${RM}" -f ~/.bashrc
687                         "${MV}" bashrc_stripped ~/.bashrc
688                 else
689                         return 1  # diff failed, bail
690                 fi
691         fi
692
693         if [ -d "${DOTFILES_DIR}/${REPO}" ]; then
694                 echo "remove the ${REPO} repository"
695                 "${RM}" -rf "${DOTFILES_DIR}/${REPO}"
696         fi
697 }
698
699 ###
700 # update command
701
702 COMMANDS+=('update')
703
704 function update_help()
705 {
706         echo 'Utility command that runs fetch, patch, and link.'
707         if [ "${1}" = '--one-line' ]; then return; fi
708
709         cat <<-EOF
710
711                 usage: $0 ${COMMAND} [REPO]
712
713                 Where 'REPO' is the name the dotfiles repository to update.
714                 If it is not given, all repositories will be updateed.
715
716                 Run 'fetch', 'patch', and 'link' sequentially on each repository
717                 to bring them in sync with the central repositories.  Keeps track
718                 of the last update time to avoid multiple fetches in the same
719                 week.
720         EOF
721 }
722
723 function update()
724 {
725         # multi-repo case handled in main() by run_on_all_repos()
726         REPO=$(nonempty_option 'link' 'REPO' "${1}") || return 1
727         maxargs 'disconnect' 1 "${@}" || return 1
728
729         # Update once a week from our remote repository.  Mark updates by
730         # touching this file.
731         UPDATE_FILE="${REPO}/updated.$(date +%U)"
732
733         if [ ! -e "${UPDATE_FILE}" ]; then
734                 echo "update ${REPO} dotfiles"
735                 "${RM}" -f "${REPO}"/updated.* || return 1
736                 "${TOUCH}" "${UPDATE_FILE}" || return 1
737                 fetch "${REPO}" || return 1
738                 patch "${REPO}" || return 1
739                 link "${REPO}" || return 1
740                 echo "${REPO} dotfiles updated"
741         fi
742 }
743
744 #####
745 # Main entry-point
746
747 function main_help()
748 {
749         echo 'Dotfiles management script.'
750         if [ "${1}" = '--one-line' ]; then return; fi
751
752         cat <<-EOF
753
754                 usage: $0 [OPTIONS] COMMAND [ARGS]
755
756                 Options:
757                 --help  Print this help message and exit.
758                 --version       Print the $0 version and exit.
759                 --dotfiles-dir DIR      Directory containing the dotfiles reposotories.  Defaults to '.'.
760                 --target DIR    Directory to install dotfiles into.  Defaults to '~'.
761
762                 Commands:
763         EOF
764         for COMMAND in "${COMMANDS[@]}"; do
765                         echo -en "${COMMAND}\t"
766                         "${COMMAND}_help" --one-line
767         done
768         cat <<-EOF
769
770                 To get help on any command, pass the '--help' as the first option
771                 to the command.  For example:
772
773                   ${0} ${COMMANDS[0]} --help
774         EOF
775 }
776
777 function main()
778 {
779         COMMAND=''
780         while [ "${1::2}" = '--' ]; do
781                 case "${1}" in
782                         '--help')
783                                 main_help || return 1
784                                 return
785                                 ;;
786                         '--version')
787                                 echo "${VERSION}"
788                                 return
789                                 ;;
790                         '--dotfiles-dir')
791                                 DOTFILES_DIR="${2}"
792                                 shift
793                                 ;;
794                         '--target')
795                                 TARGET="${2}"
796                                 shift
797                                 ;;
798                         *)
799                                 echo "ERROR: invalid option to ${0} (${1})" >&2
800                                 return 1
801                 esac
802                 shift
803         done
804         COMMAND=$(get_selection "${1}" "${COMMANDS[@]}") || return 1
805         shift
806
807         cd "${DOTFILES_DIR}" || return 1
808
809         if [ "${1}" = '--help' ]; then
810                 "${COMMAND}_help" || return 1
811         elif [ "${COMMAND}" = 'clone' ]; then
812                 "${COMMAND}" "${@}" || return 1
813         else
814                 OPTIONS=()
815                 while [ "${1::2}" = '--' ]; do
816                         OPTIONS+=("${1}")
817                         shift
818                 done
819                 if [ "${#}" -eq 0 ]; then
820                         run_on_all_repos "${COMMAND}" "${OPTIONS[@]}" || return 1
821                 else
822                         maxargs "${0}" 1 "${@}" || return 1
823                         "${COMMAND}" "${OPTIONS[@]}" "${1}" || return 1
824                 fi
825         fi
826 }
827
828 main "${@}" || exit 1