From 99a1c54f9f186feaaaa892cdcb6e1b247b29d176 Mon Sep 17 00:00:00 2001 From: Johnny IV Young Ospino Date: Wed, 7 Oct 2026 12:42:04 -0500 Subject: [PATCH 1/3] fix(prune): retain recovery copies when the active runtime is broken --- CHANGELOG.md | 2 ++ README.md | 2 +- docs/ARCHITECTURE.md | 2 +- gos.sh | 17 ++++++++-- tests/prune-recovery.bash | 70 +++++++++++++++++++++++++++++++++++++++ 5 files changed, 88 insertions(+), 5 deletions(-) create mode 100644 tests/prune-recovery.bash diff --git a/CHANGELOG.md b/CHANGELOG.md index a41f382..588bbf0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this ### Fixed +- Pruning crash-recovery backups requires the active Go to successfully report its local version, rather than only having executable mode. Broken active installs retain those recovery copies, including in dry-run reports. + - Windows uninstall validates ownership, removes only known installed files, and preserves unrelated contents instead of recursively deleting the target directory. Existing installations without a receipt remain supported, with their ambiguous `LICENSE` left in place. - Windows installs stage replacements on the destination filesystem and restore previous files on publication failure. Failed fresh installs remove partial payloads; failed restoration keeps recovery files and reports their location. - Windows PATH edits refresh a stale process even when the registry already contains gos, remove process entries on uninstall, and preserve registry value types and unrelated entries. Tests use registry substitutes and a disposable native Windows key. diff --git a/README.md b/README.md index 56d76d3..09ab6c1 100644 --- a/README.md +++ b/README.md @@ -719,7 +719,7 @@ the Windows launcher. Use the symptom-specific checks below for those cases. |---|---|---| | `go version` still shows the old Go after `gos install` | Another Go is earlier on `PATH` (Homebrew, a manual install) | `gos which` shows which binary wins; put `$GOS_INSTALL_DIR/bin` first, or `eval "$(gos env)"` | | `Error: another gos operation is running` | A previous gos was interrupted, or one is running | `gos status` shows the lock and its pid; remove `${GOS_INSTALL_DIR:-/usr/local/go}.gos-lock` only after verifying no gos operation is running (strip any trailing slash from the install path first) | -| `Residue:` or `Orphaned backup found` in `gos status` | An install was interrupted between renames | `gos prune --rollback` removes the residue once the active Go works | +| `Residue:` or `Orphaned backup found` in `gos status` | An install was interrupted between renames | `gos prune --rollback` removes residue only after the active binary successfully reports its local Go version; repair a broken active install first | | `Rollback: broken link` | The side-by-side version the rollback pointed at was uninstalled | `gos prune --rollback`; the next install creates a new rollback | | Password prompt on every install | `GOS_INSTALL_DIR` is root-owned (`/usr/local/go`) | Set `GOS_INSTALL_DIR` under your home directory; gos only escalates for the directory it writes | | `checksum verification required but ...` (exit 4) | `GOS_REQUIRE_CHECKSUM` is set and `jq`/`python3` or a SHA256 tool is missing | Install `jq` or `python3` plus `sha256sum`/`shasum`, then retry; check feed availability and the diagnostic before changing verification policy | diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index e177dfc..c1fe8f1 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -205,7 +205,7 @@ gos has no database; its state is the filesystem: |---|---| | `$GOS_INSTALL_DIR` | Active Go: a real directory (flat) or a symlink into the versions tree (side-by-side). | | `$GOS_INSTALL_DIR.gos-rollback` | The previous installation, target of `gos rollback`. A dangling link here means its version was uninstalled (`gos prune --rollback` clears it). | -| `$GOS_INSTALL_DIR.gos-backup.`, `.gos-current.` | Transient slots of an activation in progress; crash residue if left behind (`gos status`/`doctor` report it, `gos prune --rollback` removes it). | +| `$GOS_INSTALL_DIR.gos-backup.`, `.gos-current.` | Transient slots of an activation in progress; crash residue if left behind (`gos status`/`doctor` report it; `gos prune --rollback` removes it only after the active binary successfully reports a recognizable local Go version). | | `$GOS_INSTALL_DIR.gos-lock/pid` | The mutation lock. | | `.gos-lock/pid` | The path-scoped self-update lock. | | `$GOS_VERSIONS_DIR/go/` | Installed versions in side-by-side mode. | diff --git a/gos.sh b/gos.sh index b182f9c..648d754 100755 --- a/gos.sh +++ b/gos.sh @@ -4431,17 +4431,28 @@ cmd_prune() { # *.gos-current. siblings. Only remove them when the active install is # healthy (they may be the sole surviving copy otherwise), and only with # --rollback, which already means "discard my safety copies". - local orphan orphans_removed=0 orphans_found=0 + local orphan orphans_removed=0 orphans_found=0 active_version="" active_checked="false" while IFS= read -r -d '' orphan; do orphans_found=$((orphans_found + 1)) - if [ "$prune_rollback" = "true" ] && [ -x "${GOS_INSTALL_DIR}/bin/go" ]; then + if [ "$prune_rollback" = "true" ] && [ "$active_checked" = "false" ]; then + # Executable mode alone does not prove the runtime works (wrong arch, + # missing loader, or a partially installed binary). Probe once, only when + # residue exists; the shared helper forces GOTOOLCHAIN=local. + active_checked="true" + active_version=$(_gos_go_version_of "${GOS_INSTALL_DIR}/bin/go") || active_version="" + fi + if [ "$prune_rollback" = "true" ] && [ -n "$active_version" ]; then if [ "$dry_run" != "true" ]; then _gos_sudo rm -rf "$orphan" || return 1 fi orphans_removed=$((orphans_removed + 1)) _gos_json_enabled || echo "${removal_verb} orphaned backup at ${orphan}." else - _gos_json_enabled || echo "Orphaned backup found at ${orphan} (remove it with: gos prune --rollback)." + if [ "$prune_rollback" = "true" ]; then + _gos_json_enabled || echo "Keeping orphaned backup at ${orphan}: the active Go could not report its local version. Repair the active install before retrying gos prune --rollback." + else + _gos_json_enabled || echo "Orphaned backup found at ${orphan} (remove it with: gos prune --rollback)." + fi fi done < <(_gos_orphaned_backups) diff --git a/tests/prune-recovery.bash b/tests/prune-recovery.bash new file mode 100644 index 0000000..dcb7ee4 --- /dev/null +++ b/tests/prune-recovery.bash @@ -0,0 +1,70 @@ +#!/usr/bin/env bash +set -euo pipefail +# Crash-recovery copies must survive while the active executable is broken. + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +# shellcheck source=tests/lib.bash +. "${repo_root}/tests/lib.bash" +script="${repo_root}/gos.sh" +# shellcheck source=tests/lib-features.bash +. "${repo_root}/tests/lib-features.bash" + +for mode in failed empty healthy; do + case_dir="${test_root}/prune-${mode}" + create_old_install "${case_dir}/go" + create_old_install "${case_dir}/go.gos-backup.123" + create_old_install "${case_dir}/go.gos-current.456" + cat >"${case_dir}/go/bin/go" <<'GO_PROBE' +#!/usr/bin/env bash +[ "${GOTOOLCHAIN:-}" = local ] || exit 99 +printf 'probe\n' >>"${GOS_TEST_PROBE_LOG}" +case "${GOS_TEST_PROBE_MODE}" in + failed) echo 'go version go1.24.0 linux/amd64'; exit 7 ;; + empty) exit 0 ;; + healthy) echo 'go version go1.24.0 linux/amd64' ;; +esac +GO_PROBE + export GOS_TEST_PROBE_LOG="${case_dir}/probe.log" GOS_TEST_PROBE_MODE="$mode" + : >"${case_dir}/probe.log" + run_gos "$case_dir" bash "$script" prune --json + assert_status 0 "$status" "plain prune ${mode}" "$output" + [ ! -s "${case_dir}/probe.log" ] || fail "plain prune must not execute the active runtime" + for dry_run in true false; do + : >"${case_dir}/probe.log" + if [ "$dry_run" = true ]; then + run_gos "$case_dir" bash "$script" prune --rollback --dry-run --json + else + run_gos "$case_dir" bash "$script" prune --rollback --json + fi + assert_status 0 "$status" "prune ${mode} dry-run=${dry_run}" "$output" + assert_json "$output" "prune recovery report" + if [ "$mode" = healthy ]; then + assert_contains "$output" '"orphaned_backups_found":2,"orphaned_backups_removed":2' "healthy removal count" + else + assert_contains "$output" '"orphaned_backups_found":2,"orphaned_backups_removed":0' "broken runtime preserves recovery copies" + fi + if [ "$mode" != healthy ] || [ "$dry_run" = true ]; then + [ -d "${case_dir}/go.gos-backup.123" ] || fail "prune removed backup while ${mode} dry-run=${dry_run}" + [ -d "${case_dir}/go.gos-current.456" ] || fail "prune removed displaced install while ${mode} dry-run=${dry_run}" + else + [ ! -e "${case_dir}/go.gos-backup.123" ] || fail "healthy prune retained backup" + [ ! -e "${case_dir}/go.gos-current.456" ] || fail "healthy prune retained displaced install" + fi + [ "$(wc -l <"${case_dir}/probe.log" | tr -d '[:space:]')" = 1 ] || fail "prune must probe the active runtime once" + [ ! -s "${case_dir}/urls.log" ] || fail "prune contacted the network" + done + pass "prune recovery policy for ${mode} runtime, including dry-run and JSON" +done + +case_dir="${test_root}/prune-no-residue" +create_old_install "${case_dir}/go" +cat >"${case_dir}/go/bin/go" <<'UNEXPECTED_PROBE' +#!/usr/bin/env bash +printf 'probe\n' >>"${GOS_TEST_PROBE_LOG}" +exit 7 +UNEXPECTED_PROBE +export GOS_TEST_PROBE_LOG="${case_dir}/probe.log" +run_gos "$case_dir" bash "$script" prune --rollback --json +assert_status 0 "$status" "prune without residue" "$output" +[ ! -e "${case_dir}/probe.log" ] || fail "prune without residue must not execute the active runtime" +pass "prune probes only when crash-recovery cleanup needs a health decision" From 3f842280e614c3d9c1a019c2859c9b2d91b687f6 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 06:00:20 +0000 Subject: [PATCH 2/3] fix(prune): keep the version probe off the residue loop's input - close stdin and clear a stale GOROOT in the shared go version probe - handle plain prune first so the rollback path probes and decides once - cover side-by-side links, a dangling activation link, GOROOT and stdin - document residue retention in the README, schema and architecture notes - tidy the Fixed list spacing in the changelog Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01ExmnfUngrDYjAheGvYwMnn --- CHANGELOG.md | 3 +-- README.md | 7 +++-- docs/ARCHITECTURE.md | 3 +++ docs/schema/prune.schema.json | 2 +- gos.sh | 24 ++++++++++------- tests/prune-recovery.bash | 49 +++++++++++++++++++++++++++++++++++ 6 files changed, 74 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5ca97c2..515f786 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,8 +9,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this ### Fixed - Project resolution skips `.tool-versions` files that only pin other languages, preserving `go.mod` and parent Go pins. Bash/Zsh auto-switching tracks both the unrelated manifest and the selected Go manifest so edits in the same directory invalidate the prompt cache. Incomplete explicit Go entries still fail closed, and the first `go`/`golang` entry decides (a bare entry no longer lets a later line win). `.tool-versions` fields are no longer pathname-expanded, so `golang *` cannot select a version from file names in the working directory. -- Pruning crash-recovery backups requires the active Go to successfully report its local version, rather than only having executable mode. Broken active installs retain those recovery copies, including in dry-run reports. - +- Pruning crash-recovery backups requires the active Go to successfully report its local version, rather than only having executable mode. Broken active installs retain those recovery copies, including in dry-run reports. Go version probes ignore a stale exported `GOROOT` and no longer inherit gos's own input. - Windows uninstall validates ownership, removes only known installed files, and preserves unrelated contents instead of recursively deleting the target directory. Existing installations without a receipt remain supported, with their ambiguous `LICENSE` left in place. - Windows installs stage replacements on the destination filesystem and restore previous files on publication failure. Failed fresh installs remove partial payloads; failed restoration keeps recovery files and reports their location. - Windows PATH edits refresh a stale process even when the registry already contains gos, remove process entries on uninstall, and preserve registry value types and unrelated entries. Tests use registry substitutes and a disposable native Windows key. diff --git a/README.md b/README.md index 29f4978..80e1ca2 100644 --- a/README.md +++ b/README.md @@ -649,7 +649,7 @@ use `sudo` only for a confirmed root-owned Go path if permission is denied. ```bash # gos-uninstall:data -gos prune --rollback # remove known cached archives, feed metadata and rollback/residue +gos prune --rollback # remove known cached archives, feed metadata, rollback, and residue (kept if the active Go is broken) rm -ri -- "$gos_install_dir" if [ -n "$gos_versions_dir" ]; then rm -ri -- "$gos_versions_dir" @@ -666,6 +666,9 @@ rm -i -- "${XDG_DATA_HOME:-$HOME/.local/share}/bash-completion/completions/gos" Missing completion files need no cleanup. A non-empty cache directory is left for manual inspection rather than recursively deleting unrelated files. +If the active Go cannot report its version, prune keeps crash-recovery copies +(`$gos_install_dir.gos-backup.*`, `.gos-current.*`) and names each one; inspect +them and remove any you no longer need with `rm -ri`. ### Remove the gos command @@ -722,7 +725,7 @@ the Windows launcher. Use the symptom-specific checks below for those cases. |---|---|---| | `go version` still shows the old Go after `gos install` | Another Go is earlier on `PATH` (Homebrew, a manual install) | `gos which` shows which binary wins; put `$GOS_INSTALL_DIR/bin` first, or `eval "$(gos env)"` | | `Error: another gos operation is running` | A previous gos was interrupted, or one is running | `gos status` shows the lock and its pid; remove `${GOS_INSTALL_DIR:-/usr/local/go}.gos-lock` only after verifying no gos operation is running (strip any trailing slash from the install path first) | -| `Residue:` or `Orphaned backup found` in `gos status` | An install was interrupted between renames | `gos prune --rollback` removes residue only after the active binary successfully reports its local Go version; repair a broken active install first | +| `Residue:` or `Orphaned backup found` in `gos status` | An install was interrupted between renames | `gos prune --rollback` removes residue only after the active binary successfully reports its local Go version; repair a broken active install first (`gos prune --rollback` also discards the rollback copy, so use `gos rollback` before pruning if that copy is your working Go) | | `Rollback: broken link` | The side-by-side version the rollback pointed at was uninstalled | `gos prune --rollback`; the next install creates a new rollback | | Password prompt on every install | `GOS_INSTALL_DIR` is root-owned (`/usr/local/go`) | Set `GOS_INSTALL_DIR` under your home directory; gos only escalates for the directory it writes | | `checksum verification required but ...` (exit 4) | `GOS_REQUIRE_CHECKSUM` is set and `jq`/`python3` or a SHA256 tool is missing | Install `jq` or `python3` plus `sha256sum`/`shasum`, then retry; check feed availability and the diagnostic before changing verification policy | diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index d23e5b7..d8f0f1f 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -194,6 +194,9 @@ into a `sudo sh -c`. Version probes and activation checks use `GOTOOLCHAIN=local` to identify the bundled binary without Go selecting or downloading a different toolchain. This includes the GitHub Action output and `verify`'s choice of reference archive. +The shared `_gos_go_version_of` probe also clears `GOROOT` (so a stale export +cannot make a healthy binary fail), closes stdin, and relies on `pipefail`: a +nonzero exit rejects the probe even when the binary printed a version. The override is scoped to each probe: `gos run` and `gos each` preserve the caller's toolchain policy for the user command. diff --git a/docs/schema/prune.schema.json b/docs/schema/prune.schema.json index d4f7d04..4d21221 100644 --- a/docs/schema/prune.schema.json +++ b/docs/schema/prune.schema.json @@ -51,7 +51,7 @@ "orphaned_backups_removed": { "type": "integer", "minimum": 0, - "description": "Of those, removed (only with --rollback)." + "description": "Of those, removed (only with --rollback, and only when the active Go reports its local version)." } }, "required": [ diff --git a/gos.sh b/gos.sh index 0a5b994..92ea596 100755 --- a/gos.sh +++ b/gos.sh @@ -1090,9 +1090,14 @@ _gos_fetch_checksum_file() { # Identify the bundled binary, independent of project directives or the caller's # GOTOOLCHAIN. Probing installed versions must not download or run another Go. # The parse lives here once so the rc/beta regex cannot drift between callers. +# An empty GOROOT lets the binary locate its own tree (a stale exported GOROOT +# would make a healthy go exit 2), and stdin is closed so a probe inside a +# `while read` loop can never consume the loop's input. Callers rely on +# pipefail: a nonzero go exit fails the substitution even when it printed a +# version. _gos_go_version_of() { local go_bin="$1" - GOTOOLCHAIN=local "$go_bin" version 2>/dev/null \ + GOROOT='' GOTOOLCHAIN=local "$go_bin" version /dev/null \ | grep -Eo 'go[0-9]+\.[0-9]+(\.[0-9]+)?(rc[0-9]+|beta[0-9]+)?' \ | head -1 | sed 's/^go//' } @@ -4471,25 +4476,26 @@ cmd_prune() { local orphan orphans_removed=0 orphans_found=0 active_version="" active_checked="false" while IFS= read -r -d '' orphan; do orphans_found=$((orphans_found + 1)) - if [ "$prune_rollback" = "true" ] && [ "$active_checked" = "false" ]; then + if [ "$prune_rollback" != "true" ]; then + _gos_json_enabled || echo "Orphaned backup found at ${orphan} (remove it with: gos prune --rollback)." + continue + fi + if [ "$active_checked" = "false" ]; then # Executable mode alone does not prove the runtime works (wrong arch, # missing loader, or a partially installed binary). Probe once, only when - # residue exists; the shared helper forces GOTOOLCHAIN=local. + # residue exists; the shared helper forces GOTOOLCHAIN=local, and under + # pipefail a nonzero go exit discards any version it printed. active_checked="true" active_version=$(_gos_go_version_of "${GOS_INSTALL_DIR}/bin/go") || active_version="" fi - if [ "$prune_rollback" = "true" ] && [ -n "$active_version" ]; then + if [ -n "$active_version" ]; then if [ "$dry_run" != "true" ]; then _gos_sudo rm -rf "$orphan" || return 1 fi orphans_removed=$((orphans_removed + 1)) _gos_json_enabled || echo "${removal_verb} orphaned backup at ${orphan}." else - if [ "$prune_rollback" = "true" ]; then - _gos_json_enabled || echo "Keeping orphaned backup at ${orphan}: the active Go could not report its local version. Repair the active install before retrying gos prune --rollback." - else - _gos_json_enabled || echo "Orphaned backup found at ${orphan} (remove it with: gos prune --rollback)." - fi + _gos_json_enabled || echo "Keeping orphaned backup at ${orphan}: the active Go could not report its local version. Repair the active install before retrying gos prune --rollback." fi done < <(_gos_orphaned_backups) diff --git a/tests/prune-recovery.bash b/tests/prune-recovery.bash index dcb7ee4..53a756a 100644 --- a/tests/prune-recovery.bash +++ b/tests/prune-recovery.bash @@ -68,3 +68,52 @@ run_gos "$case_dir" bash "$script" prune --rollback --json assert_status 0 "$status" "prune without residue" "$output" [ ! -e "${case_dir}/probe.log" ] || fail "prune without residue must not execute the active runtime" pass "prune probes only when crash-recovery cleanup needs a health decision" + +# A healthy runtime is judged by the install, not by the caller's environment: +# a stale exported GOROOT must not make it look broken, and a probe that reads +# stdin must not swallow the NUL-delimited residue list the prune loop reads. +case_dir="${test_root}/prune-probe-env" +create_old_install "${case_dir}/go" +create_old_install "${case_dir}/go.gos-backup.123" +create_old_install "${case_dir}/go.gos-current.456" +cat >"${case_dir}/go/bin/go" <<'ENV_PROBE' +#!/usr/bin/env bash +[ -z "${GOROOT:-}" ] || exit 2 +cat >/dev/null +echo 'go version go1.24.0 linux/amd64' +ENV_PROBE +GOROOT="${case_dir}/missing-goroot" run_gos "$case_dir" bash "$script" prune --rollback --json +assert_status 0 "$status" "prune with stale GOROOT" "$output" +assert_contains "$output" '"orphaned_backups_found":2,"orphaned_backups_removed":2' "probe ignores stale GOROOT and stdin" +pass "prune probe ignores a stale GOROOT and cannot consume the residue list" + +# Side-by-side: GOS_INSTALL_DIR is an activation symlink and residue slots are +# links too. Removing residue must drop only the links, never their targets, +# and a dangling activation link counts as a broken runtime. +for active in healthy dangling; do + case_dir="${test_root}/prune-side-by-side-${active}" + versions_dir="${case_dir}/versions" + create_old_install "${versions_dir}/go1.24.0" 1.24.0 + create_old_install "${versions_dir}/go1.23.0" 1.23.0 + if [ "$active" = healthy ]; then + ln -s "${versions_dir}/go1.24.0" "${case_dir}/go" + else + ln -s "${versions_dir}/go1.22.0" "${case_dir}/go" + fi + ln -s "${versions_dir}/go1.23.0" "${case_dir}/go.gos-backup.123" + ln -s "${versions_dir}/go1.21.0" "${case_dir}/go.gos-current.456" + GOS_TEST_VERSIONS_DIR="$versions_dir" run_gos "$case_dir" bash "$script" prune --rollback + assert_status 0 "$status" "side-by-side prune ${active}" "$output" + if [ "$active" = healthy ]; then + [ ! -L "${case_dir}/go.gos-backup.123" ] || fail "side-by-side prune retained backup link" + [ ! -L "${case_dir}/go.gos-current.456" ] || fail "side-by-side prune retained dangling residue link" + assert_contains "$output" "Removed orphaned backup at ${case_dir}/go.gos-backup.123." "side-by-side removal report" + else + [ -L "${case_dir}/go.gos-backup.123" ] || fail "dangling activation must keep backup link" + [ -L "${case_dir}/go.gos-current.456" ] || fail "dangling activation must keep residue link" + assert_contains "$output" "Keeping orphaned backup at ${case_dir}/go.gos-backup.123: the active Go could not report its local version." "side-by-side keep report" + fi + [ -x "${versions_dir}/go1.23.0/bin/go" ] || fail "side-by-side prune removed a residue link target" + [ -x "${versions_dir}/go1.24.0/bin/go" ] || fail "side-by-side prune removed the active version" + pass "side-by-side prune with ${active} activation link removes only residue links" +done From cb0be448099b2d2e6927422aa24ceeeb3fe9424c Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 06:02:16 +0000 Subject: [PATCH 3/3] test(prune): skip side-by-side prune cases without real symlinks Git Bash ln -s copies and cannot link a missing target, so the new side-by-side cases would abort the suite on Windows. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01ExmnfUngrDYjAheGvYwMnn --- tests/prune-recovery.bash | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/tests/prune-recovery.bash b/tests/prune-recovery.bash index 53a756a..78ea2b5 100644 --- a/tests/prune-recovery.bash +++ b/tests/prune-recovery.bash @@ -89,8 +89,17 @@ pass "prune probe ignores a stale GOROOT and cannot consume the residue list" # Side-by-side: GOS_INSTALL_DIR is an activation symlink and residue slots are # links too. Removing residue must drop only the links, never their targets, -# and a dangling activation link counts as a broken runtime. -for active in healthy dangling; do +# and a dangling activation link counts as a broken runtime. Git Bash's ln -s +# copies (and cannot link a missing target), so probe for real symlinks first. +symlink_probe="${test_root}/symlink-probe" +if ln -s "$script" "$symlink_probe" 2>/dev/null && [ -L "$symlink_probe" ]; then + side_by_side_cases="healthy dangling" +else + side_by_side_cases="" + skip_assertion "side-by-side prune cases skipped: this filesystem has no real symlinks" +fi +rm -f "$symlink_probe" +for active in $side_by_side_cases; do case_dir="${test_root}/prune-side-by-side-${active}" versions_dir="${case_dir}/versions" create_old_install "${versions_dir}/go1.24.0" 1.24.0