stage #4486: untracked-file update collision recovery (#4310) [CHANGELOG pending post-4502]

This commit is contained in:
nesquena-hermes committed 2026-06-19 20:09:30 +00:00
1 parent 1114093d88
commit 93c72adc8a
4 files changed
+135 -3

No files matched your search

+15 -2
View File
@@ -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
+1 -1
View File
@@ -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,
+12
View File
@@ -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:
+107
View File
@@ -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 = []