Release XG (v0.51.651): force update non-fatal on undeletable untracked files (#4914)

Release XG (v0.51.651): force update non-fatal on undeletable untracked files (#4914)
This commit is contained in:
nesquena-hermes 2026-06-25 04:26:05 -07:00 committed by GitHub
commit 56931500a7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 83 additions and 12 deletions

View file

@ -3,6 +3,12 @@
## [Unreleased]
## [v0.51.651] — 2026-06-25 — Release XG (force update no longer aborts on undeletable Windows files)
### Fixed
- **Force update no longer fails when the working tree contains a file git can't delete (Windows reserved device names).** On Windows, a file named after a reserved device (`nul`, `con`, `prn`, `aux`, `com1`–`com9`, `lpt1`–`lpt9`) can end up in the working tree — for example when a shell command redirects to `> nul` under Git Bash, which treats it as a literal filename — and git cannot remove it through the normal Win32 path. That made `git clean -fd` exit non-zero, and the force-update flow treated it as fatal ("Failed to remove untracked files before force reset"), leaving the user permanently unable to force-update. The clean step is now best-effort: a failure is logged for diagnostics but no longer aborts, because the subsequent `git reset --hard` applies the update regardless (overwriting any tracked-file collisions, and the residual undeletable untracked file is harmless). A genuine reset failure still aborts as before. Thanks @rodboev for the report and root-cause. (#4914)
## [v0.51.650] — 2026-06-25 — Release XF (table cells with a pipe inside inline code render correctly)
### Fixed

View file

@ -1292,12 +1292,23 @@ def apply_force_update(target: str) -> dict:
# 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)
# Best-effort clean: a `git clean -fd` failure is NOT fatal. The
# following `reset --hard` overwrites any tracked-file collisions
# regardless, and residual untracked files that git can't delete are
# harmless. In particular, on Windows a file named after a reserved
# device name (nul, con, prn, aux, com1-9, lpt1-9) — which can appear
# in the working tree when a shell command redirects to `> nul` under
# Git Bash — cannot be removed via the normal Win32 path that git uses,
# so `clean` exits non-zero. Aborting the whole force update over that
# left users stuck (issue #4914). Log the stderr for diagnostics and
# proceed to the reset, which is what actually applies the update.
clean_out, clean_ok = _run_git(['clean', '-fd'], path)
if not clean_ok:
return {
'ok': False,
'message': 'Failed to remove untracked files before force reset',
}
logger.warning(
'force_apply_update: `git clean -fd` failed (non-fatal, '
'continuing to reset --hard): %s',
clean_out,
)
_, ok = _run_git(['reset', '--hard', compare_ref], path)
if not ok:
return {'ok': False, 'message': f'Force reset to {compare_ref} failed'}

View file

@ -736,6 +736,53 @@ class TestApplyForceUpdate:
assert 'reset' in git_cmds, "force update must call git reset --hard"
assert 'checkout' in git_cmds, "force update must call git checkout . to clear conflicts"
def test_apply_force_update_proceeds_when_clean_fails(self, tmp_path, monkeypatch):
"""#4914 — a `git clean -fd` failure must NOT abort the force update.
On Windows a reserved-device-name file (nul/con/prn/aux/com1-9/lpt1-9)
can land in the working tree (e.g. `> nul` under Git Bash) and git can't
delete it, so `clean -fd` exits non-zero. The reset --hard still applies
the update, so clean failure must be non-fatal.
"""
import api.updates as upd
(tmp_path / '.git').mkdir()
ran = []
def fake_run(args, cwd, timeout=10):
ran.append(args)
if args[0] == 'fetch':
return '', True
if args[:2] == ['rev-parse', '--abbrev-ref']:
return 'origin/master', True
if args[0] == 'checkout':
return '', True
if args[0] == 'clean':
# Simulate the Windows reserved-name failure.
return "warning: failed to remove nul: Invalid argument", False
if args[0] == 'reset':
return '', True
return '', True
monkeypatch.setattr(upd, '_run_git', fake_run)
monkeypatch.setattr(upd, 'REPO_ROOT', tmp_path)
monkeypatch.setattr(upd, '_AGENT_DIR', tmp_path)
monkeypatch.setattr(upd, '_schedule_restart', lambda delay=2.0: None)
result = upd.apply_force_update('webui')
# Clean failed, but reset --hard succeeded → the force update must STILL
# succeed (clean failure is non-fatal, #4914).
assert result['ok'] is True, (
f"force update must not abort on git clean failure (#4914): {result}"
)
assert result.get('restart_scheduled') is True
git_cmds = [r[0] for r in ran]
assert 'clean' in git_cmds, "force update should still attempt git clean"
assert 'reset' in git_cmds, (
"force update must proceed to git reset --hard even after clean failed"
)
def test_apply_force_update_rejects_unknown_target(self, tmp_path, monkeypatch):
import api.updates as upd
monkeypatch.setattr(upd, 'REPO_ROOT', tmp_path)

View file

@ -79,8 +79,11 @@ def test_apply_force_update_removes_untracked_files_before_reset(tmp_path):
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."""
def test_apply_force_update_proceeds_when_clean_fails(tmp_path):
"""A failed `git clean -fd` is NON-FATAL: the reset --hard still applies the
update (#4914). On Windows a reserved-device-name file (nul/con/prn/…) can
land in the tree and git can't delete it, so `clean` exits non-zero — but
that residue is harmless and must not block the force update."""
(tmp_path / '.git').mkdir()
call_log = []
@ -91,7 +94,9 @@ def test_apply_force_update_stops_when_clean_fails(tmp_path):
if args == ['checkout', '.']:
return '', True
if args == ['clean', '-fd']:
return 'permission denied', False
return 'warning: failed to remove nul: Invalid argument', False
if args == ['reset', '--hard', 'origin/master']:
return '', True
raise AssertionError(f'unexpected git args: {args!r}')
restart_calls = []
@ -104,11 +109,13 @@ def test_apply_force_update_stops_when_clean_fails(tmp_path):
):
result = updates.apply_force_update('webui')
assert result['ok'] is False
assert 'untracked files' in result['message']
# Clean failed, but reset --hard succeeded → force update must SUCCEED.
assert result['ok'] is True, result
assert ['clean', '-fd'] in call_log
assert ['reset', '--hard', 'origin/master'] not in call_log
assert len(restart_calls) == 0
assert ['reset', '--hard', 'origin/master'] in call_log, (
'reset --hard must still run even though clean -fd failed (#4914)'
)
assert len(restart_calls) == 1
def test_stash_apply_conflict_preserves_stash(tmp_path):