This commit is contained in:
1 parent
1114093d88
commit
93c72adc8a
4 files changed
+135
-3
No files matched your search
+15
-2
@@ -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
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 = []
|
||||
|
||||
Reference in new issue
Block a user