Release v0.51.272 — Release IN (stage-p3a — conflict-safe self-update recovery #3667) (#3678)

* 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:
nesquena-hermes
2026-06-05 12:28:31 -07:00
committed by GitHub
parent 84baa27004
commit 405c38b7d2
5 changed files with 443 additions and 48 deletions
+5
View File
@@ -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
View File
@@ -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
View File
@@ -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();
+35
View File
@@ -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()
+280 -19
View File
@@ -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