diff --git a/api/updates.py b/api/updates.py index cf7bc216..b9797279 100644 --- a/api/updates.py +++ b/api/updates.py @@ -431,21 +431,15 @@ def _dirty_suffix(path: Path, timeout=1) -> str: out, ok = _run_git(['diff-index', '--quiet', 'HEAD', '--'], path, timeout=timeout) if ok: return "" - # diff-index --quiet exits 1 with no stdout/stderr to *signal* a dirty tree - # (not an error). _run_git() substitutes a synthetic "git exited with - # status N" diagnostic when both streams are empty, which makes the naive - # `if not out` guard always false on dirty trees — silently dropping the - # suffix and defeating dev-build cache busting (static/foo.js?v=… stays - # identical to the last-committed version). Treat the synthetic shape as - # the dirty signal; real errors (timeouts, missing git) carry a different - # diagnostic and correctly suppress the suffix. - if not out or out.startswith('git exited with status '): - diff, diff_ok = _run_git(['diff', '--binary', 'HEAD', '--'], path, timeout=timeout) - if diff_ok and diff: - digest = hashlib.sha1(diff.encode('utf-8', errors='replace')).hexdigest()[:8] - return f"-dirty-{digest}" - return "-dirty" - return "" + # Only diff-index status 1 means dirty. Keep version display consistent + # with the strict action-time probe; all other failures suppress the suffix. + if out != 'git exited with status 1': + return "" + diff, diff_ok = _run_git(['diff', '--binary', 'HEAD', '--'], path, timeout=timeout) + if diff_ok and diff: + digest = hashlib.sha1(diff.encode('utf-8', errors='replace')).hexdigest()[:8] + return f"-dirty-{digest}" + return "-dirty" def _describe_git_version(path: Path, *, timeout=5, dirty_timeout=1) -> str | None: @@ -1943,16 +1937,15 @@ def _discard_local_changes(path: Path, reset_ref: str) -> bool: def apply_force_update(target: str, channel=None) -> dict: - """Force-reset the target repo to the latest remote HEAD. + """Discard local changes for the requested update target. Unlike apply_update() which requires a clean working tree and refuses merge conflicts, this discards all local modifications (checkout .) and - resets to origin/ — equivalent to what the diverged/conflict - error messages ask the user to run manually. + resets to the selected update ref. A dirty stable WebUI checkout with no + promoted ref resets to its symbolic HEAD so the commit itself is unchanged. - Should only be called when apply_update() has already returned a - response with ``conflict: True`` or ``diverged: True`` and the user - has confirmed they want to discard local changes. + The endpoint is called after the user has confirmed they want to discard + local changes, including the stable no-ref dirty-checkout recovery path. CHANNEL SAFETY (rewind guard): ``reset --hard`` is destructive. When the selected channel resolves to a ref that is an ANCESTOR of HEAD (i.e. the diff --git a/tests/test_updates.py b/tests/test_updates.py index 14c2f52c..c4ee98f1 100644 --- a/tests/test_updates.py +++ b/tests/test_updates.py @@ -813,6 +813,48 @@ def test_run_git_handles_missing_stdout_after_decode_thread_failure(tmp_path): assert out == '' +@pytest.mark.parametrize('probe_output', [ + 'git exited with status 2', + 'git exited with status 128', + 'git exited with status -9', +]) +def test_describe_git_version_suppresses_unknown_dirty_probe_status( + tmp_path, monkeypatch, probe_output, +): + calls = [] + + def fake_git(args, cwd, timeout=10): + calls.append(args) + if args == ['describe', '--tags', '--always']: + return 'v0.52.5', True + if args == ['diff-index', '--quiet', 'HEAD', '--']: + return probe_output, False + raise AssertionError(f'unexpected git args: {args!r}') + + monkeypatch.setattr(updates, '_run_git', fake_git) + + assert updates._describe_git_version(tmp_path) == 'v0.52.5' + assert calls == [ + ['describe', '--tags', '--always'], + ['diff-index', '--quiet', 'HEAD', '--'], + ] + + +def test_describe_git_version_marks_exact_dirty_probe_status(tmp_path, monkeypatch): + def fake_git(args, cwd, timeout=10): + if args == ['describe', '--tags', '--always']: + return 'v0.52.5', True + if args == ['diff-index', '--quiet', 'HEAD', '--']: + return 'git exited with status 1', False + if args == ['diff', '--binary', 'HEAD', '--']: + return 'diff --git a/tracked.txt b/tracked.txt', True + raise AssertionError(f'unexpected git args: {args!r}') + + monkeypatch.setattr(updates, '_run_git', fake_git) + + assert updates._describe_git_version(tmp_path).startswith('v0.52.5-dirty-') + + def test_split_remote_ref_splits_tracking_ref(): """_split_remote_ref should correctly split origin/branch.""" assert updates._split_remote_ref('origin/master') == ('origin', 'master')