From 93c72adc8a4c16e39dd52d97e4142ffe93f5d532 Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Fri, 19 Jun 2026 20:05:45 +0000 Subject: [PATCH 1/2] stage #4486: untracked-file update collision recovery (#4310) [CHANGELOG pending post-4502] --- api/updates.py | 17 ++++- static/ui.js | 2 +- tests/test_update_banner_fixes.py | 12 ++++ tests/test_update_stash_recovery.py | 107 ++++++++++++++++++++++++++++ 4 files changed, 135 insertions(+), 3 deletions(-) diff --git a/api/updates.py b/api/updates.py index 33df2d0d..110cdc7e 100644 --- a/api/updates.py +++ b/api/updates.py @@ -1242,8 +1242,15 @@ def apply_force_update(target: str) -> dict: compare_ref = _select_apply_compare_ref(path) - # Discard local modifications then reset to remote HEAD + # Discard local modifications and untracked colliders before resetting. + # Do not use -x: ignored build/cache artifacts should survive force update. _run_git(['checkout', '.'], path) + _, clean_ok = _run_git(['clean', '-fd'], path) + if not clean_ok: + return { + 'ok': False, + 'message': 'Failed to remove untracked files before force reset', + } _, ok = _run_git(['reset', '--hard', compare_ref], path) if not ok: return {'ok': False, 'message': f'Force reset to {compare_ref} failed'} @@ -1343,6 +1350,9 @@ def _apply_update_inner(target): if not pull_ok: pull_lower = pull_out.lower() detail = pull_out.strip()[:300] if pull_out.strip() else '(no output from git)' + untracked_collision = ( + 'untracked working tree files would be overwritten' in pull_lower + ) diverged_failure = ( 'not possible to fast-forward' in pull_lower or 'diverged' in pull_lower ) @@ -1436,7 +1446,10 @@ def _apply_update_inner(target): message_parts = [f'Pull failed: {detail}'] if restored_note: message_parts.append(restored_note) - return {'ok': False, 'message': ' '.join(message_parts)} + response = {'ok': False, 'message': ' '.join(message_parts)} + if untracked_collision: + response['conflict'] = True + return response # Re-apply stash if we stashed. stash_drop_failed = False diff --git a/static/ui.js b/static/ui.js index c580c552..ee692925 100644 --- a/static/ui.js +++ b/static/ui.js @@ -7186,7 +7186,7 @@ async function forceUpdate(btn){ if(!target) return; const confirmed=await showConfirmDialog({ title:'Force update '+target+'?', - message:'This will discard all local changes in the '+target+' repo and reset to the latest remote version. This cannot be undone.', + message:'This will discard all local changes and delete untracked files in the '+target+' repo, then reset to the latest remote version. This cannot be undone.', confirmLabel:'Force update', danger:true, focusCancel:true, diff --git a/tests/test_update_banner_fixes.py b/tests/test_update_banner_fixes.py index b7ec8366..a86c2a9c 100644 --- a/tests/test_update_banner_fixes.py +++ b/tests/test_update_banner_fixes.py @@ -2025,6 +2025,18 @@ class TestForceButtonResetOnRetry: ) +def test_force_update_confirm_discloses_untracked_file_deletion(): + """#4310: destructive force-update copy must include untracked files.""" + src = read('static/ui.js') + m = re.search(r'async function forceUpdate\b.*?\n\}', src, re.DOTALL) + assert m, "forceUpdate() not found" + fn = m.group(0) + assert 'delete untracked files' in fn, ( + "forceUpdate confirmation must disclose that git clean -fd deletes " + "untracked files before the hard reset" + ) + + # ── #785: Manual 'Check for Updates' button ─────────────────────────────────── class TestCheckForUpdatesButton: diff --git a/tests/test_update_stash_recovery.py b/tests/test_update_stash_recovery.py index 16b6fd62..6047e19f 100644 --- a/tests/test_update_stash_recovery.py +++ b/tests/test_update_stash_recovery.py @@ -4,6 +4,113 @@ from unittest.mock import patch import api.updates as updates +def test_pull_failure_untracked_overwrite_flags_conflict(tmp_path): + """Untracked overwrite pull failures must surface the existing force-update path.""" + (tmp_path / '.git').mkdir() + call_log = [] + + def fake_git(args, path, timeout=10): + call_log.append(args) + if args[:2] == ['fetch', 'origin']: + return '', True + if args == ['status', '--porcelain', '--untracked-files=no']: + return '', True + if args[:2] == ['pull', '--ff-only']: + return ( + 'error: The following untracked working tree files would be overwritten by merge:\n' + '\ttests/test_custom_provider_prefix_collisions.py\n' + 'Please move or remove them before you merge.\n' + 'Aborting', + False, + ) + raise AssertionError(f'unexpected git args: {args!r}') + + restart_calls = [] + + with ( + patch.object(updates, 'REPO_ROOT', tmp_path), + patch.object(updates, '_run_git', side_effect=fake_git), + patch.object(updates, '_select_apply_compare_ref', return_value='origin/master'), + patch.object(updates, '_schedule_restart', side_effect=lambda: restart_calls.append(1)), + ): + result = updates._apply_update_inner('webui') + + assert result['ok'] is False + assert result['conflict'] is True + assert result['message'].startswith('Pull failed:') + assert 'untracked working tree files would be overwritten' in result['message'] + assert ['stash', 'push', '-m', 'hermes-update-autostash'] not in call_log + assert len(restart_calls) == 0 + + +def test_apply_force_update_removes_untracked_files_before_reset(tmp_path): + """Force update must clear untracked colliders before reset --hard (#4310).""" + (tmp_path / '.git').mkdir() + call_log = [] + + def fake_git(args, path, timeout=10): + call_log.append(args) + if args[:2] == ['fetch', 'origin']: + return '', True + if args == ['checkout', '.']: + return '', True + if args == ['clean', '-fd']: + return '', True + if args == ['reset', '--hard', 'origin/master']: + return '', True + raise AssertionError(f'unexpected git args: {args!r}') + + restart_calls = [] + + with ( + patch.object(updates, 'REPO_ROOT', tmp_path), + patch.object(updates, '_run_git', side_effect=fake_git), + patch.object(updates, '_select_apply_compare_ref', return_value='origin/master'), + patch.object(updates, '_schedule_restart', side_effect=lambda: restart_calls.append(1)), + ): + result = updates.apply_force_update('webui') + + assert result['ok'] is True + assert ['checkout', '.'] in call_log + assert ['clean', '-fd'] in call_log + assert ['reset', '--hard', 'origin/master'] in call_log + assert call_log.index(['checkout', '.']) < call_log.index(['clean', '-fd']) + assert call_log.index(['clean', '-fd']) < call_log.index(['reset', '--hard', 'origin/master']) + assert len(restart_calls) == 1 + + +def test_apply_force_update_stops_when_clean_fails(tmp_path): + """A failed git clean must not be hidden behind a reset success.""" + (tmp_path / '.git').mkdir() + call_log = [] + + def fake_git(args, path, timeout=10): + call_log.append(args) + if args[:2] == ['fetch', 'origin']: + return '', True + if args == ['checkout', '.']: + return '', True + if args == ['clean', '-fd']: + return 'permission denied', False + raise AssertionError(f'unexpected git args: {args!r}') + + restart_calls = [] + + with ( + patch.object(updates, 'REPO_ROOT', tmp_path), + patch.object(updates, '_run_git', side_effect=fake_git), + patch.object(updates, '_select_apply_compare_ref', return_value='origin/master'), + patch.object(updates, '_schedule_restart', side_effect=lambda: restart_calls.append(1)), + ): + result = updates.apply_force_update('webui') + + assert result['ok'] is False + assert 'untracked files' in result['message'] + assert ['clean', '-fd'] in call_log + assert ['reset', '--hard', 'origin/master'] not in call_log + assert len(restart_calls) == 0 + + def test_stash_apply_conflict_preserves_stash(tmp_path): """On stash-apply conflict, stash is preserved and restart is scheduled.""" call_log = [] From 99c12c6c8d128ea95c981817c65e8e4a4897f564 Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Fri, 19 Jun 2026 20:10:02 +0000 Subject: [PATCH 2/2] changelog: stamp v0.51.520 (Release SE) for #4310 untracked-file recovery --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 162b6644..33fbab9b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,12 @@ ## [Unreleased] +## [v0.51.520] — 2026-06-19 — Release SE (recover from untracked-file update collisions) + +### Fixed + +- **An update blocked by an untracked-file collision now offers a recovery path instead of leaving the user stuck (#4310).** When `git pull` fails with "untracked working tree files would be overwritten by merge", the update response now flags the conflict so the UI surfaces the existing Force-update button (the normal path still destroys nothing). Force-update clears untracked colliders with `git clean -fd` (without `-x`, so ignored build/cache artifacts survive) before resetting, aborts before the hard reset if the clean fails, and its confirmation dialog now explicitly discloses that untracked files will be deleted. Thanks @rodboev. + ## [v0.51.519] — 2026-06-19 — Release SD (reconcile stale active-run registry entries) ### Fixed