claude-dev-env 2.19.0 → 2.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/.agents/skills/_shared/pr-loop/preflight-proposal.contract.test.mjs +31 -1
  2. package/.agents/skills/e-code-review/SKILL.md +12 -1
  3. package/.agents/skills/e-code-review/reference/fix.md +5 -1
  4. package/.agents/skills/e-code-review/reference/loop.md +4 -0
  5. package/.agents/skills/e-code-review/reference/mode-contract.test.mjs +66 -0
  6. package/.agents/skills/e-code-review/reference/preflight-proposal.md +40 -0
  7. package/.agents/skills/e-code-review/reference/runner-selection.md +1 -0
  8. package/.agents/skills/pr-cleanup/SKILL.md +109 -11
  9. package/_shared/pr-loop/scripts/code_rules_gate.py +29 -6
  10. package/_shared/pr-loop/scripts/code_rules_gate_parts/gate_arguments.py +15 -3
  11. package/_shared/pr-loop/scripts/pr_loop_shared_constants/code_rules_gate_constants.py +4 -0
  12. package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +47 -0
  13. package/docs/CODE_RULES.md +2 -0
  14. package/hooks/advisory/conftest.py +10 -0
  15. package/hooks/advisory/refactor_guard.py +250 -144
  16. package/hooks/advisory/refactor_guard_test_support.py +46 -0
  17. package/hooks/advisory/test_refactor_guard_advisory.py +171 -0
  18. package/hooks/advisory/test_refactor_guard_eligibility.py +166 -0
  19. package/hooks/blocking/block_main_commit.py +66 -33
  20. package/hooks/blocking/code_rules_blast_radius.py +194 -0
  21. package/hooks/blocking/code_rules_enforcer.py +12 -0
  22. package/hooks/blocking/test_block_main_commit.py +145 -0
  23. package/hooks/blocking/test_code_rules_blast_radius.py +161 -0
  24. package/hooks/blocking/test_code_rules_enforcer_narrow_edit.py +1 -0
  25. package/hooks/blocking/test_destructive_command_blocker.py +154 -138
  26. package/hooks/blocking/test_destructive_command_blocker_deny_mode.py +52 -9
  27. package/hooks/blocking/test_destructive_command_blocker_patterns.py +133 -0
  28. package/hooks/blocking/test_precommit_code_rules_gate_native_owner.py +71 -5
  29. package/hooks/git-hooks/AGENTS.md +1 -1
  30. package/hooks/git-hooks/git_hooks_constants/__init__.py +1 -0
  31. package/hooks/git-hooks/post_commit.py +160 -51
  32. package/hooks/git-hooks/pre_commit.py +3 -3
  33. package/hooks/git-hooks/test_post_commit.py +203 -0
  34. package/hooks/git-hooks/test_pre_commit.py +2 -2
  35. package/hooks/hooks_constants/blast_radius_constants.py +14 -0
  36. package/hooks/hooks_constants/refactor_guard_constants.py +75 -0
  37. package/hooks/hooks_constants/test_refactor_guard_constants.py +21 -0
  38. package/hooks/observability/test_instructions_loaded_logger.py +54 -0
  39. package/hooks/session/test_plugin_data_dir_cleanup.py +70 -0
  40. package/hooks/session/test_session_edit_tracker_cleanup.py +16 -3
  41. package/hooks/validation/mypy_validator.py +213 -80
  42. package/hooks/validation/test_mypy_validator.py +288 -13
  43. package/hooks/workflow/auto_formatter.py +225 -93
  44. package/hooks/workflow/investigation_tracker_reset.py +2 -0
  45. package/hooks/workflow/test_auto_formatter.py +261 -12
  46. package/hooks/workflow/test_investigation_tracker_reset.py +90 -0
  47. package/package.json +1 -1
  48. package/rules/failure-blast-radius.md +126 -0
@@ -18,6 +18,7 @@ if str(_BLOCKING_DIRECTORY) not in sys.path:
18
18
  sys.path.insert(0, str(_BLOCKING_DIRECTORY))
19
19
 
20
20
  SCRIPT_PATH = _BLOCKING_DIRECTORY / "destructive_command_blocker.py"
21
+ DISPATCHER_PATH = _BLOCKING_DIRECTORY / "bash_pre_tool_use_dispatcher.py"
21
22
 
22
23
  import _path_setup # noqa: E402, F401
23
24
 
@@ -28,12 +29,13 @@ from test_hook_subprocess_support import ( # noqa: E402
28
29
 
29
30
 
30
31
  def _run_hook_with_environment(
32
+ hook_script_path: Path,
31
33
  command: str,
32
34
  environment_update_by_name: dict[str, str],
33
35
  temporary_home_directory: Path,
34
36
  ) -> subprocess.CompletedProcess[str]:
35
37
  return run_hook_as_subprocess(
36
- hook_script_path=SCRIPT_PATH,
38
+ hook_script_path=hook_script_path,
37
39
  payload_text=build_bash_payload(command),
38
40
  working_directory=temporary_home_directory,
39
41
  home_directory=temporary_home_directory,
@@ -42,21 +44,62 @@ def _run_hook_with_environment(
42
44
  )
43
45
 
44
46
 
47
+ def _assert_terminal_response(
48
+ completed_hook: subprocess.CompletedProcess[str],
49
+ expected_permission_decision: str,
50
+ expected_permission_reason: str,
51
+ ) -> None:
52
+ assert completed_hook.returncode == 0
53
+ assert completed_hook.stderr == ""
54
+ assert json.loads(completed_hook.stdout) == {
55
+ "hookSpecificOutput": {
56
+ "hookEventName": "PreToolUse",
57
+ "permissionDecision": expected_permission_decision,
58
+ "permissionDecisionReason": expected_permission_reason,
59
+ }
60
+ }
61
+
62
+
45
63
  def test_rm_rf_denies_when_deny_mode_env_is_set(tmp_path: Path) -> None:
46
64
  completed_hook = _run_hook_with_environment(
65
+ SCRIPT_PATH,
47
66
  "rm -rf /var/log/myapp",
48
67
  {"CLAUDE_DESTRUCTIVE_DENY_MODE": "1"},
49
68
  tmp_path,
50
69
  )
51
- hook_decision_by_field = json.loads(completed_hook.stdout)
52
-
53
- assert hook_decision_by_field["hookSpecificOutput"]["permissionDecision"] == "deny"
54
- assert "rm -rf" in hook_decision_by_field["hookSpecificOutput"]["permissionDecisionReason"]
70
+ _assert_terminal_response(
71
+ completed_hook,
72
+ "deny",
73
+ "DESTRUCTIVE: rm -rf (destructive recursive forced delete). "
74
+ "Blocked in deny mode.",
75
+ )
55
76
 
56
77
 
57
78
  def test_rm_rf_asks_when_deny_mode_env_is_absent(tmp_path: Path) -> None:
58
- completed_hook = _run_hook_with_environment("rm -rf /var/log/myapp", {}, tmp_path)
59
- hook_decision_by_field = json.loads(completed_hook.stdout)
79
+ completed_hook = _run_hook_with_environment(
80
+ SCRIPT_PATH,
81
+ "rm -rf /var/log/myapp",
82
+ {},
83
+ tmp_path,
84
+ )
85
+ _assert_terminal_response(
86
+ completed_hook,
87
+ "ask",
88
+ "DESTRUCTIVE: rm -rf (destructive recursive forced delete). "
89
+ "Requires explicit user approval.",
90
+ )
91
+
60
92
 
61
- assert hook_decision_by_field["hookSpecificOutput"]["permissionDecision"] == "ask"
62
- assert "rm -rf" in hook_decision_by_field["hookSpecificOutput"]["permissionDecisionReason"]
93
+ def test_dispatcher_short_circuits_on_destructive_deny(tmp_path: Path) -> None:
94
+ completed_dispatcher = _run_hook_with_environment(
95
+ DISPATCHER_PATH,
96
+ "rm -rf /var/log/myapp",
97
+ {"CLAUDE_DESTRUCTIVE_DENY_MODE": "1"},
98
+ tmp_path,
99
+ )
100
+ _assert_terminal_response(
101
+ completed_dispatcher,
102
+ "deny",
103
+ "DESTRUCTIVE: rm -rf (destructive recursive forced delete). "
104
+ "Blocked in deny mode.",
105
+ )
@@ -0,0 +1,133 @@
1
+ """Production-subprocess coverage for destructive command patterns."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import subprocess
7
+ import sys
8
+ import tempfile
9
+ from pathlib import Path
10
+
11
+ import pytest
12
+
13
+ _BLOCKING_DIRECTORY = Path(__file__).resolve().parent
14
+ if str(_BLOCKING_DIRECTORY) not in sys.path:
15
+ sys.path.insert(0, str(_BLOCKING_DIRECTORY))
16
+
17
+ import _path_setup # noqa: E402, F401
18
+
19
+ from hooks_constants.destructive_command_environment_constants import ( # noqa: E402
20
+ DESTRUCTIVE_DENY_MODE_ENV_VAR,
21
+ EPHEMERAL_AUTO_ALLOW_DISABLE_ENV_VAR,
22
+ )
23
+ from test_hook_subprocess_support import ( # noqa: E402
24
+ build_bash_payload,
25
+ run_hook_as_subprocess,
26
+ )
27
+
28
+ SCRIPT_PATH = _BLOCKING_DIRECTORY / "destructive_command_blocker.py"
29
+ ALL_DESTRUCTIVE_ENVIRONMENT_NAMES = (
30
+ DESTRUCTIVE_DENY_MODE_ENV_VAR,
31
+ EPHEMERAL_AUTO_ALLOW_DISABLE_ENV_VAR,
32
+ )
33
+
34
+
35
+ def _run_hook(
36
+ command: str,
37
+ working_directory: Path,
38
+ home_directory: Path,
39
+ ) -> subprocess.CompletedProcess[str]:
40
+ return run_hook_as_subprocess(
41
+ hook_script_path=SCRIPT_PATH,
42
+ payload_text=build_bash_payload(command),
43
+ working_directory=working_directory,
44
+ home_directory=home_directory,
45
+ all_environment_names_to_remove=ALL_DESTRUCTIVE_ENVIRONMENT_NAMES,
46
+ )
47
+
48
+
49
+ @pytest.mark.parametrize(
50
+ ("command", "reason_fragment"),
51
+ [
52
+ ("rm -rf /var/log/myapp", "rm -rf"),
53
+ ("rm --recursive --force /var/log/myapp", "rm --recursive --force"),
54
+ ("rm -r /", "rm -r on broad path"),
55
+ ("mkfs.ext4 /dev/sda1", "mkfs"),
56
+ ("dd if=/tmp/disk.img of=/dev/sda", "dd raw disk write"),
57
+ ("git reset --hard HEAD~1", "git reset --hard"),
58
+ ("git push --force origin main", "git push --force"),
59
+ ("git push -f origin main", "git push -f"),
60
+ ("git clean -fd", "git clean -fd"),
61
+ ("git clean -f", "git clean -f"),
62
+ ("psql -c 'DROP TABLE users'", "DROP TABLE"),
63
+ ("psql -c 'DROP DATABASE app'", "DROP DATABASE"),
64
+ ("psql -c 'TRUNCATE TABLE users'", "TRUNCATE TABLE"),
65
+ ("git commit --no-verify", "git --no-verify"),
66
+ ("git commit --no-gpg-sign", "git --no-gpg-sign"),
67
+ ("git -c commit.gpgsign=false commit", "commit.gpgsign=false"),
68
+ ],
69
+ )
70
+ def test_protected_command_asks_for_approval(
71
+ command: str,
72
+ reason_fragment: str,
73
+ tmp_path: Path,
74
+ ) -> None:
75
+ filesystem_root = Path(Path.cwd().anchor)
76
+ completed_hook = _run_hook(command, filesystem_root, tmp_path)
77
+
78
+ hook_decision_payload = json.loads(completed_hook.stdout)
79
+ assert completed_hook.returncode == 0
80
+ assert completed_hook.stderr == ""
81
+ assert hook_decision_payload["hookSpecificOutput"]["hookEventName"] == "PreToolUse"
82
+ assert hook_decision_payload["hookSpecificOutput"]["permissionDecision"] == "ask"
83
+ assert reason_fragment in hook_decision_payload["hookSpecificOutput"][
84
+ "permissionDecisionReason"
85
+ ]
86
+
87
+
88
+ @pytest.mark.parametrize(
89
+ "command",
90
+ [
91
+ "git status --short",
92
+ "rm -rf /tmp/destructive-command-blocker/build",
93
+ "rm -rf /project/worktrees/feature/build",
94
+ "git push --force origin claude/fix-hook-test",
95
+ ],
96
+ )
97
+ def test_permitted_command_exits_silently(command: str, tmp_path: Path) -> None:
98
+ completed_hook = _run_hook(command, tmp_path, tmp_path)
99
+
100
+ assert completed_hook.returncode == 0
101
+ assert completed_hook.stdout == ""
102
+ assert completed_hook.stderr == ""
103
+
104
+
105
+ def test_ephemeral_rm_exits_silently_when_parent_disables_auto_allow(
106
+ monkeypatch: pytest.MonkeyPatch,
107
+ tmp_path: Path,
108
+ ) -> None:
109
+ monkeypatch.setenv(EPHEMERAL_AUTO_ALLOW_DISABLE_ENV_VAR, "1")
110
+
111
+ with tempfile.TemporaryDirectory(
112
+ dir=tmp_path,
113
+ prefix="destructive command blocker ",
114
+ ) as ephemeral_directory_path:
115
+ completed_hook = _run_hook(
116
+ f'rm -rf "{ephemeral_directory_path}"',
117
+ tmp_path,
118
+ tmp_path,
119
+ )
120
+
121
+ assert completed_hook.returncode == 0
122
+ assert completed_hook.stdout == ""
123
+ assert completed_hook.stderr == ""
124
+
125
+
126
+ def test_git_reset_hard_in_ephemeral_project_exits_silently(
127
+ tmp_path: Path,
128
+ ) -> None:
129
+ completed_hook = _run_hook("git reset --hard HEAD~1", tmp_path, tmp_path)
130
+
131
+ assert completed_hook.returncode == 0
132
+ assert completed_hook.stdout == ""
133
+ assert completed_hook.stderr == ""
@@ -7,6 +7,8 @@ native path runs the staged gate and controls the commit.
7
7
 
8
8
  from __future__ import annotations
9
9
 
10
+ from collections.abc import Iterator
11
+ from contextlib import contextmanager
10
12
  import json
11
13
  import shutil
12
14
  import stat
@@ -66,7 +68,7 @@ def write_gate_script(gate_path: Path, marker_path: Path, exit_code: int) -> Non
66
68
  )
67
69
 
68
70
 
69
- def install_native_pre_commit(repository_root: Path, hooks_path: Path) -> None:
71
+ def install_native_pre_commit(hooks_path: Path) -> None:
70
72
  """Install the native pre-commit module and its self-contained imports."""
71
73
  hooks_path.mkdir()
72
74
  shutil.copyfile(PRE_COMMIT_SOURCE_PATH, hooks_path / "pre_commit.py")
@@ -87,7 +89,47 @@ def install_native_pre_commit(repository_root: Path, hooks_path: Path) -> None:
87
89
  encoding="utf-8",
88
90
  )
89
91
  pre_commit_path.chmod(pre_commit_path.stat().st_mode | stat.S_IXUSR)
90
- run_git(repository_root, "config", "core.hooksPath", str(hooks_path))
92
+
93
+
94
+ def configured_hooks_path(repository_root: Path) -> str | None:
95
+ """Return the repository-local hooks path when one is configured."""
96
+ completed_config = run_git(
97
+ repository_root,
98
+ "config",
99
+ "--local",
100
+ "--get",
101
+ "core.hooksPath",
102
+ check=False,
103
+ )
104
+ if completed_config.returncode != 0:
105
+ return None
106
+ return completed_config.stdout.strip()
107
+
108
+
109
+ def restore_hooks_path(repository_root: Path, prior_hooks_path: str | None) -> None:
110
+ """Restore the repository-local hooks path captured before a benchmark."""
111
+ if prior_hooks_path is None:
112
+ run_git(
113
+ repository_root,
114
+ "config",
115
+ "--local",
116
+ "--unset-all",
117
+ "core.hooksPath",
118
+ check=False,
119
+ )
120
+ return
121
+ run_git(repository_root, "config", "--local", "core.hooksPath", prior_hooks_path)
122
+
123
+
124
+ @contextmanager
125
+ def temporary_hooks_path(repository_root: Path, benchmark_hooks_path: Path) -> Iterator[None]:
126
+ """Apply a benchmark hooks path and restore the prior local setting."""
127
+ prior_hooks_path = configured_hooks_path(repository_root)
128
+ run_git(repository_root, "config", "--local", "core.hooksPath", str(benchmark_hooks_path))
129
+ try:
130
+ yield
131
+ finally:
132
+ restore_hooks_path(repository_root, prior_hooks_path)
91
133
 
92
134
 
93
135
  def stage_module(repository_root: Path) -> None:
@@ -155,14 +197,38 @@ def test_installed_native_pre_commit_controls_staged_gate_decision(
155
197
  """The native Git hook runs the staged gate and returns its commit decision."""
156
198
  initialize_repository(tmp_path)
157
199
  native_hooks_path = tmp_path / "native_hooks"
158
- install_native_pre_commit(tmp_path, native_hooks_path)
200
+ install_native_pre_commit(native_hooks_path)
159
201
  stage_module(tmp_path)
160
202
  gate_marker_path = tmp_path / "native_gate_invocation.txt"
161
203
  gate_path = tmp_path / "native_gate.py"
162
204
  write_gate_script(gate_path, gate_marker_path, gate_exit_code)
163
205
  monkeypatch.setenv("CODE_RULES_GATE_PATH", str(gate_path))
164
206
 
165
- completed_commit = run_native_commit(tmp_path)
207
+ with temporary_hooks_path(tmp_path, native_hooks_path):
208
+ completed_commit = run_native_commit(tmp_path)
166
209
 
167
210
  assert completed_commit.returncode == expected_commit_exit_code
168
- assert gate_marker_path.read_text(encoding="utf-8") == "--staged"
211
+ assert gate_marker_path.read_text(encoding="utf-8") == "--immediate"
212
+
213
+
214
+ def test_temporary_hooks_path_restores_shared_worktree_config_on_timeout(
215
+ tmp_path: Path,
216
+ ) -> None:
217
+ initialize_repository(tmp_path)
218
+ supported_hooks_path = tmp_path / "supported_hooks"
219
+ supported_hooks_path.mkdir()
220
+ run_git(tmp_path, "config", "--local", "core.hooksPath", str(supported_hooks_path))
221
+ unrelated_worktree = tmp_path.parent / "unrelated_worktree"
222
+ run_git(tmp_path, "worktree", "add", "--detach", str(unrelated_worktree))
223
+ benchmark_hooks_path = tmp_path / "benchmark_hooks"
224
+ benchmark_hooks_path.mkdir()
225
+
226
+ try:
227
+ with pytest.raises(subprocess.TimeoutExpired):
228
+ with temporary_hooks_path(tmp_path, benchmark_hooks_path):
229
+ raise subprocess.TimeoutExpired("benchmark", 1)
230
+
231
+ assert configured_hooks_path(tmp_path) == str(supported_hooks_path)
232
+ assert configured_hooks_path(unrelated_worktree) == str(supported_hooks_path)
233
+ finally:
234
+ run_git(tmp_path, "worktree", "remove", "--force", str(unrelated_worktree))
@@ -6,7 +6,7 @@ Native git hooks that run outside the Claude Code lifecycle — invoked directly
6
6
 
7
7
  | File | Git hook | What it does |
8
8
  |---|---|---|
9
- | `pre_commit.py` | `pre-commit` | Runs the CODE_RULES gate (`precommit_code_rules_gate.py`) over staged changes; exits 1 when any staged file has a blocking violation |
9
+ | `pre_commit.py` | `pre-commit` | Runs immediate CODE_RULES and terminology validation over staged changes; exits 1 when any staged file has a blocking violation. CI runs package tests for package Python changes. |
10
10
  | `pre_push.py` | `pre-push` | Blocks a push that would land a non-`main` local branch onto remote `main` (or `master`), then runs the CODE_RULES gate. An existing branch's gate base is the merge base with the remote default branch; the gate process still diffs that base against checkout HEAD, so the surface matches the pushed tip only when HEAD is that tip. |
11
11
  | `pre_push_base_reference.py` | — | Resolves a usable gate base for `pre_push.py`: reads the pushed remote name from git's arguments, then turns a symbolic default-branch head into a reference that git can resolve |
12
12
  | `post_commit.py` | `post-commit` | Runs after a commit lands; performs any post-commit bookkeeping |
@@ -10,6 +10,7 @@ import ...`` resolve against this file both inside the repo and under
10
10
  from __future__ import annotations
11
11
 
12
12
  STAGED_SCOPE_ARGUMENT: str = "--staged"
13
+ IMMEDIATE_SCOPE_ARGUMENT: str = "--immediate"
13
14
  BASE_REFERENCE_ARGUMENT: str = "--base"
14
15
  DEFAULT_REMOTE_BASE_REFERENCE: str = "origin/HEAD"
15
16
  ALL_ZEROS_OBJECT_NAME_CHARACTER: str = "0"
@@ -10,87 +10,196 @@ When you commit in a submodule, this hook:
10
10
  This prevents the "lost work" issue where submodule commits aren't tracked by parent.
11
11
  """
12
12
 
13
+ from __future__ import annotations
14
+
13
15
  import subprocess
14
16
  import sys
17
+ from enum import StrEnum
15
18
  from pathlib import Path
16
19
 
20
+ from git_hooks_constants import GIT_COMMAND_SUCCESS_EXIT_CODE, GIT_EXECUTABLE_NAME
17
21
 
18
- def run_git(*args: str, cwd: Path | None = None) -> str:
19
- """Run a git command and return output."""
20
- result = subprocess.run(
21
- ["git"] + list(args),
22
- cwd=cwd,
23
- check=False, capture_output=True,
24
- text=True,
25
- )
26
- return result.stdout.strip()
22
+
23
+ class ParentPointerStatus(StrEnum):
24
+ """States returned by a parent pointer update."""
25
+
26
+ UPDATED = "updated"
27
+ UNCHANGED = "unchanged"
28
+ FAILED = "failed"
29
+
30
+
31
+ ParentPointerUpdate = tuple[ParentPointerStatus, str]
32
+
33
+
34
+ def execute_git(
35
+ *arguments: str,
36
+ cwd: Path,
37
+ ) -> subprocess.CompletedProcess[str]:
38
+ """Run a Git command and return its completed process."""
39
+ try:
40
+ return subprocess.run(
41
+ [GIT_EXECUTABLE_NAME, *arguments],
42
+ cwd=cwd,
43
+ check=False,
44
+ capture_output=True,
45
+ text=True,
46
+ )
47
+ except OSError as error:
48
+ return subprocess.CompletedProcess(
49
+ [GIT_EXECUTABLE_NAME, *arguments],
50
+ 1,
51
+ "",
52
+ str(error),
53
+ )
54
+
55
+
56
+ def run_git(*arguments: str, cwd: Path) -> str:
57
+ """Run a successful Git command and return its trimmed standard output."""
58
+ command_result = execute_git(*arguments, cwd=cwd)
59
+ if command_result.returncode != GIT_COMMAND_SUCCESS_EXIT_CODE:
60
+ return ""
61
+ return command_result.stdout.strip()
62
+
63
+
64
+ def run_git_from_current_directory(*arguments: str) -> str:
65
+ """Run a successful Git command from the hook's current directory."""
66
+ return run_git(*arguments, cwd=Path.cwd())
27
67
 
28
68
 
29
69
  def find_parent_repo(repo_dir: Path) -> Path | None:
30
- """Find parent repo that has this as a submodule."""
31
- repo_name = repo_dir.name
32
- parent_dir = repo_dir.parent
70
+ """Return the Git superproject that owns the current repository."""
71
+ parent_path_text = run_git(
72
+ "rev-parse",
73
+ "--show-superproject-working-tree",
74
+ cwd=repo_dir,
75
+ )
76
+ if not parent_path_text:
77
+ return None
78
+
79
+ parent_path = Path(parent_path_text).resolve()
80
+ if not parent_path.is_dir():
81
+ return None
82
+ return parent_path
83
+
84
+
85
+ def find_submodule_path(parent_repo: Path, repo_dir: Path) -> Path | None:
86
+ """Return the submodule path relative to its parent repository."""
87
+ try:
88
+ return repo_dir.resolve().relative_to(parent_repo.resolve())
89
+ except ValueError:
90
+ return None
91
+
33
92
 
34
- while parent_dir != parent_dir.parent:
35
- git_path = parent_dir / ".git"
36
- gitmodules_path = parent_dir / ".gitmodules"
93
+ def build_literal_pathspec(submodule_path: Path) -> str:
94
+ """Build a Git pathspec that treats every path character literally."""
95
+ return f":(literal){submodule_path.as_posix()}"
37
96
 
38
- if git_path.exists() and gitmodules_path.exists():
39
- try:
40
- content = gitmodules_path.read_text()
41
- if f"path = {repo_name}" in content:
42
- return parent_dir
43
- except Exception:
44
- pass
45
97
 
46
- parent_dir = parent_dir.parent
98
+ def get_git_failure_diagnostic(
99
+ command_result: subprocess.CompletedProcess[str],
100
+ ) -> str:
101
+ """Return the most useful diagnostic from a failed Git command."""
102
+ return command_result.stderr.strip() or command_result.stdout.strip()
47
103
 
48
- return None
104
+
105
+ def build_parent_commit_message(
106
+ repo_name: str,
107
+ commit_hash: str,
108
+ commit_message: str,
109
+ ) -> str:
110
+ """Build the parent commit message for a submodule pointer update."""
111
+ return (
112
+ f"chore: update {repo_name} submodule to {commit_hash}\n\n"
113
+ f"Submodule commit: {commit_message}\n\n"
114
+ "Co-Authored-By: Claude <noreply@anthropic.com>"
115
+ )
116
+
117
+
118
+ def update_parent_pointer(
119
+ parent_repo: Path,
120
+ repo_dir: Path,
121
+ commit_hash: str,
122
+ commit_message: str,
123
+ ) -> ParentPointerUpdate:
124
+ """Commit the current submodule pointer in its parent repository."""
125
+ submodule_path = find_submodule_path(parent_repo, repo_dir)
126
+ if submodule_path is None:
127
+ return ParentPointerStatus.FAILED, "could not resolve the submodule path"
128
+
129
+ submodule_reference = build_literal_pathspec(submodule_path)
130
+ add_result = execute_git("add", "--", submodule_reference, cwd=parent_repo)
131
+ if add_result.returncode != GIT_COMMAND_SUCCESS_EXIT_CODE:
132
+ return ParentPointerStatus.FAILED, get_git_failure_diagnostic(add_result)
133
+
134
+ staged_difference = execute_git(
135
+ "diff",
136
+ "--cached",
137
+ "--quiet",
138
+ "--",
139
+ submodule_reference,
140
+ cwd=parent_repo,
141
+ )
142
+ if staged_difference.returncode == GIT_COMMAND_SUCCESS_EXIT_CODE:
143
+ return ParentPointerStatus.UNCHANGED, ""
144
+ if staged_difference.returncode != 1:
145
+ return ParentPointerStatus.FAILED, get_git_failure_diagnostic(staged_difference)
146
+
147
+ parent_commit_message = build_parent_commit_message(
148
+ repo_dir.name,
149
+ commit_hash,
150
+ commit_message,
151
+ )
152
+ commit_result = execute_git(
153
+ "commit",
154
+ "--only",
155
+ "-m",
156
+ parent_commit_message,
157
+ "--",
158
+ submodule_reference,
159
+ cwd=parent_repo,
160
+ )
161
+ if commit_result.returncode != GIT_COMMAND_SUCCESS_EXIT_CODE:
162
+ return ParentPointerStatus.FAILED, get_git_failure_diagnostic(commit_result)
163
+ return ParentPointerStatus.UPDATED, ""
49
164
 
50
165
 
51
166
  def main() -> int:
52
- """Main hook logic."""
53
- try:
54
- repo_dir = Path(run_git("rev-parse", "--show-toplevel"))
55
- except Exception:
167
+ """Update a parent repository after a submodule commit."""
168
+ repo_path_text = run_git_from_current_directory("rev-parse", "--show-toplevel")
169
+ if not repo_path_text:
56
170
  return 0
57
171
 
58
- repo_name = repo_dir.name
172
+ repo_dir = Path(repo_path_text).resolve()
59
173
  parent_repo = find_parent_repo(repo_dir)
60
-
61
- if not parent_repo:
174
+ if parent_repo is None:
62
175
  return 0
63
176
 
64
177
  commit_msg = run_git("log", "-1", "--pretty=%s", cwd=repo_dir)
65
- commit_hash = run_git("rev-parse", "--short", "HEAD", cwd=repo_dir)
178
+ commit_hash = run_git("rev-parse", "HEAD", cwd=repo_dir)
179
+ short_commit_hash = run_git("rev-parse", "--short", "HEAD", cwd=repo_dir)
180
+ if not commit_hash or not short_commit_hash:
181
+ return 0
66
182
 
67
183
  print()
68
184
  print("=== Submodule Parent Update ===")
69
- print(f"Submodule: {repo_name} @ {commit_hash}")
185
+ print(f"Submodule: {repo_dir.name} @ {short_commit_hash}")
70
186
  print(f"Parent: {parent_repo}")
71
187
 
72
- run_git("add", repo_name, cwd=parent_repo)
73
-
74
- diff_result = subprocess.run(
75
- ["git", "diff", "--cached", "--quiet"],
76
- check=False, cwd=parent_repo,
188
+ parent_pointer_status, parent_pointer_diagnostic = update_parent_pointer(
189
+ parent_repo,
190
+ repo_dir,
191
+ commit_hash,
192
+ commit_msg,
77
193
  )
78
-
79
- if diff_result.returncode == 0:
194
+ if parent_pointer_status is ParentPointerStatus.FAILED:
195
+ print("Parent update failed.")
196
+ if parent_pointer_diagnostic:
197
+ print(f"Git diagnostic: {parent_pointer_diagnostic}")
198
+ return 0
199
+ if parent_pointer_status is ParentPointerStatus.UNCHANGED:
80
200
  print("Parent already up to date.")
81
201
  return 0
82
202
 
83
- full_commit_msg = f"""chore: update {repo_name} submodule to {commit_hash}
84
-
85
- Submodule commit: {commit_msg}
86
-
87
- Co-Authored-By: Claude <noreply@anthropic.com>"""
88
-
89
- subprocess.run(
90
- ["git", "commit", "-m", full_commit_msg],
91
- check=False, cwd=parent_repo,
92
- )
93
-
94
203
  print("Parent updated successfully.")
95
204
  print("================================")
96
205
  print()
@@ -24,20 +24,20 @@ from gate_utils import is_safe_regular_file, resolve_gate_script_path
24
24
  from git_hooks_constants import (
25
25
  GATE_INFRASTRUCTURE_FAILURE_EXIT_CODE,
26
26
  GATE_SCRIPT_NOT_FOUND_MESSAGE,
27
+ IMMEDIATE_SCOPE_ARGUMENT,
27
28
  INVOKE_GATE_FAILURE_MESSAGE,
28
- STAGED_SCOPE_ARGUMENT,
29
29
  )
30
30
 
31
31
 
32
32
  def invoke_gate(gate_script_path: Path) -> int:
33
33
  """Invoke the shared staged gate and return its exit code."""
34
- staged_scope_argument = STAGED_SCOPE_ARGUMENT
34
+ immediate_scope_argument = IMMEDIATE_SCOPE_ARGUMENT
35
35
  invoke_gate_failure_message = INVOKE_GATE_FAILURE_MESSAGE
36
36
  gate_infrastructure_failure_exit_code = GATE_INFRASTRUCTURE_FAILURE_EXIT_CODE
37
37
  try:
38
38
  resolved_gate_path = gate_script_path.resolve(strict=True)
39
39
  completion = subprocess.run(
40
- [sys.executable, str(resolved_gate_path), staged_scope_argument],
40
+ [sys.executable, str(resolved_gate_path), immediate_scope_argument],
41
41
  check=False,
42
42
  )
43
43
  except OSError as launch_error: