Add `link --force-link` to override old links (e.g. convert to relative).
[dotfiles-framework.git] / dotfiles.sh
index 1e7b8a61cb2f08a4c2b9cf83074510217e8d3027..cf3af80cbdc0925dea8f49bbd3fd52ff34c49f8d 100755 (executable)
@@ -2,6 +2,21 @@
 #
 # Dotfiles management script.  For details, run
 #   $ dotfiles.sh --help
 #
 # Dotfiles management script.  For details, run
 #   $ dotfiles.sh --help
+#
+# Copyright (C) 2011-2012 W. Trevor King <wking@tremily.us>
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
 
 VERSION='0.2'
 DOTFILES_DIR="${PWD}"
 
 VERSION='0.2'
 DOTFILES_DIR="${PWD}"
@@ -23,6 +38,19 @@ TAR=$(which tar)
 TOUCH=$(which touch)
 WGET=$(which wget)
 
 TOUCH=$(which touch)
 WGET=$(which wget)
 
+#####
+# Compatibility checks
+
+BASH="${BASH_VERSION%.*}"
+BASH_MAJOR="${BASH%.*}"
+BASH_MINOR="${BASH#*.}"
+
+if [ "${BASH_MAJOR}" -eq 3 ] && [ "${BASH_MINOR}" -eq 0 ]; then
+       echo "ERROR: ${0} requires Bash version >= 3.1" >&2
+       echo "you're running ${BASH}, which doesn't support += array assignment" >&2
+       exit 1
+fi
+
 #####
 # Utility functions
 
 #####
 # Utility functions
 
@@ -76,12 +104,15 @@ function get_selection()
 function run_on_all_repos()
 {
        COMMAND="${1}"
 function run_on_all_repos()
 {
        COMMAND="${1}"
+       shift
        if [ -z "${REPO}" ]; then  # run on all repositories
                for REPO in *; do
                        if [ "${REPO}" = '*' ]; then
                                break  # no known repositories
        if [ -z "${REPO}" ]; then  # run on all repositories
                for REPO in *; do
                        if [ "${REPO}" = '*' ]; then
                                break  # no known repositories
+                       elif [ -f "${REPO}" ]; then
+                               continue  # repositories are directories
                        fi
                        fi
-                       "${COMMAND}" "${REPO}" || return 1
+                       "${COMMAND}" "${@}" "${REPO}" || return 1
                done
                return
        fi
                done
                return
        fi
@@ -100,10 +131,18 @@ function list_files()
 }
 
 # Global variable to allow passing associative arrats between functions
 }
 
 # Global variable to allow passing associative arrats between functions
-declare -A REPO_SOURCE_DATA
+
+if [ "${BASH_MAJOR}" -ge 4 ]; then
+       declare -A REPO_SOURCE_DATA
+fi
 
 function set_repo_source()
 {
 
 function set_repo_source()
 {
+       if [ "${BASH_MAJOR}" -lt 4 ]; then
+               echo "ERROR: ${0}'s set_repo_source requires Bash version >= 4.0" >&2
+               echo "you're running ${BASH}, which doesn't support associative arrays" >&2
+               return 1
+       fi
        REPO=$(nonempty_option 'set_repo_source' 'REPO' "${1}") || return 1
        > "${REPO}/source_cache" || return 1
        for KEY in "${!REPO_SOURCE_DATA[@]}"; do
        REPO=$(nonempty_option 'set_repo_source' 'REPO' "${1}") || return 1
        > "${REPO}/source_cache" || return 1
        for KEY in "${!REPO_SOURCE_DATA[@]}"; do
@@ -114,6 +153,11 @@ function set_repo_source()
 # usage: get_repo_source REPO
 function get_repo_source()
 {
 # usage: get_repo_source REPO
 function get_repo_source()
 {
+       if [ "${BASH_MAJOR}" -lt 4 ]; then
+               echo "ERROR: ${0}'s get_repo_source() requires Bash version >= 4.0" >&2
+               echo "you're running ${BASH}, which doesn't support associative arrays" >&2
+               return 1
+       fi
        REPO=$(nonempty_option 'get_repo_source' 'REPO' "${1}") || return 1
        REPO_SOURCE_DATA=()
        if [ -f "${REPO}/source_cache" ]; then
        REPO=$(nonempty_option 'get_repo_source' 'REPO' "${1}") || return 1
        REPO_SOURCE_DATA=()
        if [ -f "${REPO}/source_cache" ]; then
@@ -136,6 +180,17 @@ function get_repo_source()
        fi
 }
 
        fi
 }
 
+function git_fetch()
+{
+       REPO=$(nonempty_option 'git_fetch' 'REPO' "${1}") || return 1
+       REMOTES=$(cd "${REPO}" && "${GIT}" remote) || return 1
+       if [ -n "${REMOTES}" ]; then
+               (cd "${REPO}" && "${GIT}" pull) || return 1
+       else
+               echo "no remote repositories found for ${REPO}"
+       fi
+}
+
 function wget_fetch()
 {
        REPO=$(nonempty_option 'wget_fetch' 'REPO' "${1}") || return 1
 function wget_fetch()
 {
        REPO=$(nonempty_option 'wget_fetch' 'REPO' "${1}") || return 1
@@ -161,11 +216,9 @@ function wget_fetch()
                if [ -n "${SERVER_ETAG}" ]; then  # store new ETag
                        REPO_SOURCE_DATA['etag']="${SERVER_ETAG}"
                        set_repo_source "${REPO}" || return 1
                if [ -n "${SERVER_ETAG}" ]; then  # store new ETag
                        REPO_SOURCE_DATA['etag']="${SERVER_ETAG}"
                        set_repo_source "${REPO}" || return 1
-               else
-                       if [ -n "${ETAG}" ]; then  # clear old ETag
-                               unset "${REPO_SOURCE_DATA['etag']}"
-                               set_repo_source "${REPO}" || return 1
-                       fi
+               elif [ -n "${ETAG}" ]; then  # clear old ETag
+                       unset "${REPO_SOURCE_DATA['etag']}"
+                       set_repo_source "${REPO}" || return 1
                fi
                echo "extracting ${BUNDLE} to ${REPO}"
                "${TAR}" -xf "${BUNDLE}" -C "${REPO}" --strip-components 1 --overwrite || return 1
                fi
                echo "extracting ${BUNDLE} to ${REPO}"
                "${TAR}" -xf "${BUNDLE}" -C "${REPO}" --strip-components 1 --overwrite || return 1
@@ -175,6 +228,7 @@ function wget_fetch()
        fi
 }
 
        fi
 }
 
+
 # usage: link_file REPO FILE
 #
 # Create the symbolic link to the version of FILE in the REPO
 # usage: link_file REPO FILE
 #
 # Create the symbolic link to the version of FILE in the REPO
@@ -203,7 +257,7 @@ function link_file()
                echo "link ${TARGET}/${FILE} to ${DOTFILES_DIR}/${REPO}/patched-src/${FILE}"
        else
                echo -n 'link '
                echo "link ${TARGET}/${FILE} to ${DOTFILES_DIR}/${REPO}/patched-src/${FILE}"
        else
                echo -n 'link '
-               "${LN}" -sv "${DOTFILES_DIR}/${REPO}/patched-src/${FILE}" "${TARGET}/${FILE}" || return 1
+               "${LN}" -rsv "${DOTFILES_DIR}/${REPO}/patched-src/${FILE}" "${TARGET}/${FILE}" || return 1
        fi
 }
 
        fi
 }
 
@@ -301,10 +355,15 @@ function fetch()
        # multi-repo case handled in main() by run_on_all_repos()
        REPO=$(nonempty_option 'fetch' 'REPO' "${1}") || return 1
        maxargs 'fetch' 1 "${@}" || return 1
        # multi-repo case handled in main() by run_on_all_repos()
        REPO=$(nonempty_option 'fetch' 'REPO' "${1}") || return 1
        maxargs 'fetch' 1 "${@}" || return 1
-       get_repo_source "${REPO}" || return 1
-       TRANSFER=$(nonempty_option 'fetch' 'TRANSFER' "${REPO_SOURCE_DATA['transfer']}") || return 1
+       if [ "${BASH_MAJOR}" -ge 4 ]; then
+               get_repo_source "${REPO}" || return 1
+               TRANSFER=$(nonempty_option 'fetch' 'TRANSFER' "${REPO_SOURCE_DATA['transfer']}") || return 1
+       else
+               echo "WARNING: Bash version < 4.0, assuming all repos use git transfer" >&2
+               TRANSFER='git'
+       fi
        if [ "${TRANSFER}" = 'git' ]; then
        if [ "${TRANSFER}" = 'git' ]; then
-               "${GIT}" --git-dir "${REPO}/.git" pull || return 1
+               git_fetch "${REPO}" || return 1
        elif [ "${TRANSFER}" = 'wget' ]; then
                wget_fetch "${REPO}" || return 1
        else
        elif [ "${TRANSFER}" = 'wget' ]; then
                wget_fetch "${REPO}" || return 1
        else
@@ -349,16 +408,16 @@ function diff()
        MODE='standard'
        while [ "${1::2}" = '--' ]; do
                case "${1}" in
        MODE='standard'
        while [ "${1::2}" = '--' ]; do
                case "${1}" in
-                       '--removed')
-                               MODE='removed'
-                               ;;
+                       '--removed')
+                               MODE='removed'
+                               ;;
                        '--local-patch')
                        '--local-patch')
-                               MODE='local-patch'
-                               ;;
-                       *)
-                               echo "ERROR: invalid option to diff (${1})" >&2
-                               return 1
-               esac
+                               MODE='local-patch'
+                               ;;
+                       *)
+                               echo "ERROR: invalid option to diff (${1})" >&2
+                               return 1
+                       esac
                shift
        done
        # multi-repo case handled in main() by run_on_all_repos()
                shift
        done
        # multi-repo case handled in main() by run_on_all_repos()
@@ -378,7 +437,7 @@ function diff()
                exec 3<&1     # save stdout to file descriptor 3
                echo "save local removed to ${REPO}/local-patch/000-local.remove"
                exec 1>"${REPO}/local-patch/000-local.remove"  # redirect stdout
                exec 3<&1     # save stdout to file descriptor 3
                echo "save local removed to ${REPO}/local-patch/000-local.remove"
                exec 1>"${REPO}/local-patch/000-local.remove"  # redirect stdout
-               diff "${REPO}" --removed
+               diff --removed "${REPO}"
                exec 1<&3     # restore old stdout
                exec 3<&-     # close temporary fd 3
                return
                exec 1<&3     # restore old stdout
                exec 3<&-     # close temporary fd 3
                return
@@ -389,10 +448,8 @@ function diff()
                        if [ ! -e "${TARGET}/${FILE}" ]; then
                                echo "${FILE}"
                        fi
                        if [ ! -e "${TARGET}/${FILE}" ]; then
                                echo "${FILE}"
                        fi
-               else
-                       if [ -f "${TARGET}/${FILE}" ]; then
-                               (cd "${REPO}/src" && "${DIFF}" -u "${FILE}" "${TARGET}/${FILE}")
-                       fi
+               elif [ -f "${TARGET}/${FILE}" ]; then
+                       (cd "${REPO}/src" && "${DIFF}" -u "${FILE}" "${TARGET}/${FILE}")
                fi
        done <<-EOF
                $(list_files "${REPO}/src")
                fi
        done <<-EOF
                $(list_files "${REPO}/src")
@@ -432,7 +489,7 @@ function patch()
                if [ -f "${FILE}" ]; then
                        echo "apply ${FILE}"
                        pushd "${REPO}/patched-src/" > /dev/null || return 1
                if [ -f "${FILE}" ]; then
                        echo "apply ${FILE}"
                        pushd "${REPO}/patched-src/" > /dev/null || return 1
-                       "${PATCH}" -p0 < "../../${FILE}" || return 1
+                       "${PATCH}" -p1 < "../../${FILE}" || return 1
                        popd > /dev/null || return 1
                fi
        done
                        popd > /dev/null || return 1
                fi
        done
@@ -440,6 +497,7 @@ function patch()
        # remove any files marked for removal in local-patch
        for REMOVE in "${REPO}/local-patch"/*.remove; do
                if [ -f "${REMOVE}" ]; then
        # remove any files marked for removal in local-patch
        for REMOVE in "${REPO}/local-patch"/*.remove; do
                if [ -f "${REMOVE}" ]; then
+                       echo "apply ${FILE}"
                        while read LINE; do
                                if [ -z "${LINE}" ] || [ "${LINE:0:1}" = '#' ]; then
                                        continue  # ignore blank lines and comments
                        while read LINE; do
                                if [ -z "${LINE}" ] || [ "${LINE:0:1}" = '#' ]; then
                                        continue  # ignore blank lines and comments
@@ -465,41 +523,52 @@ function link_help()
 
        cat <<-EOF
 
 
        cat <<-EOF
 
-               usage: $0 ${COMMAND} [--force|--force-file] [--dry-run] [--no-backup] [REPO]
+               usage: $0 ${COMMAND} [--force] [--force-dir] [--force-file] [--force-link]
+             [--dry-run] [--no-backup] [REPO]
 
                Where 'REPO' is the name the dotfiles repository to link.  If it
                is not given, all repositories will be linked.
 
 
                Where 'REPO' is the name the dotfiles repository to link.  If it
                is not given, all repositories will be linked.
 
-               By default, link.sh only replaces missing files and simlinks.  You
-               can optionally overwrite any local files by passing the --force
-               option.
+               By default, ${COMMAND} only replaces missing directories, files,
+               simlinks.  You can optionally overwrite any local stuff by passing
+               the --force option.  If you only want to overwrite a particular
+               type, use the more granular --force-dir, etc.
        EOF
 }
 
 function link()
 {
        EOF
 }
 
 function link()
 {
-       FORCE='no'   # If 'file', overwrite existing files.
-                    # If 'yes', overwrite existing files and dirs.
+       FORCE_DIR='no'    # If 'yes', overwrite existing directories.
+       FORCE_FILE='no'   # If 'yes', overwrite existing files.
+       FORCE_LINK='no'   # If 'yes', overwrite existing symlinks.
        DRY_RUN='no' # If 'yes', disable any actions that change the filesystem
        DRY_RUN='no' # If 'yes', disable any actions that change the filesystem
-       BACKUP='yes'
+       BACKUP_OPT='yes'
        while [ "${1::2}" = '--' ]; do
        while [ "${1::2}" = '--' ]; do
-       case "${1}" in
-               '--force')
-                       FORCE='yes'
-                       ;;
-               '--force-file')
-                       FORCE='file'
-                       ;;
-               '--dry-run')
-                       DRY_RUN='yes'
-                       ;;
-               '--no-backup')
-                       BACKUP='no'
-                       ;;
-               *)
-                       echo "ERROR: invalid option to link (${1})" >&2
-                       return 1
-       esac
+               case "${1}" in
+                       '--force')
+                               FORCE_DIR='yes'
+                               FORCE_FILE='yes'
+                               FORCE_LINK='yes'
+                               ;;
+                       '--force-dir')
+                               FORCE_DIR='yes'
+                               ;;
+                       '--force-file')
+                               FORCE_FILE='yes'
+                               ;;
+                       '--force-link')
+                               FORCE_LINK='yes'
+                               ;;
+                       '--dry-run')
+                               DRY_RUN='yes'
+                               ;;
+                       '--no-backup')
+                               BACKUP_OPT='no'
+                               ;;
+                       *)
+                               echo "ERROR: invalid option to link (${1})" >&2
+                               return 1
+               esac
                shift
        done
        # multi-repo case handled in main() by run_on_all_repos()
                shift
        done
        # multi-repo case handled in main() by run_on_all_repos()
@@ -508,17 +577,24 @@ function link()
        DOTFILES_SRC="${DOTFILES_DIR}/${REPO}/patched-src"
 
        while read FILE; do
        DOTFILES_SRC="${DOTFILES_DIR}/${REPO}/patched-src"
 
        while read FILE; do
+               BACKUP="${BACKUP_OPT}"
                if [ "${DOTFILES_SRC}/${FILE}" -ef "${TARGET}/${FILE}" ]; then
                if [ "${DOTFILES_SRC}/${FILE}" -ef "${TARGET}/${FILE}" ]; then
-                       continue  # already simlinked
-               fi
-               if [ -d "${DOTFILES_SRC}/${FILE}" ] && [ -d "${TARGET}/${FILE}" ] && \
-                       [ "${FORCE}" != 'yes' ]; then
-                       echo "use --force to override the existing directory: ${TARGET}/${FILE}"
-                       continue  # allow unlinked directories
-               fi
-               if [ -e "$TARGET/${FILE}" ] && [ "${FORCE}" = 'no' ]; then
-                       echo "use --force to override the existing target: ${TARGET}/${FILE}"
-                       continue  # target already exists
+                       if [ "${FORCE_LINK}" = 'no' ]; then
+                               # don't prompt about --force-link, because this will happen a lot
+                               continue  # already simlinked
+                       else
+                               # don't backup links that already point to the right place
+                               BACKUP='no'
+                       fi
+               else
+                       if [ -d "${DOTFILES_SRC}/${FILE}" ] && [ -d "${TARGET}/${FILE}" ] && \
+                               [ "${FORCE_DIR}" = 'no' ]; then
+                               echo "use --force-dir to override the existing directory: ${TARGET}/${FILE}"
+                               continue  # allow unlinked directories
+                       elif [ -f "${TARGET}/${FILE}" ] && [ "${FORCE_FILE}" = 'no' ]; then
+                               echo "use --force-file to override the existing target: ${TARGET}/${FILE}"
+                               continue  # target already exists
+                       fi
                fi
                link_file "${REPO}" "${FILE}" || return 1
        done <<-EOF
                fi
                link_file "${REPO}" "${FILE}" || return 1
        done <<-EOF
@@ -569,7 +645,7 @@ function disconnect()
        BASHRC='no'
 
        while read FILE; do
        BASHRC='no'
 
        while read FILE; do
-               if [ "${FILE}" = '.bashrc' ] && [ "$TARGET" -ef "${HOME}" ]; then
+               if [ "${FILE}" = '.bashrc' ] && [ "${TARGET}" -ef "${HOME}" ]; then
                        BASHRC='yes'
                fi
                if [ "${DOTFILES_SRC}/${FILE}" -ef "${TARGET}/${FILE}" ] && [ -h "${TARGET}/${FILE}" ]; then
                        BASHRC='yes'
                fi
                if [ "${DOTFILES_SRC}/${FILE}" -ef "${TARGET}/${FILE}" ] && [ -h "${TARGET}/${FILE}" ]; then
@@ -689,27 +765,27 @@ function main()
 {
        COMMAND=''
        while [ "${1::2}" = '--' ]; do
 {
        COMMAND=''
        while [ "${1::2}" = '--' ]; do
-       case "${1}" in
-               '--help')
-                       main_help || return 1
-                       return
-                       ;;
-               '--version')
-                       echo "${VERSION}"
-                       return
-                       ;;
-               '--dotfiles-dir')
-                       DOTFILES_DIR="${2}"
-                       shift
-                       ;;
-               '--target')
-                       TARGET="${2}"
-                       shift
-                       ;;
-               *)
-                       echo "ERROR: invalid option to ${0} (${1})" >&2
-                       return 1
-       esac
+               case "${1}" in
+                       '--help')
+                               main_help || return 1
+                               return
+                               ;;
+                       '--version')
+                               echo "${VERSION}"
+                               return
+                               ;;
+                       '--dotfiles-dir')
+                                       DOTFILES_DIR="${2}"
+                               shift
+                               ;;
+                       '--target')
+                               TARGET="${2}"
+                               shift
+                               ;;
+                       *)
+                               echo "ERROR: invalid option to ${0} (${1})" >&2
+                               return 1
+               esac
                shift
        done
        COMMAND=$(get_selection "${1}" "${COMMANDS[@]}") || return 1
                shift
        done
        COMMAND=$(get_selection "${1}" "${COMMANDS[@]}") || return 1
@@ -728,7 +804,7 @@ function main()
                        shift
                done
                if [ "${#}" -eq 0 ]; then
                        shift
                done
                if [ "${#}" -eq 0 ]; then
-                       run_on_all_repos "${COMMAND}" "$OPTIONS[@]" || return 1
+                       run_on_all_repos "${COMMAND}" "${OPTIONS[@]}" || return 1
                else
                        maxargs "${0}" 1 "${@}" || return 1
                        "${COMMAND}" "${OPTIONS[@]}" "${1}" || return 1
                else
                        maxargs "${0}" 1 "${@}" || return 1
                        "${COMMAND}" "${OPTIONS[@]}" "${1}" || return 1