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