diff --git a/hooks/pretool-branch-safety.py b/hooks/pretool-branch-safety.py index 52fe6aa2..59a07d39 100644 --- a/hooks/pretool-branch-safety.py +++ b/hooks/pretool-branch-safety.py @@ -22,6 +22,7 @@ import json import os +import re import subprocess import sys import traceback @@ -35,6 +36,30 @@ _PROTECTED_BRANCHES = {"main", "master"} +def _extract_effective_cwd(command: str, default_cwd: str | None) -> str | None: + """Extract the effective working directory from a command string. + + Detects two patterns: + - ``cd && ...`` or ``cd ; ...`` prefix + - ``git -C ...`` flag + + Returns the extracted path if found, otherwise default_cwd. + """ + # Pattern 1: cd && or cd ; + m = re.match(r'cd\s+(?:"([^"]+)"|(\S+))\s*(?:&&|;)', command.lstrip()) + if m: + p = (m.group(1) or m.group(2) or "").strip() + if p: + return p + + # Pattern 2: git -C + m = re.search(r'\bgit\s+-C\s+(?:"([^"]+)"|(\S+))', command) + if m: + return m.group(1) or m.group(2) + + return default_cwd + + def _current_branch(cwd: str | None) -> str | None: """Return the current git branch name, or None on error.""" try: @@ -74,7 +99,8 @@ def main() -> None: print("[branch-safety] Bypassed via BRANCH_SAFETY_BYPASS=1", file=sys.stderr) sys.exit(0) - cwd = event.get("cwd") or os.environ.get("CLAUDE_PROJECT_DIR") + default_cwd = event.get("cwd") or os.environ.get("CLAUDE_PROJECT_DIR") + cwd = _extract_effective_cwd(command, default_cwd) branch = _current_branch(cwd) if debug: diff --git a/hooks/pretool-unified-gate.py b/hooks/pretool-unified-gate.py index 1f7ef7ea..ca073a3b 100644 --- a/hooks/pretool-unified-gate.py +++ b/hooks/pretool-unified-gate.py @@ -266,6 +266,44 @@ def check_gitignore_bypass(command: str) -> None: ) +def _extract_effective_cwd(command: str, default_cwd: str | None = None) -> str | None: + """Extract the effective working directory from a command string. + + Detects two patterns: + - ``cd && ...`` or ``cd ; ...`` prefix + - ``git -C ...`` flag + + Returns the extracted path if found, otherwise default_cwd. + """ + m = re.match(r'cd\s+(?:"([^"]+)"|(\S+))\s*(?:&&|;)', command.lstrip()) + if m: + p = (m.group(1) or m.group(2) or "").strip() + if p: + return p + m = re.search(r'\bgit\s+-C\s+(?:"([^"]+)"|(\S+))', command) + if m: + return m.group(1) or m.group(2) + return default_cwd + + +def _is_worktree_on_feature_branch(cwd: str) -> bool: + """Return True if cwd is a worktree directory on a non-protected branch.""" + try: + result = subprocess.run( + ["git", "branch", "--show-current"], + capture_output=True, + text=True, + timeout=5, + cwd=cwd, + ) + if result.returncode == 0: + branch = result.stdout.strip() + return bool(branch) and branch not in {"main", "master"} + except (subprocess.TimeoutExpired, OSError): + pass + return False + + def check_git_submission(command: str) -> None: """Block raw git push, gh pr create, gh pr merge unless bypassed.""" # Skills prefix blocked commands with CLAUDE_GATE_BYPASS=1 to pass through @@ -274,6 +312,12 @@ def check_git_submission(command: str) -> None: for pattern, skill_name, message in _GIT_SUBMISSION_PATTERNS: if pattern.search(command): + # Allow git push from worktree directories on feature branches + if pattern is _GIT_SUBMISSION_PATTERNS[0][0]: # git push pattern + effective_cwd = _extract_effective_cwd(command) + project_dir = os.environ.get("CLAUDE_PROJECT_DIR", "") + if effective_cwd and effective_cwd != project_dir and _is_worktree_on_feature_branch(effective_cwd): + return _block(f"[git-submission-gate] BLOCKED: {message}\n[fix-with-skill] {skill_name}")