fix: prevent data loss in skills sync on copy/update failure
Two bugs in sync_skills(): 1. Failed copytree poisons manifest: when shutil.copytree fails (disk full, permission error), the skill is still recorded in the manifest. On the next sync, the skill appears as "in manifest but not on disk" which is interpreted as "user deliberately deleted it" — the skill is never retried. Fix: only write to manifest on successful copy. 2. Failed update destroys user copy: rmtree deletes the existing skill directory before copytree runs. If copytree then fails, the user's skill is gone with no way to recover. Fix: move to .bak before copying, restore from backup if copytree fails. Both bugs are proven by new regression tests that fail on the old code and pass on the fix.
This commit is contained in:
@@ -153,16 +153,18 @@ def sync_skills(quiet: bool = False) -> dict:
|
||||
if dest.exists():
|
||||
# User already has a skill with the same name — don't overwrite
|
||||
skipped += 1
|
||||
manifest[skill_name] = bundled_hash
|
||||
else:
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copytree(skill_src, dest)
|
||||
copied.append(skill_name)
|
||||
manifest[skill_name] = bundled_hash
|
||||
if not quiet:
|
||||
print(f" + {skill_name}")
|
||||
except (OSError, IOError) as e:
|
||||
if not quiet:
|
||||
print(f" ! Failed to copy {skill_name}: {e}")
|
||||
manifest[skill_name] = bundled_hash
|
||||
# Do NOT add to manifest — next sync should retry
|
||||
|
||||
elif dest.exists():
|
||||
# ── Existing skill — in manifest AND on disk ──
|
||||
@@ -190,12 +192,22 @@ def sync_skills(quiet: bool = False) -> dict:
|
||||
# User copy matches origin — check if bundled has a newer version
|
||||
if bundled_hash != origin_hash:
|
||||
try:
|
||||
shutil.rmtree(dest)
|
||||
shutil.copytree(skill_src, dest)
|
||||
manifest[skill_name] = bundled_hash
|
||||
updated.append(skill_name)
|
||||
if not quiet:
|
||||
print(f" ↑ {skill_name} (updated)")
|
||||
# Move old copy to a backup so we can restore on failure
|
||||
backup = dest.with_suffix(".bak")
|
||||
shutil.move(str(dest), str(backup))
|
||||
try:
|
||||
shutil.copytree(skill_src, dest)
|
||||
manifest[skill_name] = bundled_hash
|
||||
updated.append(skill_name)
|
||||
if not quiet:
|
||||
print(f" ↑ {skill_name} (updated)")
|
||||
# Remove backup after successful copy
|
||||
shutil.rmtree(backup, ignore_errors=True)
|
||||
except (OSError, IOError):
|
||||
# Restore from backup
|
||||
if backup.exists() and not dest.exists():
|
||||
shutil.move(str(backup), str(dest))
|
||||
raise
|
||||
except (OSError, IOError) as e:
|
||||
if not quiet:
|
||||
print(f" ! Failed to update {skill_name}: {e}")
|
||||
|
||||
Reference in New Issue
Block a user