fix(git): bypass GitPython for stash ops to fix worktree failure (#189)#191
Conversation
GitPython's repo.git.stash() exits with code 1 in linked worktrees even when changes exist, because GitPython may propagate GIT_DIR or run in a context where git cannot see the working-tree diff. Switch all four stash operations (push/pop/drop/list) to subprocess.run() with an explicit cwd=repo.working_dir and a cleaned environment that strips GIT_DIR, GIT_WORK_TREE, GIT_INDEX_FILE, and GIT_COMMON_DIR. This matches exactly what a plain shell `git stash` does in the same directory. Also improves error handling: - exit code 1 + "No local changes to save" now returns an info message instead of an error - messages with spaces are passed safely as a single subprocess argument Adds 11 tests covering push (success, message, spaces-in-message, nothing-to-stash, worktree regression, include-untracked), list (empty, with-stash), pop, drop, and drop-by-id. Closes #189 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…k tests - git_stash_push: check stdout OR stderr for "No local changes to save" (some git versions write this to stderr, not stdout) - All four stash functions: guard against repo.working_dir is None (bare repos would silently run git in the wrong directory) - test_git_stash_push_returns_info_when_nothing_to_stash: replace flaky integration test (CI repo is never reliably clean) with two mock-based unit tests covering the stdout and stderr paths Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- _stash_ops.py: validate stash_id format (^stash@{N}$) in git_stash_pop
and git_stash_drop before passing to git; rejects unexpected values like
--force that could confuse git even without shell injection risk
- models.py: document GitHubGetBranchRules.branch has no ref-name validator
intentionally — the GHAS rules API accepts glob patterns, not just branch names
- scanning.py: add single-page pagination note to all four listing function
docstrings so callers know to use per_page/page for large repos
- test_github_ghas_forensics.py: add ValueError and ConnectionError tests for
github_get_ruleset and github_get_branch_rules to match coverage of other
GHAS functions
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
|
Claude finished @MementoRC's task —— View job PR Review
Overall this is a clean, well-scoped fix. The root cause analysis is sound and the approach (subprocess bypass + env stripping) is the correct solution for the worktree problem. Below are my findings, ordered by severity. Potential Bugs / Issues1. combined = result.stdout.strip() or result.stderr.strip()
if result.returncode == 1 and "No local changes to save" in combined:
combined = result.stdout + result.stderr
if result.returncode == 1 and "No local changes to save" in combined:This is intentionally covered by two separate tests (stdout vs stderr paths), but the 2. When 3. def _run_git(args: list[str], cwd: str) -> subprocess.CompletedProcess[str]:
return subprocess.run(
["git", *args],
cwd=cwd,
env=_clean_git_env(),
capture_output=True,
text=True,
)No Code Quality4. def _clean_git_env() -> dict[str, str]:
return {k: v for k, v in os.environ.items() if k not in _GIT_ENV_KEYS}This is called once per stash operation and rebuilds the filtered env dict each time. This is fine for the current use case but slightly wasteful. No action required — just an observation. 5. _STASH_ID_RE = re.compile(r"^stash@\{\d+\}$")The anchors prevent injection (e.g. 6. Bare repo guard ( Well done — all four functions check this early and return a clear error. Test Coverage7.
def test_git_stash_pop_rejects_invalid_stash_id(self):
result = git_stash_pop(self.repo, stash_id="../../etc/passwd")
assert "❌" in result
assert "Invalid stash ID" in resultThis is particularly important given the security aspect of the regex guard. 8. The bare-repo guard ( 9. The 10. def test_git_stash_push_returns_info_when_nothing_to_stash(self):
from unittest.mock import MagicMock, patch
import subprocess as _spImports inside test methods work but are unconventional — moving them to the module top level would be cleaner. Security11. The Other Files12. branch: str # Branch name or glob pattern — no ref-name validation (API accepts patterns)This is a good inline explanation for why the validator was intentionally omitted. 13. Summary
The |
Summary
repo.git.stash()withsubprocess.run()for all four stash operations so they work correctly in git linked worktreesGIT_DIR,GIT_WORK_TREE,GIT_INDEX_FILE,GIT_COMMON_DIRfrom the subprocess environment, matching what a plain shellgit stashseesℹ️) rather than an error — checked on both stdout and stderrstash_idformat (stash@{N}) ingit_stash_popandgit_stash_dropbefore passing to gitrepo.working_dir is None)scanning.pylisting function docstringsValueError/ConnectionErrortests forgithub_get_rulesetandgithub_get_branch_rulesGitHubGetBranchRules.branchhas no ref-name validator intentionally (API accepts glob patterns)Root cause
GitPython's
repo.git.stash()fails with exit code 1 in linked worktrees even when changes exist. Git interprets the working tree as clean because GitPython may setGIT_DIRor run in a context that prevents git from finding the correct index/work-tree relationship. The plain shellgit stashworks because it inherits no such env vars and auto-detects everything from the.gitfile in the worktree directory.Test plan
test_git_stash_push_returns_success_when_changes_exist— basic happy pathtest_git_stash_push_with_message/test_git_stash_push_with_message_containing_spacestest_git_stash_push_returns_info_when_nothing_to_stash— mock-based (stdout path)test_git_stash_push_returns_info_when_nothing_to_stash_stderr— mock-based (stderr path)test_git_stash_push_in_worktree— regression test for git_stash_push fails with exit code 1 even when unstaged changes exist #189; creates a real linked worktreetest_git_stash_push_include_untrackedtest_git_stash_list_empty/test_git_stash_list_shows_stashtest_git_stash_pop_latest/test_git_stash_drop_latest/test_git_stash_drop_by_idValueError/ConnectionErrortests forgithub_get_rulesetandgithub_get_branch_rulesCloses #189
🤖 Generated with Claude Code