Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
- 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.
- Output and error messages that gos captures from file operations (with or without a privilege-escalation retry) keep their trailing newline when replayed, so the next progress or error line is no longer glued onto the command's last line (for example `...: Permission deniedError: ...`).
- `doctor` marks a Go executable that fails or returns malformed version output as a problem, exits nonzero, and suggests checking PATH or repairing that runtime. Local stable, prerelease, and development toolchains remain supported without automatic toolchain downloads.
- 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.
Expand Down
7 changes: 5 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -657,7 +657,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"
Expand All @@ -674,6 +674,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

Expand Down Expand Up @@ -730,7 +733,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 (`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 |
Expand Down
5 changes: 4 additions & 1 deletion docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -233,7 +236,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.<pid>`, `.gos-current.<pid>` | 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.<pid>`, `.gos-current.<pid>` | 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. |
| `<resolved gos script>.gos-lock/pid` | The path-scoped self-update lock. |
| `$GOS_VERSIONS_DIR/go<version>/` | Installed versions in side-by-side mode. |
Expand Down
2 changes: 1 addition & 1 deletion docs/schema/prune.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
Expand Down
25 changes: 21 additions & 4 deletions gos.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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 2>/dev/null \
| grep -Eo 'go[0-9]+\.[0-9]+(\.[0-9]+)?(rc[0-9]+|beta[0-9]+)?' \
| head -1 | sed 's/^go//'
}
Expand Down Expand Up @@ -4474,17 +4479,29 @@ cmd_prune() {
# *.gos-current.<pid> 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" ]; 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, 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 [ -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)."
_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)

Expand Down
128 changes: 128 additions & 0 deletions tests/prune-recovery.bash
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
#!/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"

# 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. 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
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
Loading