Bump to version 0.4
[dotfiles-framework.git] / dotfiles.sh
index 5927dab410ca2c603df462a02008af50aa121856..5f0c5e5cb9cb8f4202fa42db8fcd4ff7926d65ca 100755 (executable)
@@ -2,8 +2,23 @@
 #
 # Dotfiles management script.  For details, run
 #   $ dotfiles.sh --help
+#
+# Copyright (C) 2011-2015 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'
+VERSION='0.4'
 DOTFILES_DIR="${PWD}"
 TARGET=~
 CHECK_WGET_TYPE_AND_ENCODING='no'
@@ -11,17 +26,30 @@ CHECK_WGET_TYPE_AND_ENCODING='no'
 #####
 # External utilities
 
-DIFF=$(which diff)
-GIT=$(which git)
-LN=$(which ln)
-MV=$(which mv)
-PATCH=$(which patch)
-SED=$(which sed)
-RM=$(which rm)
-RSYNC=$(which rsync)
-TAR=$(which tar)
-TOUCH=$(which touch)
-WGET=$(which wget)
+DIFF=${DOTFILES_DIFF:-$(command -v diff)}
+GIT=${DOTFILES_GIT:-$(command -v git)}
+LN=${DOTFILES_LN:-$(command -v ln)}
+MV=${DOTFILES_MV:-$(command -v mv)}
+PATCH=${DOTFILES_PATCH:-$(command -v patch)}
+SED=${DOTFILES_SED:-$(command -v sed)}
+RM=${DOTFILES_RM:-$(command -v rm)}
+RSYNC=${DOTFILES_RSYNC:-$(command -v rsync)}
+TAR=${DOTFILES_TAR:-$(command -v tar)}
+TOUCH=${DOTFILES_TOUCH:-$(command -v touch)}
+WGET=${DOTFILES_WGET:-$(command -v 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
@@ -76,12 +104,15 @@ function get_selection()
 function run_on_all_repos()
 {
        COMMAND="${1}"
+       shift
        if [ -z "${REPO}" ]; then  # run on all repositories
-               for REPO in *; do
+               for REPO in *; do
                        if [ "${REPO}" = '*' ]; then
                                break  # no known repositories
+                       elif [ -f "${REPO}" ]; then
+                               continue  # repositories are directories
                        fi
-                       "${COMMAND}" "${REPO}" || return 1
+                       "${COMMAND}" "${@}" "${REPO}" || return 1
                done
                return
        fi
@@ -99,11 +130,19 @@ function list_files()
        done < <(cd "${DIR}" && find .)
 }
 
-# Global variable to allow passing associative arrats between functions
-declare -A REPO_SOURCE_DATA
+# Global variable to allow passing associative arrays between functions
+
+if [ "${BASH_MAJOR}" -ge 4 ]; then
+       declare -A REPO_SOURCE_DATA
+fi
 
 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
@@ -114,6 +153,11 @@ function set_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
@@ -128,6 +172,8 @@ function get_repo_source()
                REPO_SOURCE_DATA['repo']="${REPO}"
                if [ -d "${REPO}/.git" ]; then
                        REPO_SOURCE_DATA['transfer']='git'
+                       REPO_SOURCE_DATA['url']=$(
+                               git --git-dir "${REPO}/.git/" config remote.origin.url)
                else
                        echo "ERROR: no source location found for ${REPO}" >&2
                        return 1
@@ -136,6 +182,17 @@ function get_repo_source()
        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
@@ -161,11 +218,9 @@ function wget_fetch()
                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
@@ -175,14 +230,17 @@ function wget_fetch()
        fi
 }
 
+
 # usage: link_file REPO FILE
 #
 # Create the symbolic link to the version of FILE in the REPO
-# repository, overriding the target if it exists.
+# repository, overriding the target if it exists.  If you want to
+# override the options passed to ${LN}, set LINK_OPTS.
 function link_file()
 {
        REPO=$(nonempty_option 'link_file' 'REPO' "${1}") || return 1
        FILE=$(nonempty_option 'link_file' 'FILE' "${2}") || return 1
+       LINK_OPTS="${LINK_OPTS:--sv}"  # default to `-sv`
        if [ "${BACKUP}" = 'yes' ]; then
                if [ -e "${TARGET}/${FILE}" ] || [ -h "${TARGET}/${FILE}" ]; then
                        if [ "${DRY_RUN}" = 'yes' ]; then
@@ -202,8 +260,9 @@ function link_file()
        if [ "${DRY_RUN}" = 'yes' ]; then
                echo "link ${TARGET}/${FILE} to ${DOTFILES_DIR}/${REPO}/patched-src/${FILE}"
        else
+               SOURCE="${DOTFILES_DIR}/${REPO}/patched-src/${FILE}"
                echo -n 'link '
-               "${LN}" -sv "${DOTFILES_DIR}/${REPO}/patched-src/${FILE}" "${TARGET}/${FILE}" || return 1
+               "${LN}" ${LINK_OPTS} "${SOURCE}" "${TARGET}/${FILE}" || return 1
        fi
 }
 
@@ -277,6 +336,49 @@ function clone()
        fi
 }
 
+###
+# list command
+
+COMMANDS+=('list')
+
+function list_help()
+{
+       echo 'List current dotfiles repositories.'
+       if [ "${1}" = '--one-line' ]; then return; fi
+
+       cat <<-EOF
+
+               usage: $0 ${COMMAND} [REPO]
+
+               List information for 'REPO' in a form simular to the 'clone'
+               command's arguments.  If 'REPO' is not give, all repositories will
+               be listed.  Examples:
+
+                 $0 list public
+                 public wget http://example.com/public-dotfiles.tar.gz
+                 $0 list
+                 public wget http://example.com/public-dotfiles.tar.gz
+                 private git ssh://example.com/~/private-dotfiles.git
+       EOF
+}
+
+function list()
+{
+       # multi-repo case handled in main() by run_on_all_repos()
+       REPO=$(nonempty_option 'list' 'REPO' "${1}") || return 1
+       maxargs 'list' 1 "${@}" || return 1
+       if [ "${BASH_MAJOR}" -ge 4 ]; then
+               get_repo_source "${REPO}" || return 1
+               TRANSFER=$(nonempty_option 'list' 'TRANSFER' "${REPO_SOURCE_DATA['transfer']}") || return 1
+               URL=$(nonempty_option 'list' 'URL' "${REPO_SOURCE_DATA['url']}") || return 1
+       else
+               echo "WARNING: Bash version < 4.0, cannot use assuming all repos use git transfer" >&2
+               TRANSFER='git'
+               URL=$(git --git-dir "${REPO}/.git/" config remote.origin.url)
+       fi
+       echo "${REPO} ${TRANSFER} ${URL}"
+}
+
 ###
 # fetch command
 
@@ -301,10 +403,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
-       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
-               "${GIT}" --git-dir "${REPO}/.git" pull || return 1
+               git_fetch "${REPO}" || return 1
        elif [ "${TRANSFER}" = 'wget' ]; then
                wget_fetch "${REPO}" || return 1
        else
@@ -314,7 +421,7 @@ function fetch()
 }
 
 ###
-# fetch command
+# diff command
 
 COMMANDS+=('diff')
 
@@ -348,17 +455,17 @@ function diff()
 {
        MODE='standard'
        while [ "${1::2}" = '--' ]; do
-       case "${1}" in
-               '--removed')
-                       MODE='removed'
-                       ;;
-               '--local-patch')
-                       MODE='local-patch'
-                       ;;
-               *)
-                       echo "ERROR: invalid option to diff (${1})" >&2
-                       return 1
-       esac
+               case "${1}" in
+                       '--removed')
+                               MODE='removed'
+                               ;;
+                       '--local-patch')
+                               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()
@@ -378,7 +485,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
-               diff "${REPO}" --removed
+               diff --removed "${REPO}"
                exec 1<&3     # restore old stdout
                exec 3<&-     # close temporary fd 3
                return
@@ -389,10 +496,8 @@ function diff()
                        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")
@@ -432,7 +537,7 @@ function patch()
                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
@@ -440,6 +545,7 @@ function patch()
        # 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
@@ -465,41 +571,59 @@ function link_help()
 
        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] [--relative] [REPO]
 
                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.
+
+               If you have coreutils 8.16 (2012-03-26) or greater, you can set
+               the --relative option to create symlinks that use relative paths.
        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
-       BACKUP='yes'
+       BACKUP_OPT='yes'
+       LINK_OPTS='-sv'
        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'
+                               ;;
+                       '--relative')
+                               LINK_OPTS="${LINK_OPTS} --relative"
+                               ;;
+                       *)
+                               echo "ERROR: invalid option to link (${1})" >&2
+                               return 1
+               esac
                shift
        done
        # multi-repo case handled in main() by run_on_all_repos()
@@ -508,17 +632,27 @@ function link()
        DOTFILES_SRC="${DOTFILES_DIR}/${REPO}/patched-src"
 
        while read FILE; do
+               BACKUP="${BACKUP_OPT}"
                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
+                       elif [ ! -h "${TARGET}/${FILE}" ]; then
+                               # target file/dir underneath an already symlinked dir
+                               continue
+                       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
@@ -554,7 +688,7 @@ function disconnect_help()
 function disconnect()
 {
        # multi-repo case handled in main() by run_on_all_repos()
-       REPO=$(nonempty_option 'link' 'REPO' "${1}") || return 1
+       REPO=$(nonempty_option 'disconnect' 'REPO' "${1}") || return 1
        maxargs 'disconnect' 1 "${@}" || return 1
        DOTFILES_SRC="${DOTFILES_DIR}/${REPO}/patched-src"
 
@@ -569,7 +703,7 @@ function disconnect()
        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
@@ -619,7 +753,7 @@ function update_help()
 
        cat <<-EOF
 
-               usage: $0 ${COMMAND} [REPO]
+               usage: $0 ${COMMAND} [options] [REPO]
 
                Where 'REPO' is the name the dotfiles repository to update.
                If it is not given, all repositories will be updateed.
@@ -628,13 +762,21 @@ function update_help()
                to bring them in sync with the central repositories.  Keeps track
                of the last update time to avoid multiple fetches in the same
                week.
+
+               ${COMMAND} passes any options it receives through to the link
+               command.
        EOF
 }
 
 function update()
 {
+       LINK_OPTS=''
+       while [ "${1::2}" = '--' ]; do
+               LINK_OPTS="${LINK_FN_OPTS} ${1}"
+               shift
+       done
        # multi-repo case handled in main() by run_on_all_repos()
-       REPO=$(nonempty_option 'link' 'REPO' "${1}") || return 1
+       REPO=$(nonempty_option 'update' 'REPO' "${1}") || return 1
        maxargs 'disconnect' 1 "${@}" || return 1
 
        # Update once a week from our remote repository.  Mark updates by
@@ -647,7 +789,7 @@ function update()
                "${TOUCH}" "${UPDATE_FILE}" || return 1
                fetch "${REPO}" || return 1
                patch "${REPO}" || return 1
-               link "${REPO}" || return 1
+               link ${LINK_OPTS} "${REPO}" || return 1
                echo "${REPO} dotfiles updated"
        fi
 }
@@ -689,27 +831,27 @@ function main()
 {
        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
@@ -728,10 +870,10 @@ function main()
                        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
+                       "${COMMAND}" "${OPTIONS[@]}" "${1}" || return 1
                fi
        fi
 }