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
2 changes: 1 addition & 1 deletion Makefile.d/user.mk
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
32 changes: 28 additions & 4 deletions audit.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
7 changes: 6 additions & 1 deletion scripts/guide.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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)"

Expand Down
37 changes: 37 additions & 0 deletions tests/test_update_fixes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Loading