mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-29 18:46:59 +00:00
fix(update): survive undeletable untracked files during autostash (#70161)
git stash push --include-untracked exits non-zero when it saved everything but could not DELETE some swept untracked files from the working tree (e.g. a root-owned packaging/ directory left behind by a sudo'd build: 'warning: failed to remove ...: Permission denied'). The updater ran the push with check=True, so this benign partial failure raised CalledProcessError and aborted the whole update before it even fetched — reliably, on every run, for any user with an undeletable untracked path in the checkout. Fix, both ends of the class: - _stash_local_changes_if_needed: probe refs/stash before/after the push. Non-zero push + fresh stash entry = changes are saved; warn, reset the tracked-side leftovers (they're in the stash), and continue the update. Non-zero push + NO stash entry = real failure; keep aborting. - _restore_stashed_changes: on restore, those same undeletable files still sit in the tree, so 'git stash apply' exits 1 with 'already exists, no checkout' even though every tracked change applied and nothing was lost. Classify that stderr shape (strictly — any other error line still routes to the conflict path) as restored instead of resetting the tree and telling the user the restore failed. Repro'd both halves with real git; behavioral E2E test covers stash -> checkout -> restore round-trip with an undeletable dir.
This commit is contained in:
parent
76d4b65d59
commit
fdd3943cb1
2 changed files with 315 additions and 10 deletions
|
|
@ -7069,18 +7069,72 @@ def _stash_local_changes_if_needed(git_cmd: list[str], cwd: Path) -> Optional[st
|
|||
"hermes-update-autostash-%Y%m%d-%H%M%S"
|
||||
)
|
||||
print("→ Local changes detected — stashing before update...")
|
||||
subprocess.run(
|
||||
git_cmd + ["stash", "push", "--include-untracked", "-m", stash_name],
|
||||
cwd=cwd,
|
||||
check=True,
|
||||
)
|
||||
stash_ref = subprocess.run(
|
||||
prev_stash = subprocess.run(
|
||||
git_cmd + ["rev-parse", "--verify", "refs/stash"],
|
||||
cwd=cwd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
).stdout.strip()
|
||||
push = subprocess.run(
|
||||
git_cmd + ["stash", "push", "--include-untracked", "-m", stash_name],
|
||||
cwd=cwd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if push.stdout.strip():
|
||||
print(push.stdout.strip())
|
||||
stash_probe = subprocess.run(
|
||||
git_cmd + ["rev-parse", "--verify", "refs/stash"],
|
||||
cwd=cwd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
stash_ref = stash_probe.stdout.strip()
|
||||
stash_created = (
|
||||
stash_probe.returncode == 0 and bool(stash_ref) and stash_ref != prev_stash
|
||||
)
|
||||
|
||||
if push.returncode != 0:
|
||||
if stash_created:
|
||||
# git stash push exits non-zero when it saved everything but could
|
||||
# not delete some swept untracked files from the working tree
|
||||
# (e.g. a root-owned directory: "warning: failed to remove ...:
|
||||
# Permission denied"). The stash entry is complete — the changes
|
||||
# are safe — so this is not a failure. Leave the undeletable
|
||||
# files in place and continue the update.
|
||||
if push.stderr.strip():
|
||||
print(push.stderr.strip())
|
||||
print(
|
||||
" ⚠ Some untracked files could not be removed from the "
|
||||
"working tree (permission denied)."
|
||||
)
|
||||
print(
|
||||
" They were still saved to the stash and were left in "
|
||||
"place — the update will continue."
|
||||
)
|
||||
# A partially-failed stash push also aborts its working-tree
|
||||
# cleanup for TRACKED modifications — they are saved in the stash
|
||||
# but still dirty the tree, which would break the checkout/pull
|
||||
# that follows. Safe to reset: everything is in the stash entry.
|
||||
subprocess.run(
|
||||
git_cmd + ["reset", "--hard", "HEAD"],
|
||||
cwd=cwd,
|
||||
capture_output=True,
|
||||
)
|
||||
else:
|
||||
# No stash entry was created: the changes were NOT saved. This
|
||||
# is a real failure — bail out before the update touches HEAD.
|
||||
print("✗ Could not stash local changes — update aborted.")
|
||||
if push.stderr.strip():
|
||||
print(f" {push.stderr.strip().splitlines()[0]}")
|
||||
print(
|
||||
" Commit, stash, or clean up your local changes manually, "
|
||||
"then re-run `hermes update`."
|
||||
)
|
||||
raise subprocess.CalledProcessError(
|
||||
push.returncode, push.args, output=push.stdout, stderr=push.stderr
|
||||
)
|
||||
|
||||
return stash_ref
|
||||
|
||||
|
||||
|
|
@ -7116,6 +7170,36 @@ def _print_stash_cleanup_guidance(
|
|||
)
|
||||
|
||||
|
||||
def _stash_apply_failed_only_on_existing_untracked(stderr: str) -> bool:
|
||||
"""True when a ``git stash apply`` failure is ONLY about untracked files
|
||||
that already exist in the working tree.
|
||||
|
||||
This is the tail end of the permission-denied autostash class: ``git stash
|
||||
push --include-untracked`` swept undeletable files (e.g. a root-owned
|
||||
``packaging/`` directory) into the stash but could not remove them from
|
||||
disk. On restore, git applies all tracked changes, then refuses to
|
||||
overwrite those still-present files (``already exists, no checkout`` /
|
||||
``could not restore untracked files from stash``) and exits non-zero even
|
||||
though nothing was lost. Any other error line (e.g. ``would be
|
||||
overwritten by merge`` / ``Aborting``) means the tracked apply itself
|
||||
failed and this returns False.
|
||||
"""
|
||||
lines = [ln.strip() for ln in (stderr or "").splitlines() if ln.strip()]
|
||||
if not lines:
|
||||
return False
|
||||
saw_untracked_error = False
|
||||
for ln in lines:
|
||||
if "already exists, no checkout" in ln:
|
||||
saw_untracked_error = True
|
||||
elif "could not restore untracked files from stash" in ln:
|
||||
saw_untracked_error = True
|
||||
elif ln.startswith(("warning:", "hint:")):
|
||||
continue
|
||||
else:
|
||||
return False
|
||||
return saw_untracked_error
|
||||
|
||||
|
||||
def _restore_stashed_changes(
|
||||
git_cmd: list[str],
|
||||
cwd: Path,
|
||||
|
|
@ -7158,7 +7242,19 @@ def _restore_stashed_changes(
|
|||
)
|
||||
has_conflicts = bool(unmerged.stdout.strip())
|
||||
|
||||
if restore.returncode != 0 or has_conflicts:
|
||||
if restore.returncode != 0 and not has_conflicts and (
|
||||
_stash_apply_failed_only_on_existing_untracked(restore.stderr)
|
||||
):
|
||||
# Permission-denied autostash tail end: the tracked changes applied
|
||||
# cleanly; the only "failure" is untracked files that never left the
|
||||
# working tree (git could not delete them at stash time, so it now
|
||||
# refuses to overwrite them). Their content was never touched —
|
||||
# nothing is lost. Treat as restored.
|
||||
print(
|
||||
" ⚠ Some stashed untracked files already exist in the working "
|
||||
"tree and were kept as-is."
|
||||
)
|
||||
elif restore.returncode != 0 or has_conflicts:
|
||||
print("✗ Update pulled new code, but restoring local changes hit conflicts.")
|
||||
if restore.stdout.strip():
|
||||
print(restore.stdout.strip())
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue