fix(#4085): surface dirty install state in update-check payload

The update-check payload now carries a `dirty: bool` reflecting the
working-tree state vs HEAD, computed via the same `git diff-index
--quiet HEAD --` primitive already used by `_dirty_suffix()`. A dirty
install at-or-past the latest release tag used to silently report
"Up to date" with no remediation affordance; the Settings panel
can now read this flag to offer `apply_force_update` (which already
does `git checkout .` + `git reset --hard` to reset to a clean
latest).

The probe is conservatively clean on real errors (timeout, missing
git, fatal) so a transient probe failure never produces a false-
positive "local changes" alert.

Closes #4085
This commit is contained in:
b3nw committed 2026-06-13 06:05:06 +00:00
1 parent 3f2eb8d362
commit 9bb1dc8573
4 files changed
+79 -2

No files matched your search

+4
View File
@@ -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
+33 -2
View File
@@ -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:
+31
View File
@@ -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
+11
View File
@@ -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.