diff --git a/CHANGELOG.md b/CHANGELOG.md index 236e78da..f7d6330c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,10 @@ ## [Unreleased] +### Fixed + +- **Dirty install no longer reports "Up to date" with no remediation affordance (#4085).** The update check payload now carries a `dirty: bool` reflecting the working-tree state vs HEAD, so the Settings update panel can surface a "Local changes detected" state with an apply-latest action wired to the existing destructive-reset endpoint when the install is dirty and at-or-past the latest release tag. + ## [v0.51.382] — 2026-06-13 — Release MU (Stable Assistant Turn Anchors: activity-scene projection, inert, #4093) ### Added diff --git a/api/updates.py b/api/updates.py index b1a62abe..e1215d4e 100644 --- a/api/updates.py +++ b/api/updates.py @@ -644,7 +644,14 @@ def _check_repo_branch(path, name, *, fetch=True): def _check_repo(path, name): - """Check if a git repo is behind its latest release. Returns dict or None.""" + """Check if a git repo is behind its latest release. Returns dict or None. + + The returned dict (when not None) always carries a ``dirty: bool`` reflecting + the working-tree state vs HEAD. A dirty install at-or-past the latest release + tag used to silently report "Up to date" with no remediation affordance, so + the Settings panel reads this flag to offer ``apply_force_update`` (issue + #4085). + """ if path is None or not (path / '.git').exists(): return None @@ -667,19 +674,43 @@ def _check_repo(path, name): release_info = dict(release_info) release_info['error'] = message release_info['stale_check'] = True + release_info['dirty'] = _is_dirty(path) return release_info return { 'name': name, 'behind': None, 'error': message, 'stale_check': True, + 'dirty': _is_dirty(path), } release_info = _check_repo_release(path, name) if release_info is not None: + release_info = dict(release_info) + release_info['dirty'] = _is_dirty(path) return release_info - return _check_repo_branch(path, name, fetch=False) + branch_info = _check_repo_branch(path, name, fetch=False) + if branch_info is not None: + branch_info = dict(branch_info) + branch_info['dirty'] = _is_dirty(path) + return branch_info + return None + + +def _is_dirty(path: Path, timeout: int = 1) -> bool: + """Return True when the working tree has uncommitted changes vs HEAD. + + Same primitive as ``_dirty_suffix`` (issue #4085): ``git diff-index + --quiet HEAD --`` exits 0 on a clean tree and 1 on a dirty tree (not an + error). Real errors (timeout, missing git, fatal) are conservatively + reported as clean so a transient probe failure never produces a false- + positive "local changes" alert. + """ + out, ok = _run_git(['diff-index', '--quiet', 'HEAD', '--'], path, timeout=timeout) + if ok: + return False + return not out or out.startswith('git exited with status ') def _ignored_agent_update_info() -> dict: diff --git a/tests/test_update_checker.py b/tests/test_update_checker.py index 2e8733eb..82912de2 100644 --- a/tests/test_update_checker.py +++ b/tests/test_update_checker.py @@ -322,3 +322,34 @@ class TestApplyUpdateDiagnostics: assert 'could not reach' in result['message'].lower() or \ 'internet' in result['message'].lower() or \ 'remote' in result['message'].lower() + + +# Issue #4085 regression: a dirty install at-or-past the latest release tag +# must surface `dirty: True` on the check payload so the Settings panel can +# offer `apply_force_update` instead of the silent "Up to date" state. +class TestCheckRepoDirtyFlag: + def test_dirty_tree_surfaces_dirty_true(self, tmp_path): + """A dirty tree on a network-failed check must still carry dirty=True. + + The Settings panel needs the dirty flag on every payload shape so a + user can always recover from a local-modifications state, even when + the latest remote cannot be reached. + """ + (tmp_path / '.git').mkdir() + + def fake_git(args, cwd, timeout=10): + if args == ['diff-index', '--quiet', 'HEAD', '--']: + return '', False # dirty + if args == ['fetch', 'origin', '--tags', '--force']: + return 'network unavailable', False # fail fast + if args == ['tag', '--list', 'v*', '--sort=-v:refname']: + return '', True + raise AssertionError(f'unexpected git args: {args!r}') + + from api import updates + with patch(f'{_MODULE}._run_git', side_effect=fake_git): + info = updates._check_repo(tmp_path, 'webui') + + assert info is not None + assert info['dirty'] is True + assert info['stale_check'] is True diff --git a/tests/test_updates.py b/tests/test_updates.py index 3297ade4..0cadd897 100644 --- a/tests/test_updates.py +++ b/tests/test_updates.py @@ -5,6 +5,8 @@ import api.updates as updates def _fake_git_for_release_fetch_failure(args, cwd, timeout=10): + if args == ['diff-index', '--quiet', 'HEAD', '--']: + return '', True # clean tree if args == ['fetch', 'origin', '--tags', '--force']: return 'would clobber existing tag v0.50.294', False if args == ['tag', '--list', 'v*', '--sort=-v:refname']: @@ -32,6 +34,9 @@ def test_check_repo_reports_release_gap_even_when_tag_fetch_fails(tmp_path): assert info['latest_version'] == 'v0.51.106' assert info['stale_check'] is True assert 'would clobber existing tag' in info['error'] + # Issue #4085: the dirty flag must ride along on every payload shape. + # The mock returns ('', True) for the dirty probe, so the tree is clean. + assert info['dirty'] is False def test_check_repo_redacts_credentialed_fetch_failure(tmp_path): @@ -45,6 +50,8 @@ def test_check_repo_redacts_credentialed_fetch_failure(tmp_path): ) def fake_git(args, cwd, timeout=10): + if args == ['diff-index', '--quiet', 'HEAD', '--']: + return '', True if args == ['fetch', 'origin', '--tags', '--force']: return raw_error, False if args == ['tag', '--list', 'v*', '--sort=-v:refname']: @@ -68,6 +75,8 @@ def test_check_repo_fetch_failure_without_tags_is_not_up_to_date(tmp_path): (tmp_path / '.git').mkdir() def fake_git(args, cwd, timeout=10): + if args == ['diff-index', '--quiet', 'HEAD', '--']: + return '', True if args == ['fetch', 'origin', '--tags', '--force']: return 'network unavailable', False if args == ['tag', '--list', 'v*', '--sort=-v:refname']: @@ -225,6 +234,8 @@ def test_check_repo_fetches_tags_with_force(tmp_path): def fake_git(args, cwd, timeout=10): seen_args.append(args) + if args == ['diff-index', '--quiet', 'HEAD', '--']: + return '', True if args[:2] == ['fetch', 'origin']: # Force a fetch failure path so we don't have to mock the rest of # the release/branch logic; the assertion is about the args shape.