fix(store): do not pull what the gate cannot un-pull

Review of the gate found a data-loss path it had introduced, plus two smaller
scope errors. All three from CodeRabbit on #508.

**The stash failure was load-bearing and was not treated as one.** update_plugin
stashes local changes before pulling; when that stash failed or timed out it
logged a warning and pulled anyway. That was harmless while nothing ever undid
a pull. It is not harmless now: the gate's rollback is `git reset --hard`, which
discards uncommitted tracked edits -- exactly the edits the stash existed to
protect. A pull does not refuse on a dirty tree as long as the incoming commit
touches other files, so the sequence completed silently: pull succeeds, gate
refuses, reset takes the user's work with it.

update_plugin now returns before pulling unless the tree was already clean or
was successfully stashed. Refusing costs an update in a case that had already
gone wrong; the alternative costs data. That also makes `--hard` safe by
construction in _gate_pulled_commit, and its comment now says so rather than
observing it in passing.

Pinned by test_a_failed_stash_stops_the_update_before_pulling, which writes a
local edit, forces the stash to fail, and asserts both that HEAD did not move
and that the edit is still on disk. Verified it bites: with the new guard
removed the file comes back as `class P: pass`, the edit gone.

**_HAS_GIT could take the module down instead of skipping it.** With no git on
PATH, subprocess.run raises FileNotFoundError, and this runs at import time --
before skipif can act, so the whole file errors rather than skipping. Now
catches OSError.

**The doc overclaimed the gate's reach.** It said the floor is enforced on
"every route that installs or updates" while the same passage notes
install_from_url is ungated. Both spots now scope the claim to registry-managed
installs and the two supported update paths, and name the sideload exception.

Full suite 3720 passed, 6 skipped.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014RRtqXDCnvnY6EQwhT5CV9
This commit is contained in:
ChuckBuilds
2026-08-31 13:49:58 -04:00
co-authored by Claude Opus 5
parent 217aac780b
commit 45d912142c
3 changed files with 82 additions and 6 deletions
+31 -2
View File
@@ -2640,8 +2640,10 @@ class PluginStoreManager:
"reinstall it from the plugin store.", plugin_id)
return False
# Any local changes were stashed before the pull and are not popped on
# the success path either, so the reset leaves the working tree exactly
# Safe by construction: update_plugin returns before pulling unless the
# tree was clean or successfully stashed, so there are no uncommitted
# tracked edits for --hard to discard. The stash is not popped on the
# success path either, so the reset leaves the working tree exactly
# where a successful pull would have. Say "commit", not "changes".
reset = subprocess.run(
['git', '-C', str(plugin_path), 'reset', '--hard', previous_sha],
@@ -2937,6 +2939,8 @@ class PluginStoreManager:
status_result = type('obj', (object,), {'stdout': '', 'stderr': 'Status check timed out'})()
stash_info = ""
# Whether the pull can be undone without destroying work.
tree_is_recoverable = not has_changes
if has_changes:
self.logger.info(f"Stashing local changes in {plugin_id} before update")
try:
@@ -2950,12 +2954,37 @@ class PluginStoreManager:
)
if stash_result.returncode == 0:
stash_info = " (local changes were stashed)"
tree_is_recoverable = True
self.logger.info(f"Stashed local changes (including untracked files) for {plugin_id}")
else:
self.logger.warning(f"Failed to stash local changes for {plugin_id}: {stash_result.stderr}")
except subprocess.TimeoutExpired:
self.logger.warning(f"Stash operation timed out for {plugin_id}, proceeding with pull")
# Do not pull what cannot be un-pulled.
#
# The compatibility gate below can refuse the commit this
# pull brings down, and its only way back is `git reset
# --hard`, which discards uncommitted tracked edits. Those
# edits are exactly what the stash above exists to protect,
# so a stash that failed or timed out leaves the rollback
# unable to run without destroying them.
#
# A pull does not necessarily refuse on a dirty tree -- git
# merges happily as long as the incoming commit touches
# different files -- so without this the update would
# succeed, the gate would refuse, and the reset would take
# the user's work with it. Refusing here costs an update in
# a case that already went wrong; the alternative costs
# data.
if not tree_is_recoverable:
self.logger.error(
"Refusing to update %s: it has local changes that could "
"not be stashed, and an incompatible update could then "
"only be rolled back by discarding them. Commit or stash "
"them by hand, then update.", plugin_id)
return False
# Pull from the determined remote branch
self.logger.info(f"Pulling from origin/{remote_pull_branch} for {plugin_id}...")
pull_result = subprocess.run(