diff --git a/README.md b/README.md index bf00873..2da264a 100644 --- a/README.md +++ b/README.md @@ -19,10 +19,13 @@ updates are installed only when explicitly selected or requested with The required iDSSYS Defaults repository is handled separately: it is automatically refreshed before launch when its last successful check is more than four hours old. If the remote is unavailable, the installed copy is used. +Updates are fast-forward-only. Local file changes, local-only commits, and +diverged histories are preserved and reported instead of being overwritten. The Utilities menu can safely switch TA-ProxMenu between branches published on its Git origin. Branch switching is refused when the installed repository has -local changes. +local changes or when the destination branch has commits that would be +discarded. ## Direct actions diff --git a/defaults.inc b/defaults.inc index 5b8ec01..ba32347 100755 --- a/defaults.inc +++ b/defaults.inc @@ -3,7 +3,7 @@ action="${1:-}" FOLDER='/opt/idssys/ta-proxmenu' -VERS='2026.7.25-29' +VERS='2026.7.25-30' noupdate=' ' diff --git a/inc/git-update.inc b/inc/git-update.inc new file mode 100644 index 0000000..b8dc921 --- /dev/null +++ b/inc/git-update.inc @@ -0,0 +1,77 @@ +#!/usr/bin/env bash +# Shared non-destructive Git update helpers for TA-ProxMenu. + +TAPM_GIT_WORKTREE_DIRTY() { + local repository="$1" + + [[ -n "$(git -C "$repository" status --porcelain --untracked-files=normal 2>/dev/null)" ]] +} + +TAPM_GIT_FETCH_BRANCH() { + local repository="$1" + local branch="$2" + local timeout_seconds="${3:-30}" + + timeout "$timeout_seconds" git -C "$repository" fetch --prune origin \ + "+refs/heads/${branch}:refs/remotes/origin/${branch}" +} + +TAPM_GIT_RELATION() { + local repository="$1" + local local_ref="$2" + local remote_ref="$3" + local local_commit + local remote_commit + + local_commit="$(git -C "$repository" rev-parse --verify "${local_ref}^{commit}" 2>/dev/null)" || + return 1 + remote_commit="$(git -C "$repository" rev-parse --verify "${remote_ref}^{commit}" 2>/dev/null)" || + return 1 + + if [[ "$local_commit" == "$remote_commit" ]]; then + printf 'current\n' + elif git -C "$repository" merge-base --is-ancestor "$local_commit" "$remote_commit"; then + printf 'behind\n' + elif git -C "$repository" merge-base --is-ancestor "$remote_commit" "$local_commit"; then + printf 'ahead\n' + else + printf 'diverged\n' + fi +} + +TAPM_GIT_BRANCH_STATE() { + local repository="$1" + local branch="$2" + local current_branch + + [[ -d "${repository}/.git" ]] || { + printf 'unavailable\n' + return 1 + } + + current_branch="$(git -C "$repository" branch --show-current 2>/dev/null)" + if [[ -z "$current_branch" ]]; then + printf 'detached\n' + return 0 + fi + if [[ "$current_branch" != "$branch" ]]; then + printf 'wrong-branch\n' + return 0 + fi + if TAPM_GIT_WORKTREE_DIRTY "$repository"; then + printf 'dirty\n' + return 0 + fi + + TAPM_GIT_RELATION "$repository" HEAD "refs/remotes/origin/${branch}" || { + printf 'unavailable\n' + return 1 + } +} + +TAPM_GIT_FAST_FORWARD() { + local repository="$1" + local branch="$2" + + git -C "$repository" merge --ff-only "refs/remotes/origin/${branch}" +} diff --git a/proxmenu-scripts.sh b/proxmenu-scripts.sh index 7ec57bb..3739d7f 100755 --- a/proxmenu-scripts.sh +++ b/proxmenu-scripts.sh @@ -5,6 +5,7 @@ [ "${2:-}" != "q" ] && source /opt/idssys/defaults/colors.inc source /opt/idssys/defaults/default.inc source /opt/idssys/ta-proxmenu/defaults.inc +source /opt/idssys/ta-proxmenu/inc/git-update.inc ACTION_REQUESTED=0 [[ -n "${action:-}" ]] && ACTION_REQUESTED=1 @@ -744,16 +745,18 @@ UPDATE_CHECK_WORKER() { WRITE_UPDATE_CACHE "unavailable" "$branch" "$local_commit" "" return fi - - remote_commit="$(git -C "$FOLDER" ls-remote origin "refs/heads/${branch}" 2>/dev/null | cut -f1)" - if [[ -z "$remote_commit" ]]; then - status="unavailable" - elif [[ "$local_commit" == "$remote_commit" ]]; then - status="current" - else - status="available" + if TAPM_GIT_WORKTREE_DIRTY "$FOLDER"; then + WRITE_UPDATE_CACHE "dirty" "$branch" "$local_commit" "" + return fi + if ! TAPM_GIT_FETCH_BRANCH "$FOLDER" "$branch" 30 >/dev/null 2>&1; then + WRITE_UPDATE_CACHE "unavailable" "$branch" "$local_commit" "" + return + fi + remote_commit="$(git -C "$FOLDER" rev-parse "refs/remotes/origin/${branch}" 2>/dev/null)" + status="$(TAPM_GIT_BRANCH_STATE "$FOLDER" "$branch")" + WRITE_UPDATE_CACHE "$status" "$branch" "$local_commit" "$remote_commit" } @@ -774,12 +777,14 @@ LOAD_UPDATE_STATUS() { local cached_status local current_branch local current_commit + local current_dirty=0 local now UPDATE_STATUS='unknown' UPDATE_REMOTE_COMMIT='' current_branch="$(git -C "$FOLDER" branch --show-current 2>/dev/null)" current_commit="$(git -C "$FOLDER" rev-parse HEAD 2>/dev/null)" + TAPM_GIT_WORKTREE_DIRTY "$FOLDER" && current_dirty=1 now="$(date +%s)" if [[ -r "$UPDATE_CACHE_FILE" ]]; then @@ -789,7 +794,9 @@ LOAD_UPDATE_STATUS() { (( now - checked_at < UPDATE_CACHE_SECONDS )) && [[ "$cached_branch" == "$current_branch" ]] && [[ "$cached_local" == "$current_commit" ]] && - [[ "$cached_status" =~ ^(current|available|unavailable)$ ]]; then + [[ "$cached_status" =~ ^(current|behind|ahead|diverged|dirty|unavailable)$ ]] && + { [[ "$cached_status" == "dirty" && "$current_dirty" -eq 1 ]] || + [[ "$cached_status" != "dirty" && "$current_dirty" -eq 0 ]]; }; then UPDATE_STATUS="$cached_status" UPDATE_REMOTE_COMMIT="$cached_remote" return @@ -818,12 +825,21 @@ FORCE_UPDATE_CHECK() { echo -e "${idsCL[Default]}" case "$UPDATE_STATUS" in - available) + behind) echo -e "${idsCL[LightYellow]}An update is available for the current branch.${idsCL[Default]}" ;; current) echo -e "${idsCL[Green]}TA-ProxMenu is current.${idsCL[Default]}" ;; + ahead) + echo -e "${idsCL[LightYellow]}The current branch has local commits not on origin.${idsCL[Default]}" + ;; + diverged) + echo -e "${idsCL[LightRed]}The current branch has diverged from origin; automatic update is disabled.${idsCL[Default]}" + ;; + dirty) + echo -e "${idsCL[LightYellow]}The TA-ProxMenu repository has local file changes.${idsCL[Default]}" + ;; *) echo -e "${idsCL[Red]}The update status could not be determined.${idsCL[Default]}" ;; @@ -839,9 +855,18 @@ MENU_HEADER() { LOAD_UPDATE_STATUS case "$UPDATE_STATUS" in - available) + behind) version_display="${idsCL[LightYellow]}${VERS} ** UPDATE AVAILABLE **${idsCL[Default]}" ;; + ahead) + version_display="${idsCL[LightYellow]}${VERS} ** LOCAL COMMITS **${idsCL[Default]}" + ;; + diverged) + version_display="${idsCL[LightRed]}${VERS} ** BRANCH DIVERGED **${idsCL[Default]}" + ;; + dirty) + version_display="${idsCL[LightYellow]}${VERS} ** LOCAL CHANGES **${idsCL[Default]}" + ;; checking) version_display="${idsCL[LightCyan]}${VERS} (checking for updates)${idsCL[Default]}" ;; @@ -1114,6 +1139,7 @@ SWITCH_SCRIPT_BRANCH() { local target_branch="$1" local current_branch local choice + local target_state='' current_branch="$(git -C "$FOLDER" branch --show-current 2>/dev/null)" if [[ "$target_branch" == "$current_branch" ]]; then @@ -1136,14 +1162,36 @@ SWITCH_SCRIPT_BRANCH() { echo [[ "$choice" =~ ^[Yy]$ ]] || return - if ! timeout 30 git -C "$FOLDER" fetch origin \ - "refs/heads/${target_branch}:refs/remotes/origin/${target_branch}"; then + if ! TAPM_GIT_FETCH_BRANCH "$FOLDER" "$target_branch" 30; then echo -e "${idsCL[Red]}Failed to fetch '${target_branch}' from origin.${idsCL[Default]}" ENTER2CONTINUE return fi if git -C "$FOLDER" show-ref --verify --quiet "refs/heads/${target_branch}"; then + target_state="$( + TAPM_GIT_RELATION "$FOLDER" "refs/heads/${target_branch}" \ + "refs/remotes/origin/${target_branch}" + )" || { + echo -e "${idsCL[Red]}Could not compare local and remote '${target_branch}'.${idsCL[Default]}" + ENTER2CONTINUE + return + } + case "$target_state" in + ahead) + echo -e "${idsCL[LightYellow]}The local '${target_branch}' branch has commits not on origin.${idsCL[Default]}" + echo "Branch switching was refused to preserve those commits." + ENTER2CONTINUE + return + ;; + diverged) + echo -e "${idsCL[LightRed]}The local '${target_branch}' branch has diverged from origin.${idsCL[Default]}" + echo "Branch switching was refused; manual Git review is required." + ENTER2CONTINUE + return + ;; + esac + git -C "$FOLDER" switch "$target_branch" || { ENTER2CONTINUE return @@ -1156,10 +1204,12 @@ SWITCH_SCRIPT_BRANCH() { } fi - if ! git -C "$FOLDER" reset --hard "origin/${target_branch}"; then - echo -e "${idsCL[Red]}Failed to synchronize '${target_branch}' with origin.${idsCL[Default]}" - ENTER2CONTINUE - return + if [[ "$target_state" == "behind" ]]; then + if ! TAPM_GIT_FAST_FORWARD "$FOLDER" "$target_branch"; then + echo -e "${idsCL[Red]}Failed to fast-forward '${target_branch}'; no commits were discarded.${idsCL[Default]}" + ENTER2CONTINUE + return + fi fi rm -f "$UPDATE_CACHE_FILE" @@ -1233,12 +1283,21 @@ UTILITIES_MENU() { while true; do LOAD_UPDATE_STATUS case "$UPDATE_STATUS" in - available) + behind) labels=("Install available update") ;; current) labels=("TA-ProxMenu is current") ;; + ahead) + labels=("Local branch is ahead of origin") + ;; + diverged) + labels=("Local branch has diverged from origin") + ;; + dirty) + labels=("Local repository has uncommitted changes") + ;; checking) labels=("Update check in progress") ;; @@ -1255,7 +1314,7 @@ UTILITIES_MENU() { SELECT_MENU "Utilities" labels values case "$MENU_SELECTION" in install_update) - if [[ "$UPDATE_STATUS" != "available" ]]; then + if [[ "$UPDATE_STATUS" != "behind" ]]; then echo -e "\n${idsCL[LightCyan]}No available update is currently detected.${idsCL[Default]}" ENTER2CONTINUE continue diff --git a/run.sh b/run.sh index 357543e..ff7aaf6 100755 --- a/run.sh +++ b/run.sh @@ -6,11 +6,13 @@ DEFAULTS_CACHE_DIR='/var/cache/ta-proxmenu' DEFAULTS_CHECK_FILE="${DEFAULTS_CACHE_DIR}/defaults-last-check" DEFAULTS_CHECK_SECONDS=14400 +source /opt/idssys/ta-proxmenu/inc/git-update.inc + AUTO_UPDATE_DEFAULTS() { local checked_at=0 - local local_commit + local current_branch local now - local remote_commit + local state mkdir -p "$DEFAULTS_CACHE_DIR" 2>/dev/null || true now="$(date +%s)" @@ -41,22 +43,46 @@ AUTO_UPDATE_DEFAULTS() { return fi else - if ! timeout 20 git -C "$DEFAULTS_REPOSITORY" fetch origin master \ + current_branch="$(git -C "$DEFAULTS_REPOSITORY" branch --show-current 2>/dev/null)" + if [[ "$current_branch" != "master" ]]; then + echo "WARNING: iDSSYS Defaults is not on master; automatic update skipped." >&2 + date +%s >"$DEFAULTS_CHECK_FILE" + return + fi + if TAPM_GIT_WORKTREE_DIRTY "$DEFAULTS_REPOSITORY"; then + echo "WARNING: iDSSYS Defaults has local changes; automatic update skipped." >&2 + date +%s >"$DEFAULTS_CHECK_FILE" + return + fi + if ! TAPM_GIT_FETCH_BRANCH "$DEFAULTS_REPOSITORY" master 20 \ >/dev/null 2>&1; then echo "WARNING: Unable to check iDSSYS Defaults; using the installed copy." >&2 return fi - local_commit="$(git -C "$DEFAULTS_REPOSITORY" rev-parse HEAD)" - remote_commit="$(git -C "$DEFAULTS_REPOSITORY" rev-parse origin/master)" - if [[ "$local_commit" != "$remote_commit" ]]; then - echo "Updating required iDSSYS Defaults..." - if ! git -C "$DEFAULTS_REPOSITORY" reset --hard origin/master \ - >/dev/null 2>&1; then + state="$(TAPM_GIT_BRANCH_STATE "$DEFAULTS_REPOSITORY" master)" + case "$state" in + behind) + echo "Updating required iDSSYS Defaults..." + if ! TAPM_GIT_FAST_FORWARD "$DEFAULTS_REPOSITORY" master \ + >/dev/null 2>&1; then + echo "WARNING: Unable to update iDSSYS Defaults; using the installed copy." >&2 + return + fi + ;; + current) + ;; + ahead) + echo "WARNING: iDSSYS Defaults has local commits not on origin; automatic update skipped." >&2 + ;; + diverged) + echo "WARNING: iDSSYS Defaults has diverged from origin; automatic update skipped." >&2 + ;; + *) echo "WARNING: Unable to update iDSSYS Defaults; using the installed copy." >&2 return - fi - fi + ;; + esac fi date +%s >"$DEFAULTS_CHECK_FILE" @@ -72,36 +98,67 @@ UPDATE_REPOSITORY() { local repository="$1" local branch="$2" local label="$3" - local local_commit - local remote_commit + local current_branch + local state - cd "$repository" || return 1 - local_commit="$(git rev-parse HEAD 2>/dev/null)" || return 1 - remote_commit="$(git ls-remote origin "refs/heads/${branch}" | cut -f1)" - - if [ -z "$remote_commit" ]; then - echo -e "${idsCL[Red]}Could not find branch '${branch}' for ${label}${idsCL[Default]}" + if [[ ! -d "${repository}/.git" ]]; then + echo -e "${idsCL[Red]}${label} is not a Git repository.${idsCL[Default]}" return 1 fi - if [ "$local_commit" = "$remote_commit" ]; then - echo -e "${idsCL[Green]}${label} is current (${branch})${idsCL[Default]}" - return 0 + current_branch="$(git -C "$repository" branch --show-current 2>/dev/null)" + if [[ -z "$current_branch" ]]; then + echo -e "${idsCL[Red]}${label} is in a detached HEAD state; update skipped.${idsCL[Default]}" + return 1 + fi + if [[ "$current_branch" != "$branch" ]]; then + echo -e "${idsCL[Red]}${label} is on '${current_branch}', not '${branch}'; update skipped.${idsCL[Default]}" + return 1 + fi + if TAPM_GIT_WORKTREE_DIRTY "$repository"; then + echo -e "${idsCL[LightYellow]}${label} has local changes; update skipped to preserve them.${idsCL[Default]}" + git -C "$repository" status --short + return 1 fi - echo -en "${idsCL[LightCyan]}Updating ${label} (${branch})...${idsCL[Default]}" - if git fetch origin "$branch" >/dev/null 2>&1 && - git reset --hard "origin/${branch}" >/dev/null 2>&1; then - echo -e " ${idsCL[Green]}Done${idsCL[Default]}" - return 0 + if ! TAPM_GIT_FETCH_BRANCH "$repository" "$branch" 30 >/dev/null 2>&1; then + echo -e "${idsCL[Red]}Could not fetch branch '${branch}' for ${label}.${idsCL[Default]}" + return 1 fi - echo -e " ${idsCL[Red]}Failed${idsCL[Default]}" - return 1 + state="$(TAPM_GIT_BRANCH_STATE "$repository" "$branch")" + case "$state" in + current) + echo -e "${idsCL[Green]}${label} is current (${branch}).${idsCL[Default]}" + return 0 + ;; + behind) + echo -en "${idsCL[LightCyan]}Updating ${label} (${branch})...${idsCL[Default]}" + if TAPM_GIT_FAST_FORWARD "$repository" "$branch" >/dev/null 2>&1; then + echo -e " ${idsCL[Green]}Done${idsCL[Default]}" + return 0 + fi + echo -e " ${idsCL[Red]}Failed; local files were preserved.${idsCL[Default]}" + return 1 + ;; + ahead) + echo -e "${idsCL[LightYellow]}${label} is ahead of origin; its local commits were preserved.${idsCL[Default]}" + return 1 + ;; + diverged) + echo -e "${idsCL[LightYellow]}${label} has diverged from origin; automatic update was refused.${idsCL[Default]}" + return 1 + ;; + *) + echo -e "${idsCL[Red]}Could not determine the update state for ${label}.${idsCL[Default]}" + return 1 + ;; + esac } INSTALL_UPDATES() { local current_branch + local defaults_warning=0 local update_failed=0 echo -e "${idsCL[LightCyan]}Checking for updates...${idsCL[Default]}" @@ -112,7 +169,7 @@ INSTALL_UPDATES() { fi UPDATE_REPOSITORY /opt/idssys/defaults master "iDSSYS Defaults" || - update_failed=1 + defaults_warning=1 current_branch="$(git -C /opt/idssys/ta-proxmenu branch --show-current)" if [ -z "$current_branch" ]; then @@ -128,6 +185,9 @@ INSTALL_UPDATES() { if (( update_failed == 0 )); then source /opt/idssys/ta-proxmenu/defaults.inc echo -e "\n${idsCL[Green]}Update check complete. Installed version: ${VERS}${idsCL[Default]}" + if (( defaults_warning == 1 )); then + echo -e "${idsCL[LightYellow]}TA-ProxMenu was processed, but iDSSYS Defaults requires attention.${idsCL[Default]}" + fi return 0 fi