Skip to content

Commit b48e964

Browse files
committed
fix: use rename-then-move to replace exe on Windows
1 parent 45cc666 commit b48e964

4 files changed

Lines changed: 111 additions & 143 deletions

File tree

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,11 @@ All notable changes to Shello CLI will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [0.7.1] - 2026-03-23
9+
10+
### Fixed
11+
- Update command: replace running exe via rename-then-move on Windows to avoid `[Errno 13] Permission denied` on locked executables
12+
813
## [0.7.0] - 2026-03-23
914

1015
### Fixed

shello_cli/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
11
"""Shello CLI
22
AI Assistant with Command Execution"""
33

4-
__version__ = "0.7.0"
4+
__version__ = "0.7.1"

shello_cli/update/executable_updater.py

Lines changed: 30 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -117,48 +117,52 @@ def replace_executable(self, new_binary_path: str, target_path: str) -> None:
117117
Raises:
118118
UpdateError: If replacement fails
119119
"""
120-
backup_path = f"{target_path}.backup"
120+
old_path = f"{target_path}.old"
121121
new_binary_moved = False
122-
122+
123123
try:
124-
# Step 1: Create backup of current executable
125-
if os.path.exists(target_path):
126-
shutil.copy2(target_path, backup_path)
127-
128-
# Step 2: Replace executable with new binary
124+
# Step 1: On Windows, rename the running exe out of the way first.
125+
# Windows locks running executables against overwrite but allows rename.
126+
if os.name == 'nt':
127+
if os.path.exists(old_path):
128+
os.remove(old_path)
129+
if os.path.exists(target_path):
130+
os.rename(target_path, old_path)
131+
132+
# Step 2: Move new binary into place
129133
shutil.move(new_binary_path, target_path)
130134
new_binary_moved = True
131-
135+
132136
# Step 3: Set executable permissions on Unix systems
133-
if os.name != 'nt': # Not Windows
137+
if os.name != 'nt':
134138
current_permissions = os.stat(target_path).st_mode
135139
os.chmod(target_path, current_permissions | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
136-
137-
# Step 4: Remove backup on success
138-
if os.path.exists(backup_path):
139-
os.remove(backup_path)
140-
140+
141+
# Step 4: Remove old binary on success (best effort — may still be locked)
142+
if os.path.exists(old_path):
143+
try:
144+
os.remove(old_path)
145+
except Exception:
146+
pass # Will be cleaned up on next run
147+
141148
except Exception as e:
142-
# Restore from backup on failure
143-
if os.path.exists(backup_path):
149+
# Restore old binary on failure
150+
if os.name == 'nt' and os.path.exists(old_path):
144151
try:
145-
# Only restore if we successfully moved the new binary
146-
if new_binary_moved:
147-
shutil.move(backup_path, target_path)
148-
else:
149-
# If move failed, just remove the backup
150-
os.remove(backup_path)
152+
if new_binary_moved and os.path.exists(target_path):
153+
os.remove(target_path)
154+
os.rename(old_path, target_path)
151155
except Exception as restore_error:
152156
raise UpdateError(
153157
f"Failed to replace executable and restore backup: {e}. "
154158
f"Restore error: {restore_error}"
155159
)
156-
157-
# Clean up new binary if it exists and wasn't moved
160+
161+
# Clean up new binary if it wasn't moved
158162
if not new_binary_moved and os.path.exists(new_binary_path):
159163
try:
160164
os.remove(new_binary_path)
161165
except Exception:
162-
pass # Best effort cleanup
163-
166+
pass
167+
164168
raise UpdateError(f"Failed to replace executable: {e}")

tests/test_executable_updater.py

Lines changed: 75 additions & 116 deletions
Original file line numberDiff line numberDiff line change
@@ -173,190 +173,149 @@ def test_verify_binary_nonexistent_file(self):
173173
def test_replace_executable_success(self):
174174
"""Test successful executable replacement."""
175175
updater = ExecutableUpdater("test-owner", "test-repo")
176-
177-
# Create temporary files
176+
178177
with tempfile.NamedTemporaryFile(delete=False, mode='w') as old_file:
179178
old_file.write("old executable")
180179
old_path = old_file.name
181-
180+
182181
with tempfile.NamedTemporaryFile(delete=False, mode='w') as new_file:
183182
new_file.write("new executable")
184183
new_path = new_file.name
185-
186-
backup_path = f"{old_path}.backup"
187-
184+
185+
old_bak = f"{old_path}.old"
186+
188187
try:
189-
# Replace executable
190188
updater.replace_executable(new_path, old_path)
191-
192-
# Verify new content
189+
193190
with open(old_path, 'r') as f:
194191
content = f.read()
195192
assert content == "new executable"
196-
197-
# Verify backup was removed
198-
assert not os.path.exists(backup_path)
199-
200-
# Verify new binary was moved (not copied)
193+
194+
# .old file cleaned up on success
195+
assert not os.path.exists(old_bak)
196+
# new binary was moved, not copied
201197
assert not os.path.exists(new_path)
202-
198+
203199
finally:
204-
# Cleanup
205-
if os.path.exists(old_path):
206-
os.remove(old_path)
207-
if os.path.exists(new_path):
208-
os.remove(new_path)
209-
if os.path.exists(backup_path):
210-
os.remove(backup_path)
211-
212-
def test_replace_executable_creates_backup(self):
213-
"""Test that backup is created before replacement."""
200+
for p in [old_path, new_path, old_bak]:
201+
if os.path.exists(p):
202+
os.remove(p)
203+
204+
def test_replace_executable_uses_rename_on_windows(self):
205+
"""On Windows the old exe is renamed before placing the new one (avoids lock)."""
214206
updater = ExecutableUpdater("test-owner", "test-repo")
215-
216-
# Create temporary files
207+
217208
with tempfile.NamedTemporaryFile(delete=False, mode='w') as old_file:
218209
old_file.write("old executable")
219210
old_path = old_file.name
220-
211+
221212
with tempfile.NamedTemporaryFile(delete=False, mode='w') as new_file:
222213
new_file.write("new executable")
223214
new_path = new_file.name
224-
225-
backup_path = f"{old_path}.backup"
226-
227-
# Patch shutil.copy2 to verify backup is created
228-
original_copy2 = __import__('shutil').copy2
229-
copy2_called = [False]
230-
231-
def mock_copy2(src, dst):
232-
copy2_called[0] = True
233-
return original_copy2(src, dst)
234-
215+
216+
old_bak = f"{old_path}.old"
217+
rename_calls = []
218+
original_rename = os.rename
219+
220+
def tracking_rename(src, dst):
221+
rename_calls.append((src, dst))
222+
return original_rename(src, dst)
223+
235224
try:
236-
with patch('shutil.copy2', side_effect=mock_copy2):
225+
with patch('os.name', 'nt'), patch('os.rename', side_effect=tracking_rename):
237226
updater.replace_executable(new_path, old_path)
238-
239-
# Verify backup was created during the process
240-
assert copy2_called[0], "Backup creation (copy2) was not called"
241-
242-
# Verify final state: new content in place, backup removed
227+
228+
# rename should have been called to move old exe out of the way
229+
assert any(dst == old_bak for _, dst in rename_calls), \
230+
"Expected old exe to be renamed to .old before replacement"
231+
243232
with open(old_path, 'r') as f:
244-
content = f.read()
245-
assert content == "new executable"
246-
assert not os.path.exists(backup_path)
247-
233+
assert f.read() == "new executable"
234+
248235
finally:
249-
# Cleanup
250-
if os.path.exists(old_path):
251-
os.remove(old_path)
252-
if os.path.exists(new_path):
253-
os.remove(new_path)
254-
if os.path.exists(backup_path):
255-
os.remove(backup_path)
236+
for p in [old_path, new_path, old_bak]:
237+
if os.path.exists(p):
238+
os.remove(p)
256239

257240
def test_replace_executable_restores_on_failure(self):
258-
"""Test that backup is restored when replacement fails."""
241+
"""Test that old exe is restored when replacement fails."""
259242
updater = ExecutableUpdater("test-owner", "test-repo")
260-
261-
# Create temporary files
243+
262244
with tempfile.NamedTemporaryFile(delete=False, mode='w') as old_file:
263245
old_file.write("original content")
264246
old_path = old_file.name
265-
247+
266248
with tempfile.NamedTemporaryFile(delete=False, mode='w') as new_file:
267249
new_file.write("new content")
268250
new_path = new_file.name
269-
270-
backup_path = f"{old_path}.backup"
271-
251+
252+
old_bak = f"{old_path}.old"
253+
272254
try:
273-
# Patch shutil.move to fail during replacement
274-
with patch('shutil.move', side_effect=OSError("Simulated disk full")):
255+
with patch('os.name', 'nt'), patch('shutil.move', side_effect=OSError("Simulated disk full")):
275256
with pytest.raises(UpdateError) as exc_info:
276257
updater.replace_executable(new_path, old_path)
277-
258+
278259
assert "Failed to replace executable" in str(exc_info.value)
279-
280-
# Verify original file still exists (backup was removed after restore)
260+
261+
# Original file should be restored
281262
assert os.path.exists(old_path)
282263
with open(old_path, 'r') as f:
283-
content = f.read()
284-
assert content == "original content"
285-
286-
# Verify backup was removed after restore
287-
assert not os.path.exists(backup_path)
288-
264+
assert f.read() == "original content"
265+
266+
assert not os.path.exists(old_bak)
267+
289268
finally:
290-
# Cleanup
291-
if os.path.exists(old_path):
292-
os.remove(old_path)
293-
if os.path.exists(new_path):
294-
os.remove(new_path)
295-
if os.path.exists(backup_path):
296-
os.remove(backup_path)
269+
for p in [old_path, new_path, old_bak]:
270+
if os.path.exists(p):
271+
os.remove(p)
297272

298273
@patch('os.name', 'posix')
299274
@patch('os.chmod')
300275
def test_replace_executable_sets_permissions_unix(self, mock_chmod):
301276
"""Test that executable permissions are set on Unix systems."""
302277
updater = ExecutableUpdater("test-owner", "test-repo")
303-
304-
# Create temporary files
278+
305279
with tempfile.NamedTemporaryFile(delete=False, mode='w') as old_file:
306280
old_file.write("old")
307281
old_path = old_file.name
308-
282+
309283
with tempfile.NamedTemporaryFile(delete=False, mode='w') as new_file:
310284
new_file.write("new")
311285
new_path = new_file.name
312-
286+
313287
try:
314288
updater.replace_executable(new_path, old_path)
315-
316-
# Verify chmod was called
317289
assert mock_chmod.called
318-
290+
319291
finally:
320-
# Cleanup
321-
if os.path.exists(old_path):
322-
os.remove(old_path)
323-
if os.path.exists(new_path):
324-
os.remove(new_path)
325-
if os.path.exists(f"{old_path}.backup"):
326-
os.remove(f"{old_path}.backup")
292+
for p in [old_path, new_path, f"{old_path}.old"]:
293+
if os.path.exists(p):
294+
os.remove(p)
327295

328296
def test_replace_executable_cleans_up_on_failure(self):
329297
"""Test that temporary files are cleaned up when replacement fails."""
330298
updater = ExecutableUpdater("test-owner", "test-repo")
331-
332-
# Create temporary files
299+
333300
with tempfile.NamedTemporaryFile(delete=False, mode='w') as old_file:
334301
old_file.write("old")
335302
old_path = old_file.name
336-
303+
337304
with tempfile.NamedTemporaryFile(delete=False, mode='w') as new_file:
338305
new_file.write("new")
339306
new_path = new_file.name
340-
341-
backup_path = f"{old_path}.backup"
342-
307+
308+
old_bak = f"{old_path}.old"
309+
343310
try:
344-
# Force failure during replacement
345-
with patch('shutil.move', side_effect=OSError("Disk full")):
311+
with patch('os.name', 'nt'), patch('shutil.move', side_effect=OSError("Disk full")):
346312
with pytest.raises(UpdateError):
347313
updater.replace_executable(new_path, old_path)
348-
349-
# Verify cleanup: backup should be removed after restore
350-
assert not os.path.exists(backup_path)
351-
352-
# Original file should be restored
314+
315+
assert not os.path.exists(old_bak)
353316
assert os.path.exists(old_path)
354-
317+
355318
finally:
356-
# Cleanup
357-
if os.path.exists(old_path):
358-
os.remove(old_path)
359-
if os.path.exists(new_path):
360-
os.remove(new_path)
361-
if os.path.exists(backup_path):
362-
os.remove(backup_path)
319+
for p in [old_path, new_path, old_bak]:
320+
if os.path.exists(p):
321+
os.remove(p)

0 commit comments

Comments
 (0)