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:
commit
56931500a7
4 changed files with 83 additions and 12 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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'}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
Loading…
Reference in a new issue