claude-dev-env 4.0.0 → 4.2.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/everything-search/SKILL.md +18 -3
- package/.agents/skills/everything-search/scripts/everything_search.py +204 -0
- package/.agents/skills/everything-search/scripts/everything_search_command_constants/__init__.py +1 -0
- package/.agents/skills/everything-search/scripts/everything_search_command_constants/config/__init__.py +1 -0
- package/.agents/skills/everything-search/scripts/everything_search_command_constants/config/constants.py +11 -0
- package/.agents/skills/everything-search/scripts/test_everything_search.py +172 -0
- package/.agents/skills/syncing-submodules/SKILL.md +101 -0
- package/.agents/skills/syncing-submodules/reference/run-tasks.md +9 -0
- package/.agents/skills/syncing-submodules/scripts/submodule_sync.py +383 -0
- package/.agents/skills/syncing-submodules/scripts/submodule_sync_constants/__init__.py +1 -0
- package/.agents/skills/syncing-submodules/scripts/submodule_sync_constants/config/__init__.py +1 -0
- package/.agents/skills/syncing-submodules/scripts/submodule_sync_constants/config/constants.py +66 -0
- package/.agents/skills/syncing-submodules/scripts/sync_parent_pointer.py +88 -0
- package/.agents/skills/syncing-submodules/scripts/test_submodule_sync.py +303 -0
- package/.agents/skills/syncing-submodules/scripts/test_sync_parent_pointer.py +83 -0
- package/bin/ever-shipped-skills.mjs +1 -0
- package/bin/ever-shipped-skills.test.mjs +30 -0
- package/bin/git_hooks_installer.mjs +0 -1
- package/bin/git_hooks_installer.test.mjs +16 -1
- package/bin/install.mjs +5 -2
- package/bin/install.prune.test.mjs +95 -24
- package/bin/install.test.mjs +7 -0
- package/hooks/hooks_constants/bash_pre_tool_use_dispatcher_constants.py +5 -8
- package/hooks/hooks_constants/test_bash_pre_tool_use_dispatcher_constants.py +7 -13
- package/package.json +1 -1
- package/rules/filesystem-search.md +5 -5
- package/hooks/blocking/es_exe_path_rewriter.py +0 -159
- package/hooks/blocking/test_es_exe_path_rewriter.py +0 -447
- package/hooks/git-hooks/post_commit.py +0 -241
- package/hooks/git-hooks/test_post_commit.py +0 -298
- package/hooks/hooks_constants/path_rewriter_constants.py +0 -13
- package/hooks/hooks_constants/test_path_rewriter_constants.py +0 -57
|
@@ -1,447 +0,0 @@
|
|
|
1
|
-
"""Tests for es_exe_path_rewriter — PreToolUse hook that rewrites es.exe paths."""
|
|
2
|
-
|
|
3
|
-
import json
|
|
4
|
-
import sys
|
|
5
|
-
from io import StringIO
|
|
6
|
-
from pathlib import Path
|
|
7
|
-
from unittest.mock import patch
|
|
8
|
-
|
|
9
|
-
import pytest
|
|
10
|
-
|
|
11
|
-
_BLOCKING_DIR = Path(__file__).resolve().parent
|
|
12
|
-
_HOOKS_ROOT = _BLOCKING_DIR.parent
|
|
13
|
-
for each_sys_path_entry in (str(_BLOCKING_DIR), str(_HOOKS_ROOT)):
|
|
14
|
-
if each_sys_path_entry not in sys.path:
|
|
15
|
-
sys.path.insert(0, each_sys_path_entry)
|
|
16
|
-
|
|
17
|
-
import es_exe_path_rewriter as rewriter
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
def test_rewriter_does_not_redefine_dynamic_stderr_handler_locally() -> None:
|
|
21
|
-
"""Pin PR #230 round 3 DRY fix: handler is imported from the shared module.
|
|
22
|
-
|
|
23
|
-
Both project_paths_reader and es_exe_path_rewriter previously defined
|
|
24
|
-
identical `_DynamicStderrHandler` classes. This test fails if the
|
|
25
|
-
duplicate class reappears in es_exe_path_rewriter.
|
|
26
|
-
"""
|
|
27
|
-
assert not hasattr(rewriter, "_DynamicStderrHandler")
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
REGISTRY_WITH_ONE_REPO = {"my-repo": "Y:\\Projects\\my-repo"}
|
|
31
|
-
REGISTRY_WITH_TWO_REPOS = {
|
|
32
|
-
"my-repo": "Y:\\Projects\\my-repo",
|
|
33
|
-
"other-repo": "C:\\Dev\\other-repo",
|
|
34
|
-
}
|
|
35
|
-
ABSOLUTE_PATH_ARGUMENT = "Y:\\Projects\\already-absolute\\file.py"
|
|
36
|
-
KNOWN_REPO_NAME = "my-repo"
|
|
37
|
-
KNOWN_REPO_PATH = "Y:\\Projects\\my-repo"
|
|
38
|
-
OTHER_REPO_NAME = "other-repo"
|
|
39
|
-
OTHER_REPO_PATH = "C:\\Dev\\other-repo"
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
def _run_main_with_stdin_text(stdin_text: str) -> tuple[str, str, int]:
|
|
43
|
-
"""Return (stdout, stderr, exit_code) from running main() with the given stdin text."""
|
|
44
|
-
captured_stdout = StringIO()
|
|
45
|
-
captured_stderr = StringIO()
|
|
46
|
-
exit_code = 0
|
|
47
|
-
try:
|
|
48
|
-
with (
|
|
49
|
-
patch("sys.stdin", StringIO(stdin_text)),
|
|
50
|
-
patch("sys.stdout", captured_stdout),
|
|
51
|
-
patch("sys.stderr", captured_stderr),
|
|
52
|
-
):
|
|
53
|
-
rewriter.main()
|
|
54
|
-
except SystemExit as e:
|
|
55
|
-
exit_code = e.code or 0
|
|
56
|
-
return captured_stdout.getvalue(), captured_stderr.getvalue(), exit_code
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
def _run_main_with_input(hook_input: dict) -> tuple[str, str, int]:
|
|
60
|
-
"""Return (stdout, stderr, exit_code) from running main() with the given hook input."""
|
|
61
|
-
return _run_main_with_stdin_text(json.dumps(hook_input))
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
def _make_bash_input(command: str, description: str = "search files") -> dict:
|
|
65
|
-
return {
|
|
66
|
-
"tool_name": "Bash",
|
|
67
|
-
"tool_input": {"command": command, "description": description},
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
class TestTriggerRegex:
|
|
72
|
-
def test_matches_bare_es_exe(self) -> None:
|
|
73
|
-
assert rewriter.command_invokes_es_exe("es.exe my-repo")
|
|
74
|
-
|
|
75
|
-
def test_matches_everything_forward_slash_path(self) -> None:
|
|
76
|
-
assert rewriter.command_invokes_es_exe("Everything/es.exe my-repo")
|
|
77
|
-
|
|
78
|
-
def test_matches_everything_backslash_path(self) -> None:
|
|
79
|
-
assert rewriter.command_invokes_es_exe("Everything\\es.exe my-repo")
|
|
80
|
-
|
|
81
|
-
def test_does_not_match_unrelated_command(self) -> None:
|
|
82
|
-
assert not rewriter.command_invokes_es_exe("git status")
|
|
83
|
-
|
|
84
|
-
def test_does_not_match_es_exe_inside_longer_word(self) -> None:
|
|
85
|
-
assert not rewriter.command_invokes_es_exe("not_es.exe_here")
|
|
86
|
-
|
|
87
|
-
def test_matches_case_insensitively(self) -> None:
|
|
88
|
-
assert rewriter.command_invokes_es_exe("ES.EXE my-repo")
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
class TestRewriteCommand:
|
|
92
|
-
def test_bare_token_rewrite_substitutes_registry_path(self) -> None:
|
|
93
|
-
rewritten = rewriter.rewrite_command(
|
|
94
|
-
"es.exe my-repo config.py", REGISTRY_WITH_ONE_REPO
|
|
95
|
-
)
|
|
96
|
-
assert rewritten == f'es.exe "{KNOWN_REPO_PATH}" config.py'
|
|
97
|
-
|
|
98
|
-
def test_placeholder_token_rewrite_substitutes_registry_path(self) -> None:
|
|
99
|
-
rewritten = rewriter.rewrite_command(
|
|
100
|
-
'es.exe "{my-repo}" config.py', REGISTRY_WITH_ONE_REPO
|
|
101
|
-
)
|
|
102
|
-
assert rewritten == f'es.exe "{KNOWN_REPO_PATH}" config.py'
|
|
103
|
-
|
|
104
|
-
def test_single_quoted_placeholder_rewrite_substitutes_registry_path(self) -> None:
|
|
105
|
-
rewritten = rewriter.rewrite_command(
|
|
106
|
-
"es.exe '{my-repo}' config.py", REGISTRY_WITH_ONE_REPO
|
|
107
|
-
)
|
|
108
|
-
assert rewritten == f'es.exe "{KNOWN_REPO_PATH}" config.py'
|
|
109
|
-
|
|
110
|
-
def test_placeholder_without_quotes_rewrite_substitutes_registry_path(self) -> None:
|
|
111
|
-
rewritten = rewriter.rewrite_command(
|
|
112
|
-
"es.exe {my-repo} config.py", REGISTRY_WITH_ONE_REPO
|
|
113
|
-
)
|
|
114
|
-
assert rewritten == f'es.exe "{KNOWN_REPO_PATH}" config.py'
|
|
115
|
-
|
|
116
|
-
def test_absolute_path_argument_is_never_modified(self) -> None:
|
|
117
|
-
command = f'es.exe "{ABSOLUTE_PATH_ARGUMENT}" config.py'
|
|
118
|
-
rewritten = rewriter.rewrite_command(command, REGISTRY_WITH_ONE_REPO)
|
|
119
|
-
assert rewritten == command
|
|
120
|
-
|
|
121
|
-
def test_unknown_token_is_never_touched(self) -> None:
|
|
122
|
-
command = "es.exe unknown-name config.py"
|
|
123
|
-
rewritten = rewriter.rewrite_command(command, REGISTRY_WITH_ONE_REPO)
|
|
124
|
-
assert rewritten == command
|
|
125
|
-
|
|
126
|
-
def test_multiple_tokens_all_rewrite(self) -> None:
|
|
127
|
-
command = "es.exe my-repo other-repo config.py"
|
|
128
|
-
rewritten = rewriter.rewrite_command(command, REGISTRY_WITH_TWO_REPOS)
|
|
129
|
-
assert rewritten == f'es.exe "{KNOWN_REPO_PATH}" "{OTHER_REPO_PATH}" config.py'
|
|
130
|
-
|
|
131
|
-
def test_empty_registry_returns_unchanged_command(self) -> None:
|
|
132
|
-
command = "es.exe my-repo config.py"
|
|
133
|
-
rewritten = rewriter.rewrite_command(command, {})
|
|
134
|
-
assert rewritten == command
|
|
135
|
-
|
|
136
|
-
def test_double_spaces_inside_quoted_arg_pass_through_unchanged_on_no_registry_hit(
|
|
137
|
-
self,
|
|
138
|
-
) -> None:
|
|
139
|
-
command = 'es.exe "foo bar" baz'
|
|
140
|
-
rewritten = rewriter.rewrite_command(command, REGISTRY_WITH_ONE_REPO)
|
|
141
|
-
assert rewritten == command
|
|
142
|
-
|
|
143
|
-
def test_tab_separator_is_preserved_when_bare_token_is_rewritten(self) -> None:
|
|
144
|
-
registry = {"my-repo": "Y:\\Projects\\my-repo"}
|
|
145
|
-
command = "es.exe my-repo\tconfig.py"
|
|
146
|
-
rewritten = rewriter.rewrite_command(command, registry)
|
|
147
|
-
assert rewritten == f'es.exe "Y:\\Projects\\my-repo"\tconfig.py'
|
|
148
|
-
|
|
149
|
-
def test_multiple_substitutions_preserve_all_inter_token_whitespace(self) -> None:
|
|
150
|
-
command = "es.exe my-repo other-repo config.py"
|
|
151
|
-
rewritten = rewriter.rewrite_command(command, REGISTRY_WITH_TWO_REPOS)
|
|
152
|
-
assert rewritten == f'es.exe "{KNOWN_REPO_PATH}" "{OTHER_REPO_PATH}" config.py'
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
class TestEmittedJsonShape:
|
|
156
|
-
def test_emitted_json_has_correct_hook_event_name(self) -> None:
|
|
157
|
-
hook_input = _make_bash_input(f"es.exe {KNOWN_REPO_NAME} config.py")
|
|
158
|
-
with patch(
|
|
159
|
-
"es_exe_path_rewriter.load_registry", return_value=REGISTRY_WITH_ONE_REPO
|
|
160
|
-
):
|
|
161
|
-
stdout, _, _ = _run_main_with_input(hook_input)
|
|
162
|
-
emitted = json.loads(stdout)
|
|
163
|
-
assert emitted["hookSpecificOutput"]["hookEventName"] == "PreToolUse"
|
|
164
|
-
|
|
165
|
-
def test_emitted_json_has_allow_permission_decision(self) -> None:
|
|
166
|
-
hook_input = _make_bash_input(f"es.exe {KNOWN_REPO_NAME} config.py")
|
|
167
|
-
with patch(
|
|
168
|
-
"es_exe_path_rewriter.load_registry", return_value=REGISTRY_WITH_ONE_REPO
|
|
169
|
-
):
|
|
170
|
-
stdout, _, _ = _run_main_with_input(hook_input)
|
|
171
|
-
emitted = json.loads(stdout)
|
|
172
|
-
assert emitted["hookSpecificOutput"]["permissionDecision"] == "allow"
|
|
173
|
-
|
|
174
|
-
def test_emitted_json_has_rewritten_command(self) -> None:
|
|
175
|
-
hook_input = _make_bash_input(f"es.exe {KNOWN_REPO_NAME} config.py")
|
|
176
|
-
with patch(
|
|
177
|
-
"es_exe_path_rewriter.load_registry", return_value=REGISTRY_WITH_ONE_REPO
|
|
178
|
-
):
|
|
179
|
-
stdout, _, _ = _run_main_with_input(hook_input)
|
|
180
|
-
emitted = json.loads(stdout)
|
|
181
|
-
updated_command = emitted["hookSpecificOutput"]["updatedInput"]["command"]
|
|
182
|
-
assert updated_command == f'es.exe "{KNOWN_REPO_PATH}" config.py'
|
|
183
|
-
|
|
184
|
-
def test_description_field_round_trips_unchanged(self) -> None:
|
|
185
|
-
original_description = "my special search description"
|
|
186
|
-
hook_input = _make_bash_input(
|
|
187
|
-
f"es.exe {KNOWN_REPO_NAME} config.py", original_description
|
|
188
|
-
)
|
|
189
|
-
with patch(
|
|
190
|
-
"es_exe_path_rewriter.load_registry", return_value=REGISTRY_WITH_ONE_REPO
|
|
191
|
-
):
|
|
192
|
-
stdout, _, _ = _run_main_with_input(hook_input)
|
|
193
|
-
emitted = json.loads(stdout)
|
|
194
|
-
assert (
|
|
195
|
-
emitted["hookSpecificOutput"]["updatedInput"]["description"]
|
|
196
|
-
== original_description
|
|
197
|
-
)
|
|
198
|
-
|
|
199
|
-
def test_additional_unknown_fields_pass_through_into_updated_input(self) -> None:
|
|
200
|
-
hook_input = {
|
|
201
|
-
"tool_name": "Bash",
|
|
202
|
-
"tool_input": {
|
|
203
|
-
"command": f"es.exe {KNOWN_REPO_NAME} config.py",
|
|
204
|
-
"description": "search",
|
|
205
|
-
"extra_field": "extra_value",
|
|
206
|
-
},
|
|
207
|
-
}
|
|
208
|
-
with patch(
|
|
209
|
-
"es_exe_path_rewriter.load_registry", return_value=REGISTRY_WITH_ONE_REPO
|
|
210
|
-
):
|
|
211
|
-
stdout, _, _ = _run_main_with_input(hook_input)
|
|
212
|
-
emitted = json.loads(stdout)
|
|
213
|
-
assert (
|
|
214
|
-
emitted["hookSpecificOutput"]["updatedInput"]["extra_field"]
|
|
215
|
-
== "extra_value"
|
|
216
|
-
)
|
|
217
|
-
|
|
218
|
-
def test_no_code_path_returns_deny_decision(self) -> None:
|
|
219
|
-
for command in [
|
|
220
|
-
f"es.exe {KNOWN_REPO_NAME} config.py",
|
|
221
|
-
"es.exe unknown-token config.py",
|
|
222
|
-
"git status",
|
|
223
|
-
]:
|
|
224
|
-
hook_input = _make_bash_input(command)
|
|
225
|
-
with patch(
|
|
226
|
-
"es_exe_path_rewriter.load_registry",
|
|
227
|
-
return_value=REGISTRY_WITH_ONE_REPO,
|
|
228
|
-
):
|
|
229
|
-
stdout, _, _ = _run_main_with_input(hook_input)
|
|
230
|
-
if stdout.strip():
|
|
231
|
-
emitted = json.loads(stdout)
|
|
232
|
-
decision = emitted.get("hookSpecificOutput", {}).get(
|
|
233
|
-
"permissionDecision", ""
|
|
234
|
-
)
|
|
235
|
-
assert decision != "deny", f"deny returned for command: {command!r}"
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
class TestStdinParsingRobustness:
|
|
239
|
-
def test_empty_stdin_exits_zero_without_stdout_or_stderr(self) -> None:
|
|
240
|
-
stdout, stderr, exit_code = _run_main_with_stdin_text("")
|
|
241
|
-
assert exit_code == 0
|
|
242
|
-
assert stdout.strip() == ""
|
|
243
|
-
assert stderr.strip() == ""
|
|
244
|
-
|
|
245
|
-
def test_whitespace_only_stdin_exits_zero_without_stdout_or_stderr(self) -> None:
|
|
246
|
-
stdout, stderr, exit_code = _run_main_with_stdin_text(" \n\t ")
|
|
247
|
-
assert exit_code == 0
|
|
248
|
-
assert stdout.strip() == ""
|
|
249
|
-
assert stderr.strip() == ""
|
|
250
|
-
|
|
251
|
-
def test_invalid_json_stdin_exits_zero_without_stdout_or_stderr(self) -> None:
|
|
252
|
-
stdout, stderr, exit_code = _run_main_with_stdin_text("{not valid")
|
|
253
|
-
assert exit_code == 0
|
|
254
|
-
assert stdout.strip() == ""
|
|
255
|
-
assert stderr.strip() == ""
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
class TestMalformedHookPayloadTypes:
|
|
259
|
-
def test_null_tool_input_exits_zero_without_stdout_or_stderr(self) -> None:
|
|
260
|
-
hook_input = {
|
|
261
|
-
"tool_name": "Bash",
|
|
262
|
-
"tool_input": None,
|
|
263
|
-
}
|
|
264
|
-
with patch(
|
|
265
|
-
"es_exe_path_rewriter.load_registry", return_value=REGISTRY_WITH_ONE_REPO
|
|
266
|
-
):
|
|
267
|
-
stdout, stderr, exit_code = _run_main_with_input(hook_input)
|
|
268
|
-
assert exit_code == 0
|
|
269
|
-
assert stdout.strip() == ""
|
|
270
|
-
assert stderr.strip() == ""
|
|
271
|
-
|
|
272
|
-
def test_list_tool_input_exits_zero_without_stdout_or_stderr(self) -> None:
|
|
273
|
-
hook_input = {
|
|
274
|
-
"tool_name": "Bash",
|
|
275
|
-
"tool_input": ["es.exe", KNOWN_REPO_NAME],
|
|
276
|
-
}
|
|
277
|
-
with patch(
|
|
278
|
-
"es_exe_path_rewriter.load_registry", return_value=REGISTRY_WITH_ONE_REPO
|
|
279
|
-
):
|
|
280
|
-
stdout, stderr, exit_code = _run_main_with_input(hook_input)
|
|
281
|
-
assert exit_code == 0
|
|
282
|
-
assert stdout.strip() == ""
|
|
283
|
-
assert stderr.strip() == ""
|
|
284
|
-
|
|
285
|
-
def test_string_tool_input_exits_zero_without_stdout_or_stderr(self) -> None:
|
|
286
|
-
hook_input = {
|
|
287
|
-
"tool_name": "Bash",
|
|
288
|
-
"tool_input": f"es.exe {KNOWN_REPO_NAME}",
|
|
289
|
-
}
|
|
290
|
-
with patch(
|
|
291
|
-
"es_exe_path_rewriter.load_registry", return_value=REGISTRY_WITH_ONE_REPO
|
|
292
|
-
):
|
|
293
|
-
stdout, stderr, exit_code = _run_main_with_input(hook_input)
|
|
294
|
-
assert exit_code == 0
|
|
295
|
-
assert stdout.strip() == ""
|
|
296
|
-
assert stderr.strip() == ""
|
|
297
|
-
|
|
298
|
-
def test_non_string_command_exits_zero_without_stdout_or_stderr(self) -> None:
|
|
299
|
-
hook_input = {
|
|
300
|
-
"tool_name": "Bash",
|
|
301
|
-
"tool_input": {"command": 12345, "description": "search"},
|
|
302
|
-
}
|
|
303
|
-
with patch(
|
|
304
|
-
"es_exe_path_rewriter.load_registry", return_value=REGISTRY_WITH_ONE_REPO
|
|
305
|
-
):
|
|
306
|
-
stdout, stderr, exit_code = _run_main_with_input(hook_input)
|
|
307
|
-
assert exit_code == 0
|
|
308
|
-
assert stdout.strip() == ""
|
|
309
|
-
assert stderr.strip() == ""
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
class TestNoOutputCases:
|
|
313
|
-
def test_non_es_exe_command_produces_no_output(self) -> None:
|
|
314
|
-
hook_input = _make_bash_input("git status")
|
|
315
|
-
with patch(
|
|
316
|
-
"es_exe_path_rewriter.load_registry", return_value=REGISTRY_WITH_ONE_REPO
|
|
317
|
-
):
|
|
318
|
-
stdout, _, _ = _run_main_with_input(hook_input)
|
|
319
|
-
assert stdout.strip() == ""
|
|
320
|
-
|
|
321
|
-
def test_empty_registry_produces_no_output(self) -> None:
|
|
322
|
-
hook_input = _make_bash_input(f"es.exe {KNOWN_REPO_NAME} config.py")
|
|
323
|
-
with patch("es_exe_path_rewriter.load_registry", return_value={}):
|
|
324
|
-
stdout, _, _ = _run_main_with_input(hook_input)
|
|
325
|
-
assert stdout.strip() == ""
|
|
326
|
-
|
|
327
|
-
def test_unchanged_command_produces_no_output(self) -> None:
|
|
328
|
-
hook_input = _make_bash_input("es.exe unknown-token config.py")
|
|
329
|
-
with patch(
|
|
330
|
-
"es_exe_path_rewriter.load_registry", return_value=REGISTRY_WITH_ONE_REPO
|
|
331
|
-
):
|
|
332
|
-
stdout, _, _ = _run_main_with_input(hook_input)
|
|
333
|
-
assert stdout.strip() == ""
|
|
334
|
-
|
|
335
|
-
def test_malformed_registry_produces_no_output_and_one_stderr_line(self) -> None:
|
|
336
|
-
hook_input = _make_bash_input(f"es.exe {KNOWN_REPO_NAME} config.py")
|
|
337
|
-
with patch(
|
|
338
|
-
"es_exe_path_rewriter.load_registry",
|
|
339
|
-
side_effect=Exception("simulated read error"),
|
|
340
|
-
):
|
|
341
|
-
stdout, stderr, _ = _run_main_with_input(hook_input)
|
|
342
|
-
assert stdout.strip() == ""
|
|
343
|
-
assert stderr.strip() != ""
|
|
344
|
-
assert stderr.strip().count("\n") == 0
|
|
345
|
-
|
|
346
|
-
def test_non_bash_tool_produces_no_output(self) -> None:
|
|
347
|
-
hook_input = {
|
|
348
|
-
"tool_name": "Write",
|
|
349
|
-
"tool_input": {"command": f"es.exe {KNOWN_REPO_NAME}"},
|
|
350
|
-
}
|
|
351
|
-
with patch(
|
|
352
|
-
"es_exe_path_rewriter.load_registry", return_value=REGISTRY_WITH_ONE_REPO
|
|
353
|
-
):
|
|
354
|
-
stdout, _, _ = _run_main_with_input(hook_input)
|
|
355
|
-
assert stdout.strip() == ""
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
class TestQuoteAwareTokenizer:
|
|
359
|
-
def test_double_quoted_multiword_arg_with_registry_key_prefix_is_not_substituted(
|
|
360
|
-
self,
|
|
361
|
-
) -> None:
|
|
362
|
-
registry = {"my-repo": "Y:\\Projects\\my-repo"}
|
|
363
|
-
command = 'es.exe "my-repo foo" baz'
|
|
364
|
-
rewritten = rewriter.rewrite_command(command, registry)
|
|
365
|
-
assert rewritten == command
|
|
366
|
-
|
|
367
|
-
def test_single_quoted_multiword_arg_with_registry_key_prefix_is_not_substituted(
|
|
368
|
-
self,
|
|
369
|
-
) -> None:
|
|
370
|
-
registry = {"my-repo": "Y:\\Projects\\my-repo"}
|
|
371
|
-
command = "es.exe 'my-repo baz' config.py"
|
|
372
|
-
rewritten = rewriter.rewrite_command(command, registry)
|
|
373
|
-
assert rewritten == command
|
|
374
|
-
|
|
375
|
-
def test_bare_token_matching_registry_key_is_rewritten(self) -> None:
|
|
376
|
-
registry = {"my-repo": "Y:\\Projects\\my-repo"}
|
|
377
|
-
command = "es.exe my-repo config.py"
|
|
378
|
-
rewritten = rewriter.rewrite_command(command, registry)
|
|
379
|
-
assert rewritten == f'es.exe "Y:\\Projects\\my-repo" config.py'
|
|
380
|
-
|
|
381
|
-
def test_whitespace_between_bare_tokens_is_preserved_after_rewrite(self) -> None:
|
|
382
|
-
registry = {"my-repo": "Y:\\Projects\\my-repo", "other-repo": "C:\\Dev\\other"}
|
|
383
|
-
command = "es.exe my-repo other-repo config.py"
|
|
384
|
-
rewritten = rewriter.rewrite_command(command, registry)
|
|
385
|
-
assert rewritten == f'es.exe "Y:\\Projects\\my-repo" "C:\\Dev\\other" config.py'
|
|
386
|
-
|
|
387
|
-
def test_tab_separator_is_preserved_when_bare_token_is_rewritten(self) -> None:
|
|
388
|
-
registry = {"my-repo": "Y:\\Projects\\my-repo"}
|
|
389
|
-
command = "es.exe my-repo\tconfig.py"
|
|
390
|
-
rewritten = rewriter.rewrite_command(command, registry)
|
|
391
|
-
assert rewritten == f'es.exe "Y:\\Projects\\my-repo"\tconfig.py'
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
class TestQuotedSingleWordRewrite:
|
|
395
|
-
def test_double_quoted_single_word_registry_key_is_rewritten(self) -> None:
|
|
396
|
-
registry = {"my-repo": "Y:\\Projects\\my-repo"}
|
|
397
|
-
command = 'es.exe "my-repo" config.py'
|
|
398
|
-
rewritten = rewriter.rewrite_command(command, registry)
|
|
399
|
-
assert rewritten == f'es.exe "Y:\\Projects\\my-repo" config.py'
|
|
400
|
-
|
|
401
|
-
def test_single_quoted_single_word_registry_key_is_rewritten(self) -> None:
|
|
402
|
-
registry = {"my-repo": "Y:\\Projects\\my-repo"}
|
|
403
|
-
command = "es.exe 'my-repo' config.py"
|
|
404
|
-
rewritten = rewriter.rewrite_command(command, registry)
|
|
405
|
-
assert rewritten == f'es.exe "Y:\\Projects\\my-repo" config.py'
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
class TestPlaceholderBoundaryEnforcement:
|
|
409
|
-
def test_placeholder_inside_flag_argument_is_not_rewritten(self) -> None:
|
|
410
|
-
registry = {"my-repo": "Y:\\Projects\\my-repo"}
|
|
411
|
-
command = "es.exe --regex=^{my-repo}$"
|
|
412
|
-
rewritten = rewriter.rewrite_command(command, registry)
|
|
413
|
-
assert rewritten == command
|
|
414
|
-
|
|
415
|
-
def test_placeholder_embedded_in_token_is_not_rewritten(self) -> None:
|
|
416
|
-
registry = {"my-repo": "Y:\\Projects\\my-repo"}
|
|
417
|
-
command = "es.exe foo{my-repo}bar"
|
|
418
|
-
rewritten = rewriter.rewrite_command(command, registry)
|
|
419
|
-
assert rewritten == command
|
|
420
|
-
|
|
421
|
-
def test_standalone_placeholder_is_still_rewritten(self) -> None:
|
|
422
|
-
registry = {"my-repo": "Y:\\Projects\\my-repo"}
|
|
423
|
-
command = "es.exe {my-repo} config.py"
|
|
424
|
-
rewritten = rewriter.rewrite_command(command, registry)
|
|
425
|
-
assert rewritten == f'es.exe "Y:\\Projects\\my-repo" config.py'
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
class TestAbsolutePathDetection:
|
|
429
|
-
def test_windows_drive_letter_path_detected_as_absolute(self) -> None:
|
|
430
|
-
assert rewriter._token_is_absolute_path("C:\\Users\\x")
|
|
431
|
-
|
|
432
|
-
def test_windows_drive_letter_path_with_forward_slashes_detected_as_absolute(
|
|
433
|
-
self,
|
|
434
|
-
) -> None:
|
|
435
|
-
assert rewriter._token_is_absolute_path("Y:/Projects/foo")
|
|
436
|
-
|
|
437
|
-
def test_unc_path_detected_as_absolute(self) -> None:
|
|
438
|
-
assert rewriter._token_is_absolute_path("\\\\server\\share\\path")
|
|
439
|
-
|
|
440
|
-
def test_posix_absolute_path_detected_as_absolute(self) -> None:
|
|
441
|
-
assert rewriter._token_is_absolute_path("/etc/hosts")
|
|
442
|
-
|
|
443
|
-
def test_relative_path_not_detected_as_absolute(self) -> None:
|
|
444
|
-
assert not rewriter._token_is_absolute_path("./foo")
|
|
445
|
-
|
|
446
|
-
def test_bare_registry_token_not_detected_as_absolute(self) -> None:
|
|
447
|
-
assert not rewriter._token_is_absolute_path("my-repo")
|
|
@@ -1,241 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
"""
|
|
3
|
-
Git post-commit hook: Auto-update parent repos when committing in a submodule.
|
|
4
|
-
|
|
5
|
-
When you commit in a submodule, this hook:
|
|
6
|
-
1. Detects if current repo is a submodule of a parent
|
|
7
|
-
2. Stages the submodule update in the parent
|
|
8
|
-
3. Creates a commit in the parent pointing to the new submodule commit
|
|
9
|
-
|
|
10
|
-
This prevents the "lost work" issue where submodule commits aren't tracked by parent.
|
|
11
|
-
"""
|
|
12
|
-
|
|
13
|
-
from __future__ import annotations
|
|
14
|
-
|
|
15
|
-
import subprocess
|
|
16
|
-
import sys
|
|
17
|
-
from enum import StrEnum
|
|
18
|
-
from pathlib import Path
|
|
19
|
-
|
|
20
|
-
from git_hooks_constants import (
|
|
21
|
-
GH_EXECUTABLE_NAME,
|
|
22
|
-
GH_PR_VIEW_ARGUMENTS,
|
|
23
|
-
GH_PR_VIEW_TIMEOUT_SECONDS,
|
|
24
|
-
GIT_COMMAND_SUCCESS_EXIT_CODE,
|
|
25
|
-
GIT_EXECUTABLE_NAME,
|
|
26
|
-
)
|
|
27
|
-
from pull_request_handoff import build_pull_request_reminder
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
class ParentPointerStatus(StrEnum):
|
|
31
|
-
"""States returned by a parent pointer update."""
|
|
32
|
-
|
|
33
|
-
UPDATED = "updated"
|
|
34
|
-
UNCHANGED = "unchanged"
|
|
35
|
-
FAILED = "failed"
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
ParentPointerUpdate = tuple[ParentPointerStatus, str]
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
def execute_git(
|
|
42
|
-
*arguments: str,
|
|
43
|
-
cwd: Path,
|
|
44
|
-
) -> subprocess.CompletedProcess[str]:
|
|
45
|
-
"""Run a Git command and return its completed process."""
|
|
46
|
-
try:
|
|
47
|
-
return subprocess.run(
|
|
48
|
-
[GIT_EXECUTABLE_NAME, *arguments],
|
|
49
|
-
cwd=cwd,
|
|
50
|
-
check=False,
|
|
51
|
-
capture_output=True,
|
|
52
|
-
text=True,
|
|
53
|
-
)
|
|
54
|
-
except OSError as error:
|
|
55
|
-
return subprocess.CompletedProcess(
|
|
56
|
-
[GIT_EXECUTABLE_NAME, *arguments],
|
|
57
|
-
1,
|
|
58
|
-
"",
|
|
59
|
-
str(error),
|
|
60
|
-
)
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
def run_git(*arguments: str, cwd: Path) -> str:
|
|
64
|
-
"""Run a successful Git command and return its trimmed standard output."""
|
|
65
|
-
command_result = execute_git(*arguments, cwd=cwd)
|
|
66
|
-
if command_result.returncode != GIT_COMMAND_SUCCESS_EXIT_CODE:
|
|
67
|
-
return ""
|
|
68
|
-
return command_result.stdout.strip()
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
def run_git_from_current_directory(*arguments: str) -> str:
|
|
72
|
-
"""Run a successful Git command from the hook's current directory."""
|
|
73
|
-
return run_git(*arguments, cwd=Path.cwd())
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
def find_parent_repo(repo_dir: Path) -> Path | None:
|
|
77
|
-
"""Return the Git superproject that owns the current repository."""
|
|
78
|
-
parent_path_text = run_git(
|
|
79
|
-
"rev-parse",
|
|
80
|
-
"--show-superproject-working-tree",
|
|
81
|
-
cwd=repo_dir,
|
|
82
|
-
)
|
|
83
|
-
if not parent_path_text:
|
|
84
|
-
return None
|
|
85
|
-
|
|
86
|
-
parent_path = Path(parent_path_text).resolve()
|
|
87
|
-
if not parent_path.is_dir():
|
|
88
|
-
return None
|
|
89
|
-
return parent_path
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
def find_submodule_path(parent_repo: Path, repo_dir: Path) -> Path | None:
|
|
93
|
-
"""Return the submodule path relative to its parent repository."""
|
|
94
|
-
try:
|
|
95
|
-
return repo_dir.resolve().relative_to(parent_repo.resolve())
|
|
96
|
-
except ValueError:
|
|
97
|
-
return None
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
def build_literal_pathspec(submodule_path: Path) -> str:
|
|
101
|
-
"""Build a Git pathspec that treats every path character literally."""
|
|
102
|
-
return f":(literal){submodule_path.as_posix()}"
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
def get_git_failure_diagnostic(
|
|
106
|
-
command_result: subprocess.CompletedProcess[str],
|
|
107
|
-
) -> str:
|
|
108
|
-
"""Return the most useful diagnostic from a failed Git command."""
|
|
109
|
-
return command_result.stderr.strip() or command_result.stdout.strip()
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
def build_parent_commit_message(
|
|
113
|
-
repo_name: str,
|
|
114
|
-
commit_hash: str,
|
|
115
|
-
commit_message: str,
|
|
116
|
-
) -> str:
|
|
117
|
-
"""Build the parent commit message for a submodule pointer update."""
|
|
118
|
-
return (
|
|
119
|
-
f"chore: update {repo_name} submodule to {commit_hash}\n\n"
|
|
120
|
-
f"Submodule commit: {commit_message}\n\n"
|
|
121
|
-
"Co-Authored-By: Claude <noreply@anthropic.com>"
|
|
122
|
-
)
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
def update_parent_pointer(
|
|
126
|
-
parent_repo: Path,
|
|
127
|
-
repo_dir: Path,
|
|
128
|
-
commit_hash: str,
|
|
129
|
-
commit_message: str,
|
|
130
|
-
) -> ParentPointerUpdate:
|
|
131
|
-
"""Commit the current submodule pointer in its parent repository."""
|
|
132
|
-
submodule_path = find_submodule_path(parent_repo, repo_dir)
|
|
133
|
-
if submodule_path is None:
|
|
134
|
-
return ParentPointerStatus.FAILED, "could not resolve the submodule path"
|
|
135
|
-
|
|
136
|
-
submodule_reference = build_literal_pathspec(submodule_path)
|
|
137
|
-
add_result = execute_git("add", "--", submodule_reference, cwd=parent_repo)
|
|
138
|
-
if add_result.returncode != GIT_COMMAND_SUCCESS_EXIT_CODE:
|
|
139
|
-
return ParentPointerStatus.FAILED, get_git_failure_diagnostic(add_result)
|
|
140
|
-
|
|
141
|
-
staged_difference = execute_git(
|
|
142
|
-
"diff",
|
|
143
|
-
"--cached",
|
|
144
|
-
"--quiet",
|
|
145
|
-
"--",
|
|
146
|
-
submodule_reference,
|
|
147
|
-
cwd=parent_repo,
|
|
148
|
-
)
|
|
149
|
-
if staged_difference.returncode == GIT_COMMAND_SUCCESS_EXIT_CODE:
|
|
150
|
-
return ParentPointerStatus.UNCHANGED, ""
|
|
151
|
-
if staged_difference.returncode != 1:
|
|
152
|
-
return ParentPointerStatus.FAILED, get_git_failure_diagnostic(staged_difference)
|
|
153
|
-
|
|
154
|
-
parent_commit_message = build_parent_commit_message(
|
|
155
|
-
repo_dir.name,
|
|
156
|
-
commit_hash,
|
|
157
|
-
commit_message,
|
|
158
|
-
)
|
|
159
|
-
commit_result = execute_git(
|
|
160
|
-
"commit",
|
|
161
|
-
"--only",
|
|
162
|
-
"-m",
|
|
163
|
-
parent_commit_message,
|
|
164
|
-
"--",
|
|
165
|
-
submodule_reference,
|
|
166
|
-
cwd=parent_repo,
|
|
167
|
-
)
|
|
168
|
-
if commit_result.returncode != GIT_COMMAND_SUCCESS_EXIT_CODE:
|
|
169
|
-
return ParentPointerStatus.FAILED, get_git_failure_diagnostic(commit_result)
|
|
170
|
-
return ParentPointerStatus.UPDATED, ""
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
def print_pull_request_reminder(repo_dir: Path) -> None:
|
|
174
|
-
"""Print a reminder when the committed repository has an open pull request."""
|
|
175
|
-
try:
|
|
176
|
-
result = subprocess.run(
|
|
177
|
-
[GH_EXECUTABLE_NAME, *GH_PR_VIEW_ARGUMENTS],
|
|
178
|
-
cwd=repo_dir,
|
|
179
|
-
check=False,
|
|
180
|
-
capture_output=True,
|
|
181
|
-
text=True,
|
|
182
|
-
timeout=GH_PR_VIEW_TIMEOUT_SECONDS,
|
|
183
|
-
)
|
|
184
|
-
except (OSError, subprocess.TimeoutExpired):
|
|
185
|
-
return
|
|
186
|
-
pull_request_url = result.stdout.strip()
|
|
187
|
-
if result.returncode == 0 and pull_request_url:
|
|
188
|
-
print(build_pull_request_reminder(pull_request_url))
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
def main() -> int:
|
|
192
|
-
"""Update a parent repository after a submodule commit."""
|
|
193
|
-
repo_path_text = run_git_from_current_directory("rev-parse", "--show-toplevel")
|
|
194
|
-
if not repo_path_text:
|
|
195
|
-
return 0
|
|
196
|
-
|
|
197
|
-
repo_dir = Path(repo_path_text).resolve()
|
|
198
|
-
parent_repo = find_parent_repo(repo_dir)
|
|
199
|
-
if parent_repo is None:
|
|
200
|
-
print_pull_request_reminder(repo_dir)
|
|
201
|
-
return 0
|
|
202
|
-
|
|
203
|
-
commit_msg = run_git("log", "-1", "--pretty=%s", cwd=repo_dir)
|
|
204
|
-
commit_hash = run_git("rev-parse", "HEAD", cwd=repo_dir)
|
|
205
|
-
short_commit_hash = run_git("rev-parse", "--short", "HEAD", cwd=repo_dir)
|
|
206
|
-
if not commit_hash or not short_commit_hash:
|
|
207
|
-
print_pull_request_reminder(repo_dir)
|
|
208
|
-
return 0
|
|
209
|
-
|
|
210
|
-
print()
|
|
211
|
-
print("=== Submodule Parent Update ===")
|
|
212
|
-
print(f"Submodule: {repo_dir.name} @ {short_commit_hash}")
|
|
213
|
-
print(f"Parent: {parent_repo}")
|
|
214
|
-
|
|
215
|
-
parent_pointer_status, parent_pointer_diagnostic = update_parent_pointer(
|
|
216
|
-
parent_repo,
|
|
217
|
-
repo_dir,
|
|
218
|
-
commit_hash,
|
|
219
|
-
commit_msg,
|
|
220
|
-
)
|
|
221
|
-
if parent_pointer_status is ParentPointerStatus.FAILED:
|
|
222
|
-
print("Parent update failed.")
|
|
223
|
-
if parent_pointer_diagnostic:
|
|
224
|
-
print(f"Git diagnostic: {parent_pointer_diagnostic}")
|
|
225
|
-
print_pull_request_reminder(repo_dir)
|
|
226
|
-
return 0
|
|
227
|
-
if parent_pointer_status is ParentPointerStatus.UNCHANGED:
|
|
228
|
-
print("Parent already up to date.")
|
|
229
|
-
print_pull_request_reminder(repo_dir)
|
|
230
|
-
return 0
|
|
231
|
-
|
|
232
|
-
print("Parent updated successfully.")
|
|
233
|
-
print("================================")
|
|
234
|
-
print()
|
|
235
|
-
print_pull_request_reminder(repo_dir)
|
|
236
|
-
|
|
237
|
-
return 0
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
if __name__ == "__main__":
|
|
241
|
-
sys.exit(main())
|