diff --git a/public/checksum.txt b/public/checksum.txt index 2735178..43774b1 100644 --- a/public/checksum.txt +++ b/public/checksum.txt @@ -1,4 +1,4 @@ 6de66efba021ebcf462e7672577d9f6876f13db55691f04e7009ff7e78d658b8 public/sh/install_zpmod.sh -6e753a0314ba9f2e4f0a2c429798ca3767af4fb94c669e6923d161d6af7aa745 public/sh/install.sh +eff43da44827d4774eed21d31d841df04b9cd8fe089c1b59d38bef3608920754 public/sh/install.sh 08cc893ceb982fc99d17db1966c6c30790cc571e16e4f5392352d995f5252952 public/sh/sync-init.sh c979e39748d1d86ace17a61ff2b1bf6e1224a43291c25bf7fad985d1e9e11af1 public/zsh/init.zsh diff --git a/public/sh/install.sh b/public/sh/install.sh index 6edb7d9..9d740da 100755 --- a/public/sh/install.sh +++ b/public/sh/install.sh @@ -40,11 +40,11 @@ shift $((OPTIND - 1)) # | is the sed delimiter used in the substitution; \ and & are special in # sed replacement expressions. The *[\\]* pattern matches a single backslash. case "${BOPT}" in - # [\\] is a bracket expression for a literal backslash. - *'|'* | *[\\]* | *'&'* ) - printf '%s\n' "-- ERROR -- Invalid -b value: branch name must not contain '|', '\\', or '&'." >&2 - exit 1 - ;; +# [\\] is a bracket expression for a literal backslash. +*'|'* | *[\\]* | *'&'*) + printf '%s\n' "-- ERROR -- Invalid -b value: branch name must not contain '|', '\\', or '&'." >&2 + exit 1 + ;; esac SCRIPT_DIR="" @@ -104,8 +104,8 @@ fetch_to_file() { is_absolute_path() { case "${1-}" in - /*) return 0 ;; - *) return 1 ;; + /*) return 0 ;; + *) return 1 ;; esac } @@ -183,6 +183,13 @@ if ! command -v git >/dev/null 2>&1; then exit 1 fi +# -b is used as a fetch refspec and inside generated Zsh: accept only a +# well-formed branch name (no ':', no leading '-', no '..'). +if ! command git check-ref-format --branch "${BOPT}" >/dev/null 2>&1; then + printf '%s\n' "-- ERROR -- Invalid -b value: '${BOPT}' is not a valid branch name." >&2 + exit 1 +fi + # Get the download-progress bar tool command mkdir -p /tmp/zi cd /tmp/zi || exit 1 @@ -204,9 +211,10 @@ if test -d "${ZI_HOME}/${ZI_BIN_DIR_NAME}/.git"; then if test -f "${ZI_HOME}/${ZI_BIN_DIR_NAME}/zi.zsh"; then # Canonical zi remote URLs (HTTPS and SSH, with and without .git suffix) case "$(command git -C "${ZI_HOME}/${ZI_BIN_DIR_NAME}" remote get-url origin 2>/dev/null || true)" in - https://github.com/z-shell/zi | https://github.com/z-shell/zi.git \ - | git@github.com:z-shell/zi | git@github.com:z-shell/zi.git) - _zi_valid=1 ;; + https://github.com/z-shell/zi | https://github.com/z-shell/zi.git | \ + git@github.com:z-shell/zi | git@github.com:z-shell/zi.git) + _zi_valid=1 + ;; esac fi if [ "${_zi_valid}" -ne 1 ]; then @@ -216,9 +224,18 @@ if test -d "${ZI_HOME}/${ZI_BIN_DIR_NAME}/.git"; then fi cd "${ZI_HOME}/${ZI_BIN_DIR_NAME}" || exit 1 printf '%s\n' "▓▒░ Updating (z-shell/zi) plugin manager at ${ZI_HOME}/${ZI_BIN_DIR_NAME}" - command git clean -d -f -f - command git reset --hard HEAD - command git pull -q origin "${BOPT}" + # Match `zi self-update`: fetch the requested branch and fast-forward only. + # Local state is never discarded; refuse loudly when it cannot be advanced. + if ! command git fetch -q origin "refs/heads/${BOPT}"; then + printf '%s\n' "-- ERROR -- failed to fetch origin ${BOPT} into ${ZI_HOME}/${ZI_BIN_DIR_NAME}" >&2 + exit 1 + fi + if ! command git merge -q --ff-only FETCH_HEAD; then + printf '%s\n' "-- ERROR -- ${ZI_HOME}/${ZI_BIN_DIR_NAME} cannot be fast-forwarded to origin/${BOPT}; local state was left untouched:" >&2 + command git status --short --branch 2>/dev/null | head -20 >&2 + printf '%s\n' "-- ERROR -- resolve the checkout state shown above so HEAD can fast-forward to origin/${BOPT} (move local commits to another branch, or drop changes you do not need), then rerun the installer." >&2 + exit 1 + fi else cd "${ZI_HOME}" || exit 1 printf '%s\n' "▓▒░ Installing (z-shell/zi) plugin manager at ${ZI_HOME}/${ZI_BIN_DIR_NAME}" @@ -238,22 +255,32 @@ fi MAIN_PROFILE() { THE_ZDOTDIR="${ZDOTDIR:-${HOME}}" - if grep -E '(zi|init|zinit)\.zsh' "${THE_ZDOTDIR}/.zshrc" >/dev/null 2>&1; then - printf '%s\n' "▓▒░ Seems that .zshrc already has content or setup skipped - no changes will be made." - ZOPT='skip' + ZSHRC_INTEGRATED=0 + # Detect an existing Zi integration by a real source line. A comment that + # merely mentions the file name must not suppress the integration. + if grep -E '^[[:space:]]*(source|\.)[[:space:]]+[^#]*(zi|init|zinit)\.zsh(["'"'"'[:space:]]|$)' "${THE_ZDOTDIR}/.zshrc" >/dev/null 2>&1; then + printf '%s\n' "▓▒░ Seems that .zshrc already sources Zi - the integration block will not be added." + ZSHRC_INTEGRATED=1 fi - if [ "${ZOPT}" != skip ] && [ "${AOPT}" != loader ]; then + # The .zshrc text refers to the home through $HOME; the installer itself + # keeps using the real path. + # shellcheck disable=SC2016 + case "${ZI_HOME}" in + "${HOME}") ZI_HOME_TEXT='$HOME' ;; + "${HOME}"/*) ZI_HOME_TEXT="\$HOME${ZI_HOME#"${HOME}"}" ;; + *) ZI_HOME_TEXT="${ZI_HOME}" ;; + esac + if [ "${ZOPT}" != skip ] && [ "${ZSHRC_INTEGRATED}" -eq 0 ] && [ "${AOPT}" != loader ]; then printf '%s\n' "▓▒░ Updating ${THE_ZDOTDIR}/.zshrc" - ZI_HOME="$(echo "${ZI_HOME}" | sed "s|${HOME}|\$HOME|")" command cat <<-EOF >>"${THE_ZDOTDIR}/.zshrc" -if [[ ! -f ${ZI_HOME}/${ZI_BIN_DIR_NAME}/zi.zsh ]]; then +if [[ ! -f ${ZI_HOME_TEXT}/${ZI_BIN_DIR_NAME}/zi.zsh ]]; then print -P "%F{33}▓▒░ %F{160}Installing (%F{33}z-shell/zi%F{160})…%f" - command mkdir -p "${ZI_HOME}" && command chmod go-rwX "${ZI_HOME}" - command git clone -q --filter=blob:none --single-branch --branch "${BOPT}" https://github.com/z-shell/zi "${ZI_HOME}/${ZI_BIN_DIR_NAME}" && \\ + command mkdir -p "${ZI_HOME_TEXT}" && command chmod go-rwX "${ZI_HOME_TEXT}" + command git clone -q --filter=blob:none --single-branch --branch "${BOPT}" https://github.com/z-shell/zi "${ZI_HOME_TEXT}/${ZI_BIN_DIR_NAME}" && \\ print -P "%F{33}▓▒░ %F{34}Installation successful.%f%b" || \\ print -P "%F{160}▓▒░ The clone has failed.%f%b" fi -source "${ZI_HOME}/${ZI_BIN_DIR_NAME}/zi.zsh" +source "${ZI_HOME_TEXT}/${ZI_BIN_DIR_NAME}/zi.zsh" autoload -Uz _zi (( \${+_comps} )) && _comps[zi]=_zi # examples here -> https://wiki.zshell.dev/ecosystem/category/-annexes @@ -261,7 +288,7 @@ zicompinit # <- https://wiki.zshell.dev/docs/guides/commands EOF printf '%s\n' "▓▒░ Minimal configuration" fi - if [ "${AOPT}" = loader ] && [ "${ZOPT}" != skip ]; then + if [ "${AOPT}" = loader ] && [ "${ZOPT}" != skip ] && [ "${ZSHRC_INTEGRATED}" -eq 0 ]; then command cat <<-EOF >>"${THE_ZDOTDIR}/.zshrc" if [[ -n \${XDG_CONFIG_HOME:-} && \${XDG_CONFIG_HOME} == /* ]]; then ZI_LOADER_CONFIG_HOME="\${XDG_CONFIG_HOME}/zi" @@ -278,9 +305,22 @@ EOF } ANNEX_PROFILE() { + if [ "${AOPT}" != annex ] && [ "${AOPT}" != zunit ]; then + printf '%s\n' "▓▒░ Skipped all annexes" + return 0 + fi + if [ "${ZOPT}" = skip ]; then + printf '%s\n' "▓▒░ .zshrc changes were skipped (-i skip); annexes were not configured" + return 0 + fi + # Rerunning the installer must not append the block a second time. + if grep -E '^[^#]*z-shell/z-a-meta-plugins([[:space:]]|$)' "${THE_ZDOTDIR}/.zshrc" >/dev/null 2>&1; then + printf '%s\n' "▓▒░ .zshrc already loads z-shell/z-a-meta-plugins - annex block not added again" + return 0 + fi + file="${WORKDIR}/temp-zsh-config" if [ "${AOPT}" = annex ]; then - file="${WORKDIR}/temp-zsh-config" - command cat <<-EOF >>"${file}" + command cat <<-EOF >"${file}" zi light-mode for \\ z-shell/z-a-meta-plugins \\ @annexes # <- https://wiki.zshell.dev/ecosystem/category/-annexes @@ -288,21 +328,29 @@ zi light-mode for \\ zicompinit # <- https://wiki.zshell.dev/docs/guides/commands EOF printf '%s\n' "▓▒░ Installing annexes" - command cat "${file}" >>"${THE_ZDOTDIR}/.zshrc" - zsh -ic "@zi-scheduler burst" - elif [ "${AOPT}" = zunit ]; then - file2="${WORKDIR}/temp-zunit-config" - command cat <<-EOF >>"${file2}" + else + command cat <<-EOF >"${file}" zi light-mode for \\ z-shell/z-a-meta-plugins \\ @annexes @zunit EOF printf '%s\n' "▓▒░ Installing annexes + zunit" - command cat "${file2}" >>"${THE_ZDOTDIR}/.zshrc" - zsh -ic "@zi-scheduler burst" - else - printf '%s\n' "▓▒░ Skipped all annexes" fi + command cat "${file}" >>"${THE_ZDOTDIR}/.zshrc" + ANNEX_BURST "${file}" +} + +ANNEX_BURST() { + # Install the annexes now without an interactive shell and with -f, so no + # user startup file (not even .zshenv) runs: source only zi.zsh and the + # fragment just written. A failure here must not abort an install whose + # .zshrc changes are already in place. + if zsh -f -c 'builtin source "$1" && builtin source "$2" && @zi-scheduler burst' zsh \ + "${ZI_HOME}/${ZI_BIN_DIR_NAME}/zi.zsh" "$1"; then + return 0 + fi + printf '%s\n' "▓▒░ Annexes could not be installed now; they will be installed on the next shell start." >&2 + return 0 } ZPMOD_PROFILE() { diff --git a/tests/installers.sh b/tests/installers.sh index e8955d2..b1ee218 100755 --- a/tests/installers.sh +++ b/tests/installers.sh @@ -400,6 +400,7 @@ if [ "${1:-}" = "-C" ]; then fi cmd="${1:-}" +[ -z "${ZI_SRC_TEST_GIT_LOG:-}" ] || printf '%s\n' "$*" >>"${ZI_SRC_TEST_GIT_LOG}" [ "$#" -gt 0 ] && shift case "${cmd}" in @@ -413,7 +414,24 @@ case "${cmd}" in printf '%s\n' '# fake zi.zsh' > "${dest}/zi.zsh" printf '%s\n' '# fake _zi completion' > "${dest}/lib/_zi" ;; - clean | reset | pull) + check-ref-format) + [ "${1:-}" = "--branch" ] || { printf '%s\n' "installers.sh git test double: expected --branch" >&2; exit 65; } + case "${2:-}" in + "" | -* | *:* | *..* | *[[:space:]]* | *~* | *^* | *\\* ) exit 1 ;; + esac + printf '%s\n' "$2" + ;; + fetch) + ;; + merge) + if [ "${ZI_SRC_TEST_FAKE_FF_FAIL:-0}" -ne 0 ]; then + printf '%s\n' "fatal: Not possible to fast-forward, aborting." >&2 + exit 128 + fi + ;; + status) + printf '%s\n' "## main...origin/main [ahead 1]" + printf '%s\n' " M zi.zsh" ;; log) printf '%s\n' 'abcdef0 - fake zi commit (now) ' @@ -438,7 +456,25 @@ case "${cmd}" in esac EOF - command chmod a+x "${FAKE_BIN}/curl" "${FAKE_BIN}/git" + cat >"${FAKE_BIN}/zsh" <<'EOF' +#!/usr/bin/env sh +set -eu + +# The installer must never start an interactive shell: that would execute the +# user's own startup files with the installer's environment. +for arg; do + case "${arg}" in + -i | -i?* | -?*i?*) + printf '%s\n' "zsh test double: interactive flag ${arg}" >&2 + exit 66 + ;; + esac + [ "${arg}" != "-c" ] || break +done +[ -z "${ZI_SRC_TEST_ZSH_LOG:-}" ] || printf '%s\n' "zsh $*" >>"${ZI_SRC_TEST_ZSH_LOG}" +EOF + + command chmod a+x "${FAKE_BIN}/curl" "${FAKE_BIN}/git" "${FAKE_BIN}/zsh" } test_loader_install() { @@ -639,6 +675,194 @@ test_update_rejects_wrong_remote() { pass "update path rejects a repository with a non-zi remote origin" } +test_update_fast_forwards_without_reset() { + home="${TMP_ROOT}/update-ff-home" + data="${TMP_ROOT}/update-ff-data" + zi_bin="${data}/zi/bin" + git_log="${TMP_ROOT}/update-ff-git-log" + command mkdir -p "${home}" "${zi_bin}/.git" + printf '%s\n' '# fake zi.zsh' >"${zi_bin}/zi.zsh" + + HOME="${home}" \ + ZDOTDIR="${home}" \ + XDG_DATA_HOME="${data}" \ + ZI_SRC_TEST_ROOT="${ROOT}" \ + ZI_SRC_TEST_GIT_LOG="${git_log}" \ + PATH="${FAKE_BIN}:${PATH}" \ + sh "${ROOT}/public/sh/install.sh" -i skip -b feature/test >/dev/null + + contains "${git_log}" 'fetch -q origin refs/heads/feature/test' + contains "${git_log}" 'merge -q --ff-only FETCH_HEAD' + if grep -E '^(clean|reset|pull)( |$)' "${git_log}" >/dev/null 2>&1; then + fail "update path still discards local state (clean, reset, or pull was invoked)" + fi + pass "update path fetches and fast-forwards without discarding local state" +} + +test_update_refuses_non_fast_forward() { + home="${TMP_ROOT}/update-nonff-home" + data="${TMP_ROOT}/update-nonff-data" + zi_bin="${data}/zi/bin" + err="${TMP_ROOT}/update-nonff-err" + command mkdir -p "${home}" "${zi_bin}/.git" + printf '%s\n' '# fake zi.zsh' >"${zi_bin}/zi.zsh" + + set +e + HOME="${home}" \ + ZDOTDIR="${home}" \ + XDG_DATA_HOME="${data}" \ + ZI_SRC_TEST_ROOT="${ROOT}" \ + ZI_SRC_TEST_FAKE_FF_FAIL=1 \ + PATH="${FAKE_BIN}:${PATH}" \ + sh "${ROOT}/public/sh/install.sh" -i skip >/dev/null 2>"${err}" + exit_code="$?" + set -e + + [ "${exit_code}" -ne 0 ] || fail "install.sh should have refused a non-fast-forward update" + contains "${err}" 'cannot be fast-forwarded' + contains "${err}" 'local state was left untouched' + contains "${err}" ' M zi.zsh' + pass "update path refuses a non-fast-forward and shows the checkout state" +} + +test_zshrc_comment_does_not_suppress_integration() { + home="${TMP_ROOT}/probe-home" + data="${TMP_ROOT}/probe-data" + command mkdir -p "${home}" + printf '%s\n' '# Zi is loaded from ~/.config/zi/init.zsh, see the wiki' >"${home}/.zshrc" + + HOME="${home}" \ + ZDOTDIR="${home}" \ + XDG_DATA_HOME="${data}" \ + ZI_SRC_TEST_ROOT="${ROOT}" \ + PATH="${FAKE_BIN}:${PATH}" \ + sh "${ROOT}/public/sh/install.sh" >/dev/null + + contains "${home}/.zshrc" "source \"${data}/zi/bin/zi.zsh\"" + pass "a comment mentioning init.zsh does not suppress the .zshrc integration" + + # A real source line is still detected and nothing is appended. + home2="${TMP_ROOT}/probe-home-sourced" + data2="${TMP_ROOT}/probe-data-sourced" + command mkdir -p "${home2}" + # shellcheck disable=SC2016 + printf '%s\n' 'source "$HOME/.zi/bin/zi.zsh"' >"${home2}/.zshrc" + before="$(sha256_file "${home2}/.zshrc")" + + HOME="${home2}" \ + ZDOTDIR="${home2}" \ + XDG_DATA_HOME="${data2}" \ + ZI_SRC_TEST_ROOT="${ROOT}" \ + PATH="${FAKE_BIN}:${PATH}" \ + sh "${ROOT}/public/sh/install.sh" >/dev/null + + [ "$(sha256_file "${home2}/.zshrc")" = "${before}" ] || fail "an existing source line did not suppress the integration block" + pass "an existing Zi source line keeps .zshrc unchanged" +} + +test_annex_rerun_is_idempotent() { + home="${TMP_ROOT}/annex-home" + data="${TMP_ROOT}/annex-data" + zsh_log="${TMP_ROOT}/annex-zsh-log" + command mkdir -p "${home}" + + for run in first second; do + HOME="${home}" \ + ZDOTDIR="${home}" \ + XDG_DATA_HOME="${data}" \ + ZI_SRC_TEST_ROOT="${ROOT}" \ + ZI_SRC_TEST_ZSH_LOG="${zsh_log}" \ + PATH="${FAKE_BIN}:${PATH}" \ + sh "${ROOT}/public/sh/install.sh" -a annex >/dev/null || fail "annex install (${run} run) failed" + done + + meta_lines="$(grep -c 'z-shell/z-a-meta-plugins' "${home}/.zshrc")" + [ "${meta_lines}" -eq 1 ] || fail "annex block appended ${meta_lines} times across two runs" + contains "${home}/.zshrc" "source \"${data}/zi/bin/zi.zsh\"" + # The burst ran once, non-interactively, sourcing only zi.zsh and the fragment. + burst_lines="$(wc -l <"${zsh_log}" | tr -d ' ')" + [ "${burst_lines}" -eq 1 ] || fail "expected one annex burst, saw ${burst_lines}" + contains "${zsh_log}" '@zi-scheduler burst' + # Logged with argv0 so the pattern cannot be read as grep options. + contains "${zsh_log}" 'zsh -f -c ' + contains "${zsh_log}" "${data}/zi/bin/zi.zsh" + contains "${zsh_log}" 'temp-zsh-config' + pass "annex profile is idempotent across reruns and never starts an interactive shell" +} + +test_skip_leaves_annex_out() { + home="${TMP_ROOT}/annex-skip-home" + data="${TMP_ROOT}/annex-skip-data" + zsh_log="${TMP_ROOT}/annex-skip-zsh-log" + command mkdir -p "${home}" + + HOME="${home}" \ + ZDOTDIR="${home}" \ + XDG_DATA_HOME="${data}" \ + ZI_SRC_TEST_ROOT="${ROOT}" \ + ZI_SRC_TEST_ZSH_LOG="${zsh_log}" \ + PATH="${FAKE_BIN}:${PATH}" \ + sh "${ROOT}/public/sh/install.sh" -i skip -a annex >/dev/null + + [ ! -e "${home}/.zshrc" ] || fail "-i skip -a annex modified .zshrc" + [ ! -e "${zsh_log}" ] || fail "-i skip -a annex still ran the annex burst" + pass "-i skip leaves .zshrc untouched even with an annex profile" +} + +test_branch_option_rejects_refspec() { + home="${TMP_ROOT}/branch-refspec-home" + data="${TMP_ROOT}/branch-refspec-data" + err="${TMP_ROOT}/branch-refspec-err" + command mkdir -p "${home}" + + set +e + HOME="${home}" \ + ZDOTDIR="${home}" \ + XDG_DATA_HOME="${data}" \ + ZI_SRC_TEST_ROOT="${ROOT}" \ + PATH="${FAKE_BIN}:${PATH}" \ + sh "${ROOT}/public/sh/install.sh" -i skip -b 'main:refs/heads/other' >/dev/null 2>"${err}" + exit_code="$?" + set -e + + [ "${exit_code}" -ne 0 ] || fail "install.sh accepted a refspec as -b" + contains "${err}" 'not a valid branch name' + [ ! -e "${data}/zi/bin/zi.zsh" ] || fail "install proceeded after an invalid -b value" + pass "-b rejects values that are not a branch name" +} + +test_zshrc_text_uses_home_variable() { + home="${TMP_ROOT}/home-text home" + sibling="${TMP_ROOT}/home-text home-sibling" + command mkdir -p "${home}" + + HOME="${home}" \ + ZDOTDIR="${home}" \ + XDG_DATA_HOME="${home}/xdg data" \ + ZI_SRC_TEST_ROOT="${ROOT}" \ + PATH="${FAKE_BIN}:${PATH}" \ + sh "${ROOT}/public/sh/install.sh" >/dev/null + + # shellcheck disable=SC2016 + contains "${home}/.zshrc" 'source "$HOME/xdg data/zi/bin/zi.zsh"' + pass '.zshrc refers to a home under $HOME through the variable' + + home2="${TMP_ROOT}/home-text-2" + command mkdir -p "${home2}" + HOME="${home2}" \ + ZDOTDIR="${home2}" \ + ZI_HOME="${sibling}" \ + ZI_SRC_TEST_ROOT="${ROOT}" \ + PATH="${FAKE_BIN}:${PATH}" \ + sh "${ROOT}/public/sh/install.sh" >/dev/null + + contains "${home2}/.zshrc" "source \"${sibling}/bin/zi.zsh\"" + if grep -F '$HOME' "${home2}/.zshrc" >/dev/null 2>&1; then + fail 'a sibling of $HOME was rewritten as if it were inside it' + fi + pass '.zshrc keeps a literal path for a home that only shares a prefix with $HOME' +} + test_sync_init() { local_file="${TMP_ROOT}/local-init.zsh" remote_file="${TMP_ROOT}/remote-init.zsh" @@ -693,4 +917,11 @@ test_standalone_zpmod_delegation test_update_valid_zi_clone test_update_rejects_foreign_repo test_update_rejects_wrong_remote +test_update_fast_forwards_without_reset +test_update_refuses_non_fast_forward +test_zshrc_comment_does_not_suppress_integration +test_annex_rerun_is_idempotent +test_skip_leaves_annex_out +test_branch_option_rejects_refspec +test_zshrc_text_uses_home_variable test_sync_init