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.
- package/.agents/skills/_shared/pr-loop/preflight-proposal.contract.test.mjs +31 -1
- package/.agents/skills/e-code-review/SKILL.md +12 -1
- package/.agents/skills/e-code-review/reference/fix.md +5 -1
- package/.agents/skills/e-code-review/reference/loop.md +4 -0
- package/.agents/skills/e-code-review/reference/mode-contract.test.mjs +66 -0
- package/.agents/skills/e-code-review/reference/preflight-proposal.md +40 -0
- package/.agents/skills/e-code-review/reference/runner-selection.md +1 -0
- package/.agents/skills/pr-cleanup/SKILL.md +109 -11
- package/_shared/pr-loop/scripts/code_rules_gate.py +29 -6
- package/_shared/pr-loop/scripts/code_rules_gate_parts/gate_arguments.py +15 -3
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/code_rules_gate_constants.py +4 -0
- package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +47 -0
- package/docs/CODE_RULES.md +2 -0
- package/hooks/advisory/conftest.py +10 -0
- package/hooks/advisory/refactor_guard.py +250 -144
- package/hooks/advisory/refactor_guard_test_support.py +46 -0
- package/hooks/advisory/test_refactor_guard_advisory.py +171 -0
- package/hooks/advisory/test_refactor_guard_eligibility.py +166 -0
- package/hooks/blocking/block_main_commit.py +66 -33
- package/hooks/blocking/code_rules_blast_radius.py +194 -0
- package/hooks/blocking/code_rules_enforcer.py +12 -0
- package/hooks/blocking/test_block_main_commit.py +145 -0
- package/hooks/blocking/test_code_rules_blast_radius.py +161 -0
- package/hooks/blocking/test_code_rules_enforcer_narrow_edit.py +1 -0
- package/hooks/blocking/test_destructive_command_blocker.py +154 -138
- package/hooks/blocking/test_destructive_command_blocker_deny_mode.py +52 -9
- package/hooks/blocking/test_destructive_command_blocker_patterns.py +133 -0
- package/hooks/blocking/test_precommit_code_rules_gate_native_owner.py +71 -5
- package/hooks/git-hooks/AGENTS.md +1 -1
- package/hooks/git-hooks/git_hooks_constants/__init__.py +1 -0
- package/hooks/git-hooks/post_commit.py +160 -51
- package/hooks/git-hooks/pre_commit.py +3 -3
- package/hooks/git-hooks/test_post_commit.py +203 -0
- package/hooks/git-hooks/test_pre_commit.py +2 -2
- package/hooks/hooks_constants/blast_radius_constants.py +14 -0
- package/hooks/hooks_constants/refactor_guard_constants.py +75 -0
- package/hooks/hooks_constants/test_refactor_guard_constants.py +21 -0
- package/hooks/observability/test_instructions_loaded_logger.py +54 -0
- package/hooks/session/test_plugin_data_dir_cleanup.py +70 -0
- package/hooks/session/test_session_edit_tracker_cleanup.py +16 -3
- package/hooks/validation/mypy_validator.py +213 -80
- package/hooks/validation/test_mypy_validator.py +288 -13
- package/hooks/workflow/auto_formatter.py +225 -93
- package/hooks/workflow/investigation_tracker_reset.py +2 -0
- package/hooks/workflow/test_auto_formatter.py +261 -12
- package/hooks/workflow/test_investigation_tracker_reset.py +90 -0
- package/package.json +1 -1
- 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=
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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(
|
|
59
|
-
|
|
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
|
-
|
|
62
|
-
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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") == "--
|
|
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
|
|
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
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
-
"""
|
|
31
|
-
|
|
32
|
-
|
|
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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
"""
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
|
|
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", "
|
|
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: {
|
|
185
|
+
print(f"Submodule: {repo_dir.name} @ {short_commit_hash}")
|
|
70
186
|
print(f"Parent: {parent_repo}")
|
|
71
187
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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),
|
|
40
|
+
[sys.executable, str(resolved_gate_path), immediate_scope_argument],
|
|
41
41
|
check=False,
|
|
42
42
|
)
|
|
43
43
|
except OSError as launch_error:
|