fix(update): never offer the delete answer when there is nothing to pull; scan ignores showUntrackedFiles

- A main checkout reports in_place_update=False, so the keep/delete prompt fired even with HEAD
  already on origin/main; a "no" deleted edits on a no-op run. Ask only for a real branch switch
  or when the target is not HEAD or an ancestor of it.
- status.showUntrackedFiles=no hid every untracked entry from the scan, so the stash fell back
  to whole-tree --include-untracked. The scan now passes --untracked-files=normal.
This commit is contained in:
Teknium committed 2026-11-01 10:08:01 -08:00
1 parent c483933325
commit 001b0eba27
3 files changed
+39 -4

No files matched your search

+3 -2
View File
@@ -81,7 +81,7 @@ from hermes_cli.update_cmd_stash import (
_git_untracked_paths, _park_stashed_changes, _print_stash_cleanup_guidance,
_reject_unsafe_stash_restore, _resolve_stash_selector, _restore_stashed_changes,
_restored_python_paths, _stash_apply_failed_only_on_existing_untracked,
_stash_local_changes_if_needed, _unrestored_autostash_notice,
_stash_local_changes_if_needed, _target_would_move_head, _unrestored_autostash_notice,
_warn_orphaned_update_autostashes)
from hermes_cli.update_cmd_config import (
_LAST_SIBLING_SNAPSHOTS, _check_and_apply_config_migration, _migrate_sibling_profile_configs,
@@ -1413,7 +1413,8 @@ def _prepare_checkout_for_update(
# lands on a ref fetched only later.
prompt_for_restore = not assume_yes and (gateway_mode or (sys.stdin.isatty() and sys.stdout.isatty()))
ask_first = prompt_for_restore and not gateway_mode and (
not in_place_update or _resolved_commit(git_cmd, target_ref) != _capture_head_sha(git_cmd, _m().PROJECT_ROOT))
(not release_tag and not in_place_update and current_branch != branch)
or _target_would_move_head(git_cmd, _m().PROJECT_ROOT, target_ref))
auto_stash_ref = _m()._stash_local_changes_if_needed(
git_cmd, _m().PROJECT_ROOT, checkout_move=_moves_for(_windows_gateway_resume), ask_first=ask_first,
untracked_targets=() if is_fork else (target_ref, f"origin/{branch}", f"refs/heads/{branch}"))
+10 -1
View File
@@ -150,7 +150,9 @@ def _scan_local_changes(git_cmd: list[str], cwd: Path, untracked_targets=()) ->
blocks no pull or checkout, so with *untracked_targets* (every ref the update may land on) only the
colliding ones are moved; without targets, or when a target tree is unreadable, all of them are."""
from hermes_cli.update_cmd_git import _git_run
status = _git_run(git_cmd, ["status", "--porcelain", "-z"], cwd, check=True)
# Explicit mode: status.showUntrackedFiles=no would hide every untracked entry, and an empty
# leave-set means the stash takes them all.
status = _git_run(git_cmd, ["status", "--porcelain", "-z", "--untracked-files=normal"], cwd, check=True)
if not status.stdout.strip():
return None
records = _porcelain_records(status.stdout)
@@ -162,6 +164,13 @@ def _scan_local_changes(git_cmd: list[str], cwd: Path, untracked_targets=()) ->
stash_untracked=tuple(collide), leave_untracked=tuple(u for u in untracked if u not in collide))
def _target_would_move_head(git_cmd: list[str], cwd: Path, target_ref: str) -> bool:
"""True unless *target_ref* is HEAD or an ancestor of it (nothing to pull). An unresolvable
target answers True: the update fails on it later, and asking first is the safe side."""
probe = _git_quiet(git_cmd, ["merge-base", "--is-ancestor", f"{target_ref}^{{commit}}", "HEAD"], cwd)
return probe is None or probe.returncode != 0
def _literal(path: str, *magic: str) -> str:
"""Pathspec matching *path* byte-for-byte (relative to the checkout root git runs in)."""
return f":({','.join(('literal', *magic))}){path}"
@@ -5,8 +5,10 @@ the autostash and deleted from disk on every update; declining the restore then
"""
import subprocess
import sys
from hermes_cli import update_cmd_stash
import hermes_cli.main as cli_main
from hermes_cli import update_cmd, update_cmd_stash
def _git(cwd, *args):
@@ -28,6 +30,7 @@ def _behind_clone(tmp_path):
_git(up, "add", ".")
_git(up, "commit", "-qm", "adds pkg/new.py")
_git(work, "fetch", "-q", "origin")
_git(work, "config", "status.showUntrackedFiles", "no") # the scan must not inherit display settings
(work / "a.txt").write_text("a\nmine\n") # tracked edit
(work / "pkg").mkdir()
(work / "pkg" / "new.py").write_text("my copy\n") # untracked, collides with the update
@@ -63,3 +66,25 @@ def test_declining_up_front_discards_without_a_stash_entry(tmp_path, monkeypatch
assert not (work / "pkg" / "new.py").exists()
assert (work / "stray" / "big.bin").exists()
_git(work, "merge", "--ff-only", "-q", "origin/main")
def test_up_to_date_checkout_never_offers_to_delete(tmp_path, monkeypatch):
work = _behind_clone(tmp_path)
_git(work, "stash", "push", "-q", "--include-untracked")
_git(work, "merge", "--ff-only", "-q", "origin/main")
(work / "a.txt").write_text("a\nmine\n") # dirty, already on the target commit
monkeypatch.setattr(cli_main, "PROJECT_ROOT", work)
monkeypatch.chdir(work)
for stream in (sys.stdin, sys.stdout):
monkeypatch.setattr(stream, "isatty", lambda: True, raising=False)
def refuse(*_a):
raise AssertionError("asked keep/delete on an update with nothing to pull")
monkeypatch.setattr("builtins.input", refuse)
plan = update_cmd._prepare_checkout_for_update(
["git"], "main", "main", is_fork=False, assume_yes=False, gateway_mode=False,
gw_input_fn=None, switch_branch=False, _windows_gateway_resume=None)
assert plan.commit_count == 0
assert "a.txt" in _git(work, "stash", "show", "--name-only", plan.auto_stash_ref) # kept, not deleted