mirror of
https://github.com/nesquena/hermes-webui.git
synced 2026-07-20 22:51:07 +00:00
* fix(updates): use stash apply (not pop) for conflict-safe self-update recovery (#3667) Co-authored-by: Rod Boev <rod.boev@gmail.com> * docs(changelog): v0.51.272 — Release IN (stage-p3a, #3667 only) --------- Co-authored-by: nesquena-hermes <[email protected]> Co-authored-by: Rod Boev <rod.boev@gmail.com>
This commit is contained in:
@@ -3,6 +3,11 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [v0.51.272] — 2026-06-05 — Release IN (stage-p3a — conflict-safe self-update recovery)
|
||||
|
||||
### Fixed
|
||||
- **Self-update conflict recovery no longer risks losing local changes.** The update path now stashes with a named entry (`stash push -m hermes-update-autostash`) and restores with `stash apply` (keeping the stash if the drop fails) instead of `stash pop`. On a restore conflict it resets the working tree to HEAD to clear conflict markers but **preserves your changes in the git stash**, and returns a clear `stash_conflict` message telling you exactly how to inspect/re-apply them — replacing the previous `stash pop` → `reset --merge` path that could discard local modifications. (#3667, @rodboev)
|
||||
|
||||
## [v0.51.271] — 2026-06-05 — Release IM (stage-m1 — named custom provider binding)
|
||||
|
||||
### Fixed
|
||||
|
||||
+116
-28
@@ -1209,7 +1209,7 @@ def _apply_update_inner(target):
|
||||
}
|
||||
stashed = False
|
||||
if status_out:
|
||||
_, ok = _run_git(['stash'], path)
|
||||
_, ok = _run_git(['stash', 'push', '-m', 'hermes-update-autostash'], path)
|
||||
if not ok:
|
||||
return {'ok': False, 'message': 'Failed to stash local changes'}
|
||||
stashed = True
|
||||
@@ -1225,57 +1225,139 @@ def _apply_update_inner(target):
|
||||
pull_args.extend(['origin', compare_ref])
|
||||
pull_out, pull_ok = _run_git(pull_args, path, timeout=30)
|
||||
if not pull_ok:
|
||||
pull_lower = pull_out.lower()
|
||||
detail = pull_out.strip()[:300] if pull_out.strip() else '(no output from git)'
|
||||
diverged_failure = (
|
||||
'not possible to fast-forward' in pull_lower or 'diverged' in pull_lower
|
||||
)
|
||||
restored_stash = False
|
||||
stash_drop_failed = False
|
||||
if stashed:
|
||||
_run_git(['stash', 'pop'], path)
|
||||
_, apply_ok = _run_git(['stash', 'apply'], path)
|
||||
if apply_ok:
|
||||
_, drop_ok = _run_git(['stash', 'drop'], path)
|
||||
restored_stash = True
|
||||
stash_drop_failed = not drop_ok
|
||||
else:
|
||||
_, reset_ok = _run_git(['reset', '--hard', 'HEAD'], path)
|
||||
if not reset_ok:
|
||||
response = {
|
||||
'ok': False,
|
||||
'message': (
|
||||
'Pull failed, and failed to clean up a stash-apply '
|
||||
'conflict while restoring local changes. Manual '
|
||||
'intervention needed: run git -C ' + str(path) + ' '
|
||||
'reset --hard HEAD to remove conflict markers. Your '
|
||||
'changes remain in the git stash. Pull error: '
|
||||
+ detail
|
||||
),
|
||||
'stash_conflict': True,
|
||||
}
|
||||
if diverged_failure:
|
||||
response['diverged'] = True
|
||||
return response
|
||||
response = {
|
||||
'ok': False,
|
||||
'message': (
|
||||
f'Pull failed, and your local {target} modifications '
|
||||
'conflicted while restoring from stash. The index and '
|
||||
'tracked files were restored to HEAD, and your changes '
|
||||
'remain in the git stash. To inspect: git -C ' + str(path) + ' stash show -p. '
|
||||
'To re-apply: git -C ' + str(path) + ' stash apply, then '
|
||||
'resolve conflicts. Pull error: ' + detail
|
||||
),
|
||||
'stash_conflict': True,
|
||||
}
|
||||
if diverged_failure:
|
||||
response['diverged'] = True
|
||||
return response
|
||||
|
||||
restored_note_parts = []
|
||||
if restored_stash:
|
||||
restored_note_parts.append(
|
||||
f'Local {target} modifications were restored to the working '
|
||||
'tree; save or stash them before running destructive recovery '
|
||||
'commands.'
|
||||
)
|
||||
if stash_drop_failed:
|
||||
restored_note_parts.append(
|
||||
'The temporary stash entry may still be present because '
|
||||
'git stash drop failed.'
|
||||
)
|
||||
restored_note = ' '.join(restored_note_parts)
|
||||
|
||||
# Diagnose the most common failure modes and surface actionable messages.
|
||||
pull_lower = pull_out.lower()
|
||||
if 'not possible to fast-forward' in pull_lower or 'diverged' in pull_lower:
|
||||
if diverged_failure:
|
||||
message_parts = [
|
||||
f'The local {target} repo has commits that are not on the remote '
|
||||
'branch, so a fast-forward update is not possible.'
|
||||
]
|
||||
if restored_note:
|
||||
message_parts.append(restored_note)
|
||||
message_parts.append(
|
||||
'Run: git -C ' + str(path) + ' fetch origin && '
|
||||
'git -C ' + str(path) + ' reset --hard ' + compare_ref
|
||||
)
|
||||
return {
|
||||
'ok': False,
|
||||
'message': (
|
||||
f'The local {target} repo has commits that are not on the remote '
|
||||
'branch, so a fast-forward update is not possible. '
|
||||
'Run: git -C ' + str(path) + ' fetch origin && '
|
||||
'git -C ' + str(path) + ' reset --hard ' + compare_ref
|
||||
),
|
||||
'message': ' '.join(message_parts),
|
||||
'diverged': True,
|
||||
}
|
||||
if 'does not track' in pull_lower or 'no tracking information' in pull_lower:
|
||||
message_parts = [
|
||||
f'The local {target} branch has no upstream tracking branch configured.'
|
||||
]
|
||||
if restored_note:
|
||||
message_parts.append(restored_note)
|
||||
message_parts.append(
|
||||
'Run: git -C ' + str(path) + ' branch --set-upstream-to=' + compare_ref
|
||||
)
|
||||
return {
|
||||
'ok': False,
|
||||
'message': (
|
||||
f'The local {target} branch has no upstream tracking branch configured. '
|
||||
'Run: git -C ' + str(path) + ' branch --set-upstream-to=' + compare_ref
|
||||
),
|
||||
'message': ' '.join(message_parts),
|
||||
}
|
||||
# Generic fallback — include the raw git output for debugging.
|
||||
detail = pull_out.strip()[:300] if pull_out.strip() else '(no output from git)'
|
||||
return {'ok': False, 'message': f'Pull failed: {detail}'}
|
||||
message_parts = [f'Pull failed: {detail}']
|
||||
if restored_note:
|
||||
message_parts.append(restored_note)
|
||||
return {'ok': False, 'message': ' '.join(message_parts)}
|
||||
|
||||
# Pop stash if we stashed
|
||||
# Re-apply stash if we stashed.
|
||||
stash_drop_failed = False
|
||||
if stashed:
|
||||
_, pop_ok = _run_git(['stash', 'pop'], path)
|
||||
if not pop_ok:
|
||||
_, reset_ok = _run_git(['reset', '--merge'], path)
|
||||
_, apply_ok = _run_git(['stash', 'apply'], path)
|
||||
if apply_ok:
|
||||
_, drop_ok = _run_git(['stash', 'drop'], path)
|
||||
stash_drop_failed = not drop_ok
|
||||
else:
|
||||
_, reset_ok = _run_git(['reset', '--hard', 'HEAD'], path)
|
||||
if not reset_ok:
|
||||
return {
|
||||
'ok': False,
|
||||
'message': (
|
||||
'Updated successfully, but failed to clean up a '
|
||||
'stash-pop conflict. Manual intervention needed: '
|
||||
'run git reset --merge in ' + str(path)
|
||||
'stash-apply conflict. Manual intervention needed: '
|
||||
'run git -C ' + str(path) + ' reset --hard HEAD to '
|
||||
'remove conflict markers. Your changes remain in the '
|
||||
'git stash.'
|
||||
),
|
||||
'stash_conflict': True,
|
||||
}
|
||||
with _cache_lock:
|
||||
_update_cache['checked_at'] = 0
|
||||
_schedule_restart()
|
||||
return {
|
||||
'ok': False,
|
||||
'ok': True,
|
||||
'message': (
|
||||
f'{target} updated to the latest version, but your local '
|
||||
'modifications conflict with upstream changes. Your changes '
|
||||
'are preserved in stash@{0}. To re-apply them: '
|
||||
'git -C ' + str(path) + ' stash pop, then resolve conflicts.'
|
||||
f'{target} updated to the latest version. Your local '
|
||||
'modifications conflicted with upstream changes and were '
|
||||
'set aside in a git stash. To inspect: '
|
||||
'git -C ' + str(path) + ' stash show -p. To re-apply: '
|
||||
'git -C ' + str(path) + ' stash apply, then resolve '
|
||||
'conflicts. Drop the stash after you are satisfied.'
|
||||
),
|
||||
'target': target,
|
||||
'restart_scheduled': True,
|
||||
'stash_conflict': True,
|
||||
}
|
||||
|
||||
@@ -1295,10 +1377,16 @@ def _apply_update_inner(target):
|
||||
# setTimeout(() => location.reload(), 1500) on success, so the page reload
|
||||
# and the restart land at roughly the same time.
|
||||
_schedule_restart()
|
||||
message = f'{target} updated successfully'
|
||||
if stash_drop_failed:
|
||||
message += (
|
||||
'. Local modifications were restored, but the temporary stash '
|
||||
'entry may still be present because git stash drop failed.'
|
||||
)
|
||||
|
||||
return {
|
||||
'ok': True,
|
||||
'message': f'{target} updated successfully',
|
||||
'message': message,
|
||||
'target': target,
|
||||
'restart_scheduled': True,
|
||||
}
|
||||
|
||||
+7
-1
@@ -5608,6 +5608,7 @@ async function applyUpdates(){
|
||||
return;
|
||||
}
|
||||
try{
|
||||
const stashConflictMessages=[];
|
||||
for(const target of targets){
|
||||
const res=await api('/api/updates/apply',{method:'POST',body:JSON.stringify({target}),timeoutMs:120000});
|
||||
if(!res.ok){
|
||||
@@ -5615,8 +5616,13 @@ async function applyUpdates(){
|
||||
resetApplyButton(0);
|
||||
return;
|
||||
}
|
||||
if(res.stash_conflict){
|
||||
stashConflictMessages.push('Update applied ('+target+'): '+(res.message||'Local changes were preserved in git stash.'));
|
||||
if(errEl){errEl.textContent=stashConflictMessages.join('\n\n');errEl.style.display='block';}
|
||||
}
|
||||
}
|
||||
showToast('Update applied — restarting…');
|
||||
const stashConflictMessage=stashConflictMessages.join('\n\n');
|
||||
showToast(stashConflictMessage||'Update applied — restarting…',stashConflictMessages.length?10000:undefined,stashConflictMessages.length?'warning':undefined);
|
||||
sessionStorage.removeItem('hermes-update-checked');
|
||||
sessionStorage.removeItem('hermes-update-dismissed');
|
||||
_waitForServerThenReload();
|
||||
|
||||
@@ -31,6 +31,41 @@ def test_update_apply_structured_server_errors_still_use_json_message_path():
|
||||
assert "const msg='Update failed ('+target+'): '+(res.message||'unknown error');" in src
|
||||
|
||||
|
||||
def test_update_apply_successful_stash_conflict_displays_recovery_message():
|
||||
"""ok=True stash conflicts must show the server recovery message before restarting."""
|
||||
src = _ui_js()
|
||||
apply_start = src.index("async function applyUpdates()")
|
||||
next_fn = src.index("function _showUpdateError", apply_start)
|
||||
body = src[apply_start:next_fn]
|
||||
|
||||
messages_decl = body.index("const stashConflictMessages=[];")
|
||||
stash_branch = body.index("if(res.stash_conflict)")
|
||||
message_push = body.index("stashConflictMessages.push('Update applied ('+target+'):", stash_branch)
|
||||
persistent_display = body.index("errEl.textContent=stashConflictMessages.join('\\n\\n')", message_push)
|
||||
message_join = body.index("const stashConflictMessage=stashConflictMessages.join('\\n\\n');", persistent_display)
|
||||
restart_wait = body.index("_waitForServerThenReload();", message_join)
|
||||
|
||||
assert messages_decl < stash_branch < message_push < persistent_display < message_join < restart_wait
|
||||
assert "showToast(stashConflictMessage||'Update applied" in body
|
||||
assert "stashConflictMessages.length?10000" in body
|
||||
|
||||
|
||||
def test_update_apply_multiple_stash_conflicts_are_aggregated_not_overwritten():
|
||||
"""Multiple ok=True stash conflicts must preserve every target recovery message."""
|
||||
src = _ui_js()
|
||||
apply_start = src.index("async function applyUpdates()")
|
||||
next_fn = src.index("function _showUpdateError", apply_start)
|
||||
body = src[apply_start:next_fn]
|
||||
|
||||
assert "let stashConflictMessage='';" not in body
|
||||
assert "stashConflictMessage='Update applied ('+target+'):" not in body
|
||||
assert "const stashConflictMessages=[];" in body
|
||||
assert "stashConflictMessages.push('Update applied ('+target+'): " in body
|
||||
assert "errEl.textContent=stashConflictMessages.join('\\n\\n')" in body
|
||||
assert "const stashConflictMessage=stashConflictMessages.join('\\n\\n');" in body
|
||||
assert "showToast(stashConflictMessage||'Update applied" in body
|
||||
|
||||
|
||||
def test_update_apply_network_error_classifier_ignores_http_status_errors():
|
||||
"""HTTP response errors should not be classified as interrupted transport failures."""
|
||||
src = _ui_js()
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
"""Tests for graceful stash-pop failure recovery in _apply_update_inner."""
|
||||
"""Tests for graceful stash-apply recovery in _apply_update_inner."""
|
||||
from unittest.mock import patch
|
||||
|
||||
import api.updates as updates
|
||||
|
||||
|
||||
def test_stash_pop_conflict_preserves_stash(tmp_path):
|
||||
"""On stash-pop failure, stash is preserved and no restart is scheduled."""
|
||||
def test_stash_apply_conflict_preserves_stash(tmp_path):
|
||||
"""On stash-apply conflict, stash is preserved and restart is scheduled."""
|
||||
call_log = []
|
||||
|
||||
def fake_git(args, path, timeout=10):
|
||||
@@ -14,13 +14,13 @@ def test_stash_pop_conflict_preserves_stash(tmp_path):
|
||||
return '', True
|
||||
if args == ['status', '--porcelain', '--untracked-files=no']:
|
||||
return 'M modified_file.py', True
|
||||
if args == ['stash']:
|
||||
if args == ['stash', 'push', '-m', 'hermes-update-autostash']:
|
||||
return '', True
|
||||
if args[:2] == ['pull', '--ff-only']:
|
||||
return 'Already up to date.', True
|
||||
if args == ['stash', 'pop']:
|
||||
if args == ['stash', 'apply']:
|
||||
return 'CONFLICT (content): Merge conflict in modified_file.py', False
|
||||
if args == ['reset', '--merge']:
|
||||
if args == ['reset', '--hard', 'HEAD']:
|
||||
return '', True
|
||||
raise AssertionError(f'unexpected git args: {args!r}')
|
||||
|
||||
@@ -33,16 +33,17 @@ def test_stash_pop_conflict_preserves_stash(tmp_path):
|
||||
):
|
||||
result = updates._apply_update_inner('webui')
|
||||
|
||||
assert result['ok'] is False
|
||||
assert result['ok'] is True
|
||||
assert result['stash_conflict'] is True
|
||||
assert 'stash@{0}' in result['message']
|
||||
assert 'git stash' in result['message']
|
||||
assert ['stash', 'apply'] in call_log
|
||||
assert ['stash', 'drop'] not in call_log
|
||||
assert ['reset', '--merge'] in call_log
|
||||
assert len(restart_calls) == 0
|
||||
assert ['reset', '--hard', 'HEAD'] in call_log
|
||||
assert len(restart_calls) == 1
|
||||
|
||||
|
||||
def test_stash_pop_reset_failure_returns_error(tmp_path):
|
||||
"""If reset --merge also fails, return ok=False so the app does not restart into a broken tree."""
|
||||
def test_stash_apply_reset_failure_returns_error(tmp_path):
|
||||
"""If reset cleanup fails, return ok=False and do not restart into a broken tree."""
|
||||
call_log = []
|
||||
|
||||
def fake_git(args, path, timeout=10):
|
||||
@@ -51,13 +52,13 @@ def test_stash_pop_reset_failure_returns_error(tmp_path):
|
||||
return '', True
|
||||
if args == ['status', '--porcelain', '--untracked-files=no']:
|
||||
return 'M modified_file.py', True
|
||||
if args == ['stash']:
|
||||
if args == ['stash', 'push', '-m', 'hermes-update-autostash']:
|
||||
return '', True
|
||||
if args[:2] == ['pull', '--ff-only']:
|
||||
return 'Already up to date.', True
|
||||
if args == ['stash', 'pop']:
|
||||
if args == ['stash', 'apply']:
|
||||
return 'CONFLICT', False
|
||||
if args == ['reset', '--merge']:
|
||||
if args == ['reset', '--hard', 'HEAD']:
|
||||
return 'error: could not reset', False
|
||||
raise AssertionError(f'unexpected git args: {args!r}')
|
||||
|
||||
@@ -73,13 +74,15 @@ def test_stash_pop_reset_failure_returns_error(tmp_path):
|
||||
assert result['ok'] is False
|
||||
assert result['stash_conflict'] is True
|
||||
assert 'Manual intervention' in result['message']
|
||||
assert 'reset --hard HEAD' in result['message']
|
||||
assert 'stash drop' not in result['message']
|
||||
assert len(restart_calls) == 0
|
||||
assert ['reset', '--hard', 'HEAD'] in call_log
|
||||
assert ['stash', 'drop'] not in call_log
|
||||
|
||||
|
||||
def test_stash_pop_success_still_restarts(tmp_path):
|
||||
"""Happy path: stash pop succeeds, restart is scheduled."""
|
||||
def test_stash_apply_success_drops_and_restarts(tmp_path):
|
||||
"""Happy path: stash apply succeeds, stash is dropped, and restart is scheduled."""
|
||||
call_log = []
|
||||
|
||||
def fake_git(args, path, timeout=10):
|
||||
@@ -88,11 +91,13 @@ def test_stash_pop_success_still_restarts(tmp_path):
|
||||
return '', True
|
||||
if args == ['status', '--porcelain', '--untracked-files=no']:
|
||||
return 'M modified_file.py', True
|
||||
if args == ['stash']:
|
||||
if args == ['stash', 'push', '-m', 'hermes-update-autostash']:
|
||||
return '', True
|
||||
if args[:2] == ['pull', '--ff-only']:
|
||||
return 'Already up to date.', True
|
||||
if args == ['stash', 'pop']:
|
||||
if args == ['stash', 'apply']:
|
||||
return '', True
|
||||
if args == ['stash', 'drop']:
|
||||
return '', True
|
||||
raise AssertionError(f'unexpected git args: {args!r}')
|
||||
|
||||
@@ -107,4 +112,260 @@ def test_stash_pop_success_still_restarts(tmp_path):
|
||||
|
||||
assert result['ok'] is True
|
||||
assert 'stash_conflict' not in result
|
||||
assert ['stash', 'apply'] in call_log
|
||||
assert ['stash', 'drop'] in call_log
|
||||
assert len(restart_calls) == 1
|
||||
|
||||
|
||||
def test_stash_apply_success_discloses_drop_failure(tmp_path):
|
||||
"""If stash drop fails after a successful update, disclose the leftover entry."""
|
||||
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 'M modified_file.py', True
|
||||
if args == ['stash', 'push', '-m', 'hermes-update-autostash']:
|
||||
return '', True
|
||||
if args[:2] == ['pull', '--ff-only']:
|
||||
return 'Already up to date.', True
|
||||
if args == ['stash', 'apply']:
|
||||
return '', True
|
||||
if args == ['stash', 'drop']:
|
||||
return 'error: could not drop stash', False
|
||||
raise AssertionError(f'unexpected git args: {args!r}')
|
||||
|
||||
restart_calls = []
|
||||
|
||||
with (
|
||||
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 True
|
||||
assert 'temporary stash entry may still be present' in result['message']
|
||||
assert ['stash', 'drop'] in call_log
|
||||
assert len(restart_calls) == 1
|
||||
|
||||
|
||||
def test_pull_failure_stash_apply_recovery(tmp_path):
|
||||
"""If pull fails after stashing, apply restores changes and successful apply drops the stash."""
|
||||
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 'M modified_file.py', True
|
||||
if args == ['stash', 'push', '-m', 'hermes-update-autostash']:
|
||||
return '', True
|
||||
if args[:2] == ['pull', '--ff-only']:
|
||||
return 'Some unrecognized git error', False
|
||||
if args == ['stash', 'apply']:
|
||||
return '', True
|
||||
if args == ['stash', 'drop']:
|
||||
return '', True
|
||||
raise AssertionError(f'unexpected git args: {args!r}')
|
||||
|
||||
restart_calls = []
|
||||
|
||||
with (
|
||||
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['message'].startswith('Pull failed:')
|
||||
assert 'Local webui modifications were restored to the working tree' in result['message']
|
||||
assert ['stash', 'apply'] in call_log
|
||||
assert ['stash', 'drop'] in call_log
|
||||
assert ['stash', 'pop'] not in call_log
|
||||
assert len(restart_calls) == 0
|
||||
|
||||
|
||||
def test_pull_failure_stash_apply_recovery_discloses_drop_failure(tmp_path):
|
||||
"""If pull fails and stash drop fails after restore, disclose the leftover entry."""
|
||||
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 'M modified_file.py', True
|
||||
if args == ['stash', 'push', '-m', 'hermes-update-autostash']:
|
||||
return '', True
|
||||
if args[:2] == ['pull', '--ff-only']:
|
||||
return 'Some unrecognized git error', False
|
||||
if args == ['stash', 'apply']:
|
||||
return '', True
|
||||
if args == ['stash', 'drop']:
|
||||
return 'error: could not drop stash', False
|
||||
raise AssertionError(f'unexpected git args: {args!r}')
|
||||
|
||||
with (
|
||||
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'),
|
||||
):
|
||||
result = updates._apply_update_inner('webui')
|
||||
|
||||
assert result['ok'] is False
|
||||
assert 'Local webui modifications were restored to the working tree' in result['message']
|
||||
assert 'temporary stash entry may still be present' in result['message']
|
||||
assert ['stash', 'drop'] in call_log
|
||||
|
||||
|
||||
def test_pull_failure_stash_apply_recovery_warns_before_diverged_reset(tmp_path):
|
||||
"""Diverged recovery must warn when local changes were restored before reset advice."""
|
||||
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 'M modified_file.py', True
|
||||
if args == ['stash', 'push', '-m', 'hermes-update-autostash']:
|
||||
return '', True
|
||||
if args[:2] == ['pull', '--ff-only']:
|
||||
return 'Not possible to fast-forward, aborting.', False
|
||||
if args == ['stash', 'apply']:
|
||||
return '', True
|
||||
if args == ['stash', 'drop']:
|
||||
return '', True
|
||||
raise AssertionError(f'unexpected git args: {args!r}')
|
||||
|
||||
with (
|
||||
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'),
|
||||
):
|
||||
result = updates._apply_update_inner('webui')
|
||||
|
||||
assert result['ok'] is False
|
||||
assert result['diverged'] is True
|
||||
assert 'Local webui modifications were restored to the working tree' in result['message']
|
||||
assert 'save or stash them before running destructive recovery commands' in result['message']
|
||||
assert result['message'].index('save or stash') < result['message'].index('reset --hard')
|
||||
assert ['stash', 'drop'] in call_log
|
||||
|
||||
|
||||
def test_pull_failure_stash_apply_conflict_cleans_worktree(tmp_path):
|
||||
"""If restoring local changes conflicts after pull failure, clean markers and preserve stash."""
|
||||
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 'M modified_file.py', True
|
||||
if args == ['stash', 'push', '-m', 'hermes-update-autostash']:
|
||||
return '', True
|
||||
if args[:2] == ['pull', '--ff-only']:
|
||||
return 'Some unrecognized git error', False
|
||||
if args == ['stash', 'apply']:
|
||||
return 'CONFLICT (content): Merge conflict in modified_file.py', False
|
||||
if args == ['reset', '--hard', 'HEAD']:
|
||||
return '', True
|
||||
raise AssertionError(f'unexpected git args: {args!r}')
|
||||
|
||||
restart_calls = []
|
||||
|
||||
with (
|
||||
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['stash_conflict'] is True
|
||||
assert result['message'].startswith('Pull failed, and your local webui modifications conflicted')
|
||||
assert 'index and tracked files were restored to HEAD' in result['message']
|
||||
assert 'Pull error: Some unrecognized git error' in result['message']
|
||||
assert ['stash', 'apply'] in call_log
|
||||
assert ['reset', '--hard', 'HEAD'] in call_log
|
||||
assert ['stash', 'drop'] not in call_log
|
||||
assert len(restart_calls) == 0
|
||||
|
||||
|
||||
def test_pull_failure_stash_apply_conflict_preserves_diverged_flag(tmp_path):
|
||||
"""A combined restore conflict must not hide the force-update affordance."""
|
||||
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 'M modified_file.py', True
|
||||
if args == ['stash', 'push', '-m', 'hermes-update-autostash']:
|
||||
return '', True
|
||||
if args[:2] == ['pull', '--ff-only']:
|
||||
return 'Not possible to fast-forward, aborting.', False
|
||||
if args == ['stash', 'apply']:
|
||||
return 'CONFLICT (content): Merge conflict in modified_file.py', False
|
||||
if args == ['reset', '--hard', 'HEAD']:
|
||||
return '', True
|
||||
raise AssertionError(f'unexpected git args: {args!r}')
|
||||
|
||||
with (
|
||||
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'),
|
||||
):
|
||||
result = updates._apply_update_inner('webui')
|
||||
|
||||
assert result['ok'] is False
|
||||
assert result['stash_conflict'] is True
|
||||
assert result['diverged'] is True
|
||||
assert ['reset', '--hard', 'HEAD'] in call_log
|
||||
|
||||
|
||||
def test_pull_failure_stash_apply_conflict_reset_failure_returns_error(tmp_path):
|
||||
"""If pull-failure rollback cleanup fails, return an explicit manual recovery error."""
|
||||
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 'M modified_file.py', True
|
||||
if args == ['stash', 'push', '-m', 'hermes-update-autostash']:
|
||||
return '', True
|
||||
if args[:2] == ['pull', '--ff-only']:
|
||||
return 'Some unrecognized git error', False
|
||||
if args == ['stash', 'apply']:
|
||||
return 'CONFLICT (content): Merge conflict in modified_file.py', False
|
||||
if args == ['reset', '--hard', 'HEAD']:
|
||||
return 'error: could not reset', False
|
||||
raise AssertionError(f'unexpected git args: {args!r}')
|
||||
|
||||
restart_calls = []
|
||||
|
||||
with (
|
||||
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['stash_conflict'] is True
|
||||
assert 'Manual intervention needed' in result['message']
|
||||
assert 'reset --hard HEAD' in result['message']
|
||||
assert 'Pull error: Some unrecognized git error' in result['message']
|
||||
assert ['stash', 'apply'] in call_log
|
||||
assert ['reset', '--hard', 'HEAD'] in call_log
|
||||
assert ['stash', 'drop'] not in call_log
|
||||
assert len(restart_calls) == 0
|
||||
|
||||
Reference in New Issue
Block a user