diff --git a/Makefile.d/user.mk b/Makefile.d/user.mk index d4d07b0..ed25d4f 100644 --- a/Makefile.d/user.mk +++ b/Makefile.d/user.mk @@ -20,7 +20,7 @@ update: ## Collect fresh version data with network calls and update snapshot (~1 @$(MAKE) check-python-managers 2>/dev/null || true @$(MAKE) check-node-managers 2>/dev/null || true -upgrade: scripts-perms ## Run interactive upgrade guide (uses snapshot, no network calls) +upgrade: scripts-perms ## Run interactive upgrade guide (refreshes installed state first, no network calls) @bash scripts/guide.sh cleanup: scripts-perms ## Interactive removal of installed tools diff --git a/audit.py b/audit.py index de3b154..98f4eb8 100755 --- a/audit.py +++ b/audit.py @@ -955,10 +955,34 @@ def cmd_update_local(args: argparse.Namespace) -> int: merged_tools = list(tools_by_name.values()) write_snapshot(merged_tools, offline=OFFLINE_MODE) else: - # Full update: replace entire snapshot - legacy_snapshot = build_legacy_snapshot(upstream_cache, local_state) - write_snapshot(legacy_snapshot.get("tools", []), offline=OFFLINE_MODE) - print(f"✓ Legacy snapshot updated: {get_snapshot_path()}", file=sys.stderr) + # Full update: refresh the snapshot's installed_version + status from the + # fresh local detection, but PRESERVE each tool's existing latest_version. + # A network-free refresh must NOT clobber the upstream "latest" that + # `make update` collected (the committed baseline is older) -- otherwise + # make-update and make-upgrade disagree and the guide shows a target that + # is lower than the installed version. + existing = load_snapshot().get("tools", []) + if existing: + for entry in existing: + name = entry.get("tool", "") + if "@" in name: + continue # multi-version cycle: no per-cycle local-only data + inst = local_state.tools.get(name) + if inst is None: + continue + latest = entry.get("latest_version") or "" + entry["installed"] = inst.installed_version or "" + entry["installed_version"] = inst.installed_version or "" + entry["installed_method"] = inst.installed_method or "" + entry["installed_path_selected"] = inst.installed_path or "" + entry["status"] = compute_status(inst.installed_version or "", latest) + write_snapshot(existing, offline=OFFLINE_MODE) + print(f"✓ Legacy snapshot refreshed (installed state): {get_snapshot_path()}", file=sys.stderr) + else: + # No snapshot yet -- build one from the upstream baseline. + legacy_snapshot = build_legacy_snapshot(upstream_cache, local_state) + write_snapshot(legacy_snapshot.get("tools", []), offline=OFFLINE_MODE) + print(f"✓ Legacy snapshot updated: {get_snapshot_path()}", file=sys.stderr) return 0 diff --git a/scripts/guide.sh b/scripts/guide.sh index d49b463..9374f13 100755 --- a/scripts/guide.sh +++ b/scripts/guide.sh @@ -96,7 +96,12 @@ reload_audit_json() { fi } -echo "Gathering current tool status from snapshot..." +# Refresh the snapshot's installed state (network-free) so the displayed +# "installed:" matches what the installers detect live as "before:". The refresh +# preserves the cached upstream "latest", so it stays offline and consistent with +# `make update` (no target regression, no make-update/make-upgrade disagreement). +echo "Refreshing installed status (no network)..." +(cd "$ROOT" && "$CLI" audit.py --update-local >/dev/null 2>&1) || true AUDIT_OUTPUT="$(cd "$ROOT" && CLI_AUDIT_RENDER=1 CLI_AUDIT_LINKS=0 CLI_AUDIT_EMOJI=0 "$CLI" audit.py || true)" AUDIT_JSON="$(cd "$ROOT" && CLI_AUDIT_JSON=1 CLI_AUDIT_RENDER=1 "$CLI" audit.py || true)" diff --git a/tests/test_update_fixes.py b/tests/test_update_fixes.py index 2b9653b..8e0e294 100644 --- a/tests/test_update_fixes.py +++ b/tests/test_update_fixes.py @@ -1223,3 +1223,40 @@ def test_missing_latest_is_unknown(self): import audit # no known latest -> UNKNOWN, never a false OUTDATED assert audit.compute_status("1.0.0", "") == "UNKNOWN" + + +@skip_on_windows +class TestUpdateLocalPreservesLatest: + """A full `--update-local` refreshes installed_version + status from live + detection but PRESERVES each tool's existing latest_version, so a network-free + refresh never clobbers the upstream data `make update` collected (which would + show a target lower than installed and make update/upgrade disagree).""" + + def test_refreshes_installed_but_preserves_latest(self, tmp_path): + snap = tmp_path / "snap.json" + # git is present in CI; seed a stale installed + a deliberately-high latest + snap.write_text(json.dumps({ + "__meta__": {"count": 1}, + "tools": [{"tool": "git", "installed_version": "0.0.1", + "latest_version": "999.0.0", "status": "OUTDATED"}], + })) + env = os.environ.copy() + env.update({ + "CLI_AUDIT_SNAPSHOT_FILE": str(snap), + "CLI_AUDIT_LOCAL_FILE": str(tmp_path / "local.json"), + "CLI_AUDIT_UPSTREAM_FILE": str(tmp_path / "upstream.json"), + "CLI_AUDIT_OFFLINE": "1", + "PYTHONUTF8": "1", + }) + r = subprocess.run( + [sys.executable, "audit.py", "--update-local", "git"], + capture_output=True, text=True, cwd=str(PROJECT_ROOT), env=env, timeout=60, + ) + assert r.returncode == 0, r.stderr + entry = next(t for t in json.loads(snap.read_text())["tools"] if t["tool"] == "git") + # latest PRESERVED (not rebuilt from the empty/baseline upstream cache) + assert entry["latest_version"] == "999.0.0", "latest must be preserved" + # installed REFRESHED to the real git version (not the seeded 0.0.1) + assert entry["installed_version"] not in ("", "0.0.1"), "installed must be refreshed" + # directional status: real git (e.g. 2.x) < 999.0.0 -> OUTDATED + assert entry["status"] == "OUTDATED"