From 001b0eba27e352d9aedeaf379ab17c55661d113d Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sun, 1 Nov 2026 09:59:21 -0800 Subject: [PATCH] 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. --- hermes_cli/update_cmd.py | 5 ++-- hermes_cli/update_cmd_stash.py | 11 +++++++- .../test_update_scoped_autostash.py | 27 ++++++++++++++++++- 3 files changed, 39 insertions(+), 4 deletions(-) diff --git a/hermes_cli/update_cmd.py b/hermes_cli/update_cmd.py index 209197eea7..23d4b46290 100644 --- a/hermes_cli/update_cmd.py +++ b/hermes_cli/update_cmd.py @@ -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}")) diff --git a/hermes_cli/update_cmd_stash.py b/hermes_cli/update_cmd_stash.py index 2e0f0081a3..e8af61d37e 100644 --- a/hermes_cli/update_cmd_stash.py +++ b/hermes_cli/update_cmd_stash.py @@ -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}" diff --git a/tests/hermes_cli/test_update_scoped_autostash.py b/tests/hermes_cli/test_update_scoped_autostash.py index a6c98ca85d..46a9c1518d 100644 --- a/tests/hermes_cli/test_update_scoped_autostash.py +++ b/tests/hermes_cli/test_update_scoped_autostash.py @@ -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