claude-dev-env 1.26.5 → 1.27.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.
@@ -0,0 +1,146 @@
1
+ #!/usr/bin/env python3
2
+ """Git pre-push hook: run the CODE_RULES gate over commits about to be pushed.
3
+
4
+ Installed to the user's shared git-hooks directory via the claude-dev-env
5
+ installer; git invokes this file as `pre-push` (the installer strips the
6
+ `_` and `.py` suffix when copying into the live hooks path).
7
+
8
+ Protocol: git pre-push provides remote name and URL as argv, then writes
9
+ `<local-ref> <local-sha> <remote-ref> <remote-sha>` lines on stdin. The
10
+ first non-zero remote-sha is used as the gate `--base`, so violations are
11
+ scoped to commits that are not already on the remote. When every remote
12
+ object name is zero (new branch) or stdin is empty, the gate falls back
13
+ to the remote's default branch symbolic ref.
14
+
15
+ Exit codes:
16
+ 0 - commits to be pushed pass the gate (or the gate is not installed).
17
+ 1 - one or more commits introduce blocking violations.
18
+ 2 - unexpected invocation failure (e.g., subprocess could not launch).
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import subprocess
24
+ import sys
25
+ from pathlib import Path
26
+
27
+ from config import (
28
+ ALL_ZEROS_OBJECT_NAME_CHARACTER,
29
+ BASE_REFERENCE_ARGUMENT,
30
+ DEFAULT_REMOTE_BASE_REFERENCE,
31
+ GATE_INFRASTRUCTURE_FAILURE_EXIT_CODE,
32
+ INVOKE_GATE_FAILURE_MESSAGE,
33
+ LOCAL_SHA_FIELD_INDEX,
34
+ MALFORMED_STDIN_LINE_MESSAGE,
35
+ NO_PARSEABLE_STDIN_LINES_MESSAGE,
36
+ NO_PARSEABLE_STDIN_LINES_SENTINEL,
37
+ PRE_PUSH_GATE_SCRIPT_NOT_FOUND_MESSAGE,
38
+ STDIN_LINE_FIELD_COUNT,
39
+ STDIN_READ_FAILURE_MESSAGE,
40
+ STDIN_REMOTE_OBJECT_FIELD_INDEX,
41
+ )
42
+ from gate_utils import is_safe_regular_file, resolve_gate_script_path
43
+
44
+
45
+ def is_all_zeros_object_name(object_name: str) -> bool:
46
+ all_zeros_object_name_character = ALL_ZEROS_OBJECT_NAME_CHARACTER
47
+ stripped_object_name = object_name.strip()
48
+ if not stripped_object_name:
49
+ return True
50
+ return all(
51
+ each_character == all_zeros_object_name_character
52
+ for each_character in stripped_object_name
53
+ )
54
+
55
+
56
+ def resolve_base_reference_from_stdin(stdin_text: str) -> str | None:
57
+ stdin_line_field_count = STDIN_LINE_FIELD_COUNT
58
+ stdin_remote_object_field_index = STDIN_REMOTE_OBJECT_FIELD_INDEX
59
+ local_sha_field_index = LOCAL_SHA_FIELD_INDEX
60
+ default_remote_base_reference = DEFAULT_REMOTE_BASE_REFERENCE
61
+ malformed_stdin_line_message = MALFORMED_STDIN_LINE_MESSAGE
62
+ has_seen_any_valid_line = False
63
+ is_all_valid_lines_deletions = True
64
+ has_stdin_content = False
65
+ for each_line in stdin_text.splitlines():
66
+ stripped_line = each_line.strip()
67
+ if not stripped_line:
68
+ continue
69
+ has_stdin_content = True
70
+ fields = stripped_line.split()
71
+ if len(fields) < stdin_line_field_count:
72
+ print(
73
+ malformed_stdin_line_message.format(line=stripped_line),
74
+ file=sys.stderr,
75
+ )
76
+ continue
77
+ has_seen_any_valid_line = True
78
+ if is_all_zeros_object_name(fields[local_sha_field_index]):
79
+ continue
80
+ is_all_valid_lines_deletions = False
81
+ remote_object_name = fields[stdin_remote_object_field_index]
82
+ if not is_all_zeros_object_name(remote_object_name):
83
+ return remote_object_name
84
+ if has_stdin_content and not has_seen_any_valid_line:
85
+ return NO_PARSEABLE_STDIN_LINES_SENTINEL
86
+ if has_seen_any_valid_line and is_all_valid_lines_deletions:
87
+ return None
88
+ return default_remote_base_reference
89
+
90
+
91
+ def invoke_gate(gate_script_path: Path, base_reference: str) -> int:
92
+ base_reference_argument = BASE_REFERENCE_ARGUMENT
93
+ invoke_gate_failure_message = INVOKE_GATE_FAILURE_MESSAGE
94
+ gate_infrastructure_failure_exit_code = GATE_INFRASTRUCTURE_FAILURE_EXIT_CODE
95
+ try:
96
+ resolved_gate_path = gate_script_path.resolve(strict=True)
97
+ completion = subprocess.run(
98
+ [
99
+ sys.executable,
100
+ str(resolved_gate_path),
101
+ base_reference_argument,
102
+ base_reference,
103
+ ],
104
+ check=False,
105
+ )
106
+ except OSError as launch_error:
107
+ print(
108
+ invoke_gate_failure_message.format(error=launch_error),
109
+ file=sys.stderr,
110
+ )
111
+ return gate_infrastructure_failure_exit_code
112
+ return completion.returncode
113
+
114
+
115
+ def main() -> int:
116
+ stdin_read_failure_message = STDIN_READ_FAILURE_MESSAGE
117
+ gate_infrastructure_failure_exit_code = GATE_INFRASTRUCTURE_FAILURE_EXIT_CODE
118
+ pre_push_gate_script_not_found_message = PRE_PUSH_GATE_SCRIPT_NOT_FOUND_MESSAGE
119
+ no_parseable_stdin_lines_message = NO_PARSEABLE_STDIN_LINES_MESSAGE
120
+ no_parseable_stdin_lines_sentinel = NO_PARSEABLE_STDIN_LINES_SENTINEL
121
+ gate_script_path, exact_allowed_path = resolve_gate_script_path()
122
+ if not is_safe_regular_file(gate_script_path, exact_allowed_path):
123
+ print(
124
+ pre_push_gate_script_not_found_message.format(path=gate_script_path),
125
+ file=sys.stderr,
126
+ )
127
+ return 0
128
+ try:
129
+ stdin_text = sys.stdin.read()
130
+ except OSError as read_error:
131
+ print(
132
+ stdin_read_failure_message.format(error=read_error),
133
+ file=sys.stderr,
134
+ )
135
+ return gate_infrastructure_failure_exit_code
136
+ base_reference = resolve_base_reference_from_stdin(stdin_text)
137
+ if base_reference is None:
138
+ return 0
139
+ if base_reference == no_parseable_stdin_lines_sentinel:
140
+ print(no_parseable_stdin_lines_message, file=sys.stderr)
141
+ return gate_infrastructure_failure_exit_code
142
+ return invoke_gate(gate_script_path, base_reference)
143
+
144
+
145
+ if __name__ == "__main__":
146
+ sys.exit(main())
@@ -0,0 +1,24 @@
1
+ from __future__ import annotations
2
+
3
+ import sys
4
+ from pathlib import Path
5
+
6
+
7
+ SCRIPT_DIRECTORY = Path(__file__).resolve().parent
8
+ if str(SCRIPT_DIRECTORY) not in sys.path:
9
+ sys.path.insert(0, str(SCRIPT_DIRECTORY))
10
+
11
+ import config
12
+
13
+
14
+ def test_pre_push_gate_script_not_found_message_contains_path_placeholder() -> None:
15
+ assert "{path}" in config.PRE_PUSH_GATE_SCRIPT_NOT_FOUND_MESSAGE
16
+
17
+
18
+ def test_no_parseable_stdin_lines_message_exists_and_describes_problem() -> None:
19
+ assert "no parseable stdin lines" in config.NO_PARSEABLE_STDIN_LINES_MESSAGE
20
+
21
+
22
+ def test_no_parseable_stdin_lines_sentinel_is_distinct_sentinel_value() -> None:
23
+ assert config.NO_PARSEABLE_STDIN_LINES_SENTINEL is not None
24
+ assert config.NO_PARSEABLE_STDIN_LINES_SENTINEL != config.DEFAULT_REMOTE_BASE_REFERENCE
@@ -0,0 +1,225 @@
1
+ from __future__ import annotations
2
+
3
+ import sys
4
+ from pathlib import Path
5
+
6
+ import pytest
7
+
8
+
9
+ SCRIPT_DIRECTORY = Path(__file__).resolve().parent
10
+ if str(SCRIPT_DIRECTORY) not in sys.path:
11
+ sys.path.insert(0, str(SCRIPT_DIRECTORY))
12
+
13
+ import gate_utils
14
+
15
+
16
+ def test_resolve_gate_script_path_uses_override_env_var_when_set(
17
+ tmp_path: Path,
18
+ monkeypatch: pytest.MonkeyPatch,
19
+ ) -> None:
20
+ override_path = tmp_path / "override_gate.py"
21
+ override_path.write_text("import sys\nsys.exit(0)\n", encoding="utf-8")
22
+ monkeypatch.setenv("CODE_RULES_GATE_PATH", str(override_path))
23
+
24
+ resolved_path, exact_allowed = gate_utils.resolve_gate_script_path()
25
+
26
+ assert resolved_path == override_path
27
+ assert exact_allowed == override_path
28
+
29
+
30
+ def test_resolve_gate_script_path_defaults_to_claude_home_when_env_var_set(
31
+ tmp_path: Path,
32
+ monkeypatch: pytest.MonkeyPatch,
33
+ ) -> None:
34
+ monkeypatch.delenv("CODE_RULES_GATE_PATH", raising=False)
35
+ monkeypatch.setenv("CLAUDE_HOME", str(tmp_path))
36
+
37
+ resolved_path, exact_allowed = gate_utils.resolve_gate_script_path()
38
+
39
+ expected_path = (
40
+ tmp_path / "skills" / "bugteam" / "scripts" / "bugteam_code_rules_gate.py"
41
+ )
42
+ assert resolved_path == expected_path
43
+ assert exact_allowed is None
44
+
45
+
46
+ def test_resolve_gate_script_path_falls_back_to_home_dot_claude_when_no_env_vars(
47
+ tmp_path: Path,
48
+ monkeypatch: pytest.MonkeyPatch,
49
+ ) -> None:
50
+ monkeypatch.delenv("CODE_RULES_GATE_PATH", raising=False)
51
+ monkeypatch.delenv("CLAUDE_HOME", raising=False)
52
+ monkeypatch.setattr(Path, "home", staticmethod(lambda: tmp_path))
53
+
54
+ resolved_path, exact_allowed = gate_utils.resolve_gate_script_path()
55
+
56
+ expected_path = (
57
+ tmp_path
58
+ / ".claude"
59
+ / "skills"
60
+ / "bugteam"
61
+ / "scripts"
62
+ / "bugteam_code_rules_gate.py"
63
+ )
64
+ assert resolved_path == expected_path
65
+ assert exact_allowed is None
66
+
67
+
68
+ def test_resolve_gate_script_path_resolves_relative_override_to_absolute(
69
+ monkeypatch: pytest.MonkeyPatch,
70
+ ) -> None:
71
+ monkeypatch.setenv("CODE_RULES_GATE_PATH", "relative/gate.py")
72
+
73
+ resolved_path, _ = gate_utils.resolve_gate_script_path()
74
+
75
+ assert resolved_path.is_absolute()
76
+
77
+
78
+ def test_is_safe_regular_file_rejects_sibling_of_override_path(
79
+ tmp_path: Path,
80
+ ) -> None:
81
+ override_gate = tmp_path / "gate.py"
82
+ override_gate.write_text("", encoding="utf-8")
83
+ sibling_script = tmp_path / "attacker_script.py"
84
+ sibling_script.write_text("", encoding="utf-8")
85
+
86
+ is_safe = gate_utils.is_safe_regular_file(sibling_script, override_gate.resolve())
87
+
88
+ assert not is_safe
89
+
90
+
91
+ def test_is_safe_regular_file_accepts_exact_override_path(
92
+ tmp_path: Path,
93
+ ) -> None:
94
+ override_gate = tmp_path / "gate.py"
95
+ override_gate.write_text("", encoding="utf-8")
96
+
97
+ is_safe = gate_utils.is_safe_regular_file(override_gate, override_gate.resolve())
98
+
99
+ assert is_safe
100
+
101
+
102
+ def test_is_safe_regular_file_rejects_claude_home_override_outside_home_dot_claude(
103
+ tmp_path: Path,
104
+ monkeypatch: pytest.MonkeyPatch,
105
+ ) -> None:
106
+ attacker_home = tmp_path / "attacker_home"
107
+ gate_under_attacker_home = (
108
+ attacker_home / "skills" / "bugteam" / "scripts" / "bugteam_code_rules_gate.py"
109
+ )
110
+ gate_under_attacker_home.parent.mkdir(parents=True)
111
+ gate_under_attacker_home.write_text("", encoding="utf-8")
112
+ real_home = tmp_path / "real_home"
113
+ monkeypatch.setattr(Path, "home", staticmethod(lambda: real_home))
114
+
115
+ is_safe = gate_utils.is_safe_regular_file(gate_under_attacker_home, None)
116
+
117
+ assert not is_safe
118
+
119
+
120
+ def test_is_safe_regular_file_accepts_gate_inside_home_dot_claude(
121
+ tmp_path: Path,
122
+ monkeypatch: pytest.MonkeyPatch,
123
+ ) -> None:
124
+ home_dir = tmp_path / "real_home"
125
+ gate_path = (
126
+ home_dir / ".claude" / "skills" / "bugteam" / "scripts" / "bugteam_code_rules_gate.py"
127
+ )
128
+ gate_path.parent.mkdir(parents=True)
129
+ gate_path.write_text("", encoding="utf-8")
130
+ monkeypatch.setattr(Path, "home", staticmethod(lambda: home_dir))
131
+
132
+ is_safe = gate_utils.is_safe_regular_file(gate_path, None)
133
+
134
+ assert is_safe
135
+
136
+
137
+ def test_is_safe_regular_file_rejects_nonexistent_path_under_trusted_prefix(
138
+ tmp_path: Path,
139
+ monkeypatch: pytest.MonkeyPatch,
140
+ ) -> None:
141
+ home_dir = tmp_path / "real_home"
142
+ (home_dir / ".claude").mkdir(parents=True)
143
+ missing_gate_path = (
144
+ home_dir / ".claude" / "skills" / "bugteam" / "scripts" / "missing_gate.py"
145
+ )
146
+ monkeypatch.setattr(Path, "home", staticmethod(lambda: home_dir))
147
+
148
+ is_safe = gate_utils.is_safe_regular_file(missing_gate_path, None)
149
+
150
+ assert not is_safe
151
+
152
+
153
+ def test_is_safe_regular_file_resolves_symlink_before_prefix_check(
154
+ tmp_path: Path,
155
+ monkeypatch: pytest.MonkeyPatch,
156
+ ) -> None:
157
+ home_dir = tmp_path / "home"
158
+ claude_home = home_dir / ".claude"
159
+ claude_home.mkdir(parents=True)
160
+ real_target = tmp_path / "outside_claude" / "evil.py"
161
+ real_target.parent.mkdir(parents=True)
162
+ real_target.write_text("", encoding="utf-8")
163
+ symlink_inside_claude = claude_home / "evil_link.py"
164
+ symlink_inside_claude.symlink_to(real_target)
165
+ monkeypatch.setattr(Path, "home", staticmethod(lambda: home_dir))
166
+
167
+ is_safe = gate_utils.is_safe_regular_file(symlink_inside_claude, None)
168
+
169
+ assert not is_safe
170
+
171
+
172
+ def test_is_safe_regular_file_uses_claude_home_env_as_trust_root(
173
+ tmp_path: Path,
174
+ monkeypatch: pytest.MonkeyPatch,
175
+ ) -> None:
176
+ custom_claude_home = tmp_path / "custom_claude"
177
+ gate_path = (
178
+ custom_claude_home / "skills" / "bugteam" / "scripts" / "bugteam_code_rules_gate.py"
179
+ )
180
+ gate_path.parent.mkdir(parents=True)
181
+ gate_path.write_text("", encoding="utf-8")
182
+ monkeypatch.delenv("CODE_RULES_GATE_PATH", raising=False)
183
+ monkeypatch.setenv("CLAUDE_HOME", str(custom_claude_home))
184
+
185
+ gate_script_path, exact_allowed = gate_utils.resolve_gate_script_path()
186
+
187
+ is_safe = gate_utils.is_safe_regular_file(gate_script_path, exact_allowed)
188
+
189
+ assert is_safe
190
+
191
+
192
+ def test_resolve_gate_script_path_snapshot_is_consistent_with_is_safe_regular_file(
193
+ tmp_path: Path,
194
+ monkeypatch: pytest.MonkeyPatch,
195
+ ) -> None:
196
+ custom_claude_home = tmp_path / "custom_claude"
197
+ gate_path = (
198
+ custom_claude_home / "skills" / "bugteam" / "scripts" / "bugteam_code_rules_gate.py"
199
+ )
200
+ gate_path.parent.mkdir(parents=True)
201
+ gate_path.write_text("", encoding="utf-8")
202
+ monkeypatch.delenv("CODE_RULES_GATE_PATH", raising=False)
203
+ monkeypatch.setenv("CLAUDE_HOME", str(custom_claude_home))
204
+
205
+ resolved_path, exact_allowed = gate_utils.resolve_gate_script_path()
206
+ is_safe = gate_utils.is_safe_regular_file(resolved_path, exact_allowed)
207
+
208
+ assert is_safe
209
+
210
+
211
+ def test_is_safe_regular_file_rejects_path_outside_claude_home_env_trust_root(
212
+ tmp_path: Path,
213
+ monkeypatch: pytest.MonkeyPatch,
214
+ ) -> None:
215
+ custom_claude_home = tmp_path / "custom_claude"
216
+ custom_claude_home.mkdir(parents=True)
217
+ outside_path = tmp_path / "outside" / "gate.py"
218
+ outside_path.parent.mkdir(parents=True)
219
+ outside_path.write_text("", encoding="utf-8")
220
+ monkeypatch.delenv("CODE_RULES_GATE_PATH", raising=False)
221
+ monkeypatch.setenv("CLAUDE_HOME", str(custom_claude_home))
222
+
223
+ is_safe = gate_utils.is_safe_regular_file(outside_path, None)
224
+
225
+ assert not is_safe
@@ -0,0 +1,179 @@
1
+ from __future__ import annotations
2
+
3
+ import sys
4
+ from pathlib import Path
5
+
6
+ import pytest
7
+
8
+
9
+ SCRIPT_DIRECTORY = Path(__file__).resolve().parent
10
+ if str(SCRIPT_DIRECTORY) not in sys.path:
11
+ sys.path.insert(0, str(SCRIPT_DIRECTORY))
12
+
13
+ import pre_commit
14
+
15
+
16
+ def make_gate_script_returning(exit_code: int, target_path: Path) -> Path:
17
+ target_path.write_text(
18
+ f"import sys\nsys.exit({exit_code})\n",
19
+ encoding="utf-8",
20
+ )
21
+ return target_path
22
+
23
+
24
+ @pytest.fixture()
25
+ def fake_gate_script_blocking(tmp_path: Path) -> Path:
26
+ return make_gate_script_returning(1, tmp_path / "fake_gate_blocking.py")
27
+
28
+
29
+ @pytest.fixture()
30
+ def fake_gate_script_passing(tmp_path: Path) -> Path:
31
+ return make_gate_script_returning(0, tmp_path / "fake_gate_passing.py")
32
+
33
+
34
+ def test_main_exits_zero_when_gate_script_missing(
35
+ tmp_path: Path,
36
+ monkeypatch: pytest.MonkeyPatch,
37
+ ) -> None:
38
+ monkeypatch.setenv(
39
+ "CODE_RULES_GATE_PATH",
40
+ str(tmp_path / "does_not_exist.py"),
41
+ )
42
+
43
+ exit_code = pre_commit.main()
44
+
45
+ assert exit_code == 0
46
+
47
+
48
+ def test_main_propagates_blocking_exit_code_from_gate(
49
+ fake_gate_script_blocking: Path,
50
+ monkeypatch: pytest.MonkeyPatch,
51
+ ) -> None:
52
+ monkeypatch.setenv("CODE_RULES_GATE_PATH", str(fake_gate_script_blocking))
53
+
54
+ exit_code = pre_commit.main()
55
+
56
+ assert exit_code == 1
57
+
58
+
59
+ def test_main_propagates_passing_exit_code_from_gate(
60
+ fake_gate_script_passing: Path,
61
+ monkeypatch: pytest.MonkeyPatch,
62
+ ) -> None:
63
+ monkeypatch.setenv("CODE_RULES_GATE_PATH", str(fake_gate_script_passing))
64
+
65
+ exit_code = pre_commit.main()
66
+
67
+ assert exit_code == 0
68
+
69
+
70
+ def test_main_invokes_gate_with_staged_flag(
71
+ tmp_path: Path,
72
+ monkeypatch: pytest.MonkeyPatch,
73
+ ) -> None:
74
+ recorded_arguments_path = tmp_path / "recorded_arguments.txt"
75
+ recording_gate_script_path = tmp_path / "recording_gate.py"
76
+ recording_gate_script_path.write_text(
77
+ "import sys, pathlib\n"
78
+ f'pathlib.Path(r"{recorded_arguments_path}").write_text('
79
+ "'\\n'.join(sys.argv[1:]), encoding='utf-8')\n"
80
+ "sys.exit(0)\n",
81
+ encoding="utf-8",
82
+ )
83
+ monkeypatch.setenv("CODE_RULES_GATE_PATH", str(recording_gate_script_path))
84
+
85
+ exit_code = pre_commit.main()
86
+
87
+ assert exit_code == 0
88
+ assert recorded_arguments_path.exists(), (
89
+ f"recording gate did not write to {recorded_arguments_path}"
90
+ )
91
+ recorded_arguments = recorded_arguments_path.read_text(
92
+ encoding="utf-8"
93
+ ).splitlines()
94
+ assert recorded_arguments == ["--staged"]
95
+
96
+
97
+ def test_main_exits_two_when_invoke_gate_raises_oserror(
98
+ tmp_path: Path,
99
+ monkeypatch: pytest.MonkeyPatch,
100
+ ) -> None:
101
+ existing_gate_path = tmp_path / "gate.py"
102
+ existing_gate_path.write_text("import sys\nsys.exit(0)\n", encoding="utf-8")
103
+ monkeypatch.setenv("CODE_RULES_GATE_PATH", str(existing_gate_path))
104
+
105
+ original_run = __import__("subprocess").run
106
+
107
+ def raising_run(*args: object, **kwargs: object) -> object:
108
+ raise OSError("no such file")
109
+
110
+ monkeypatch.setattr(__import__("subprocess"), "run", raising_run)
111
+
112
+ exit_code = pre_commit.main()
113
+
114
+ assert exit_code == 2
115
+
116
+
117
+ def test_main_emits_stderr_warning_when_gate_script_missing(
118
+ tmp_path: Path,
119
+ monkeypatch: pytest.MonkeyPatch,
120
+ capsys: pytest.CaptureFixture[str],
121
+ ) -> None:
122
+ monkeypatch.setenv(
123
+ "CODE_RULES_GATE_PATH",
124
+ str(tmp_path / "does_not_exist.py"),
125
+ )
126
+
127
+ exit_code = pre_commit.main()
128
+
129
+ assert exit_code == 0
130
+ captured = capsys.readouterr()
131
+ assert "gate script not found" in captured.err
132
+
133
+
134
+ def test_invoke_gate_returns_infrastructure_failure_when_strict_resolve_raises(
135
+ tmp_path: Path,
136
+ monkeypatch: pytest.MonkeyPatch,
137
+ ) -> None:
138
+ missing_gate_path = tmp_path / "missing_gate.py"
139
+ monkeypatch.setenv("CODE_RULES_GATE_PATH", str(missing_gate_path))
140
+ missing_gate_path.write_text("import sys\nsys.exit(0)\n", encoding="utf-8")
141
+
142
+ original_resolve = Path.resolve
143
+
144
+ def raising_resolve(self: Path, strict: bool = False) -> Path:
145
+ if strict and self == missing_gate_path.resolve():
146
+ raise FileNotFoundError("not found")
147
+ return original_resolve(self, strict=strict)
148
+
149
+ monkeypatch.setattr(Path, "resolve", raising_resolve)
150
+
151
+ exit_code = pre_commit.invoke_gate(missing_gate_path)
152
+
153
+ assert exit_code == 2
154
+
155
+
156
+ def test_invoke_gate_uses_resolved_path(
157
+ tmp_path: Path,
158
+ monkeypatch: pytest.MonkeyPatch,
159
+ ) -> None:
160
+ real_gate_dir = tmp_path / "real"
161
+ real_gate_dir.mkdir()
162
+ real_gate_path = real_gate_dir / "gate.py"
163
+ recorded_path_file = tmp_path / "recorded_path.txt"
164
+ real_gate_path.write_text(
165
+ "import sys, pathlib\n"
166
+ f'pathlib.Path(r"{recorded_path_file}").write_text(sys.argv[0], encoding="utf-8")\n'
167
+ "sys.exit(0)\n",
168
+ encoding="utf-8",
169
+ )
170
+ symlink_gate_path = tmp_path / "link_gate.py"
171
+ symlink_gate_path.symlink_to(real_gate_path)
172
+ resolved_path = symlink_gate_path.resolve()
173
+ monkeypatch.setenv("CODE_RULES_GATE_PATH", str(symlink_gate_path))
174
+
175
+ exit_code = pre_commit.main()
176
+
177
+ assert exit_code == 0
178
+ executed_path = recorded_path_file.read_text(encoding="utf-8")
179
+ assert executed_path == str(resolved_path)