diff --git a/CHANGELOG.md b/CHANGELOG.md index 8540b1f3..9c43d3a9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/api/updates.py b/api/updates.py index f8ed3dab..5c1c0f70 100644 --- a/api/updates.py +++ b/api/updates.py @@ -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'} diff --git a/tests/test_update_banner_fixes.py b/tests/test_update_banner_fixes.py index a86c2a9c..39de83ae 100644 --- a/tests/test_update_banner_fixes.py +++ b/tests/test_update_banner_fixes.py @@ -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) diff --git a/tests/test_update_stash_recovery.py b/tests/test_update_stash_recovery.py index 6047e19f..76b2be5a 100644 --- a/tests/test_update_stash_recovery.py +++ b/tests/test_update_stash_recovery.py @@ -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):