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
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import subprocess
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import pytest
|
|
9
|
+
|
|
10
|
+
SCRIPT_DIRECTORY = Path(__file__).resolve().parent
|
|
11
|
+
if str(SCRIPT_DIRECTORY) not in sys.path:
|
|
12
|
+
sys.path.insert(0, str(SCRIPT_DIRECTORY))
|
|
13
|
+
|
|
14
|
+
import submodule_sync
|
|
15
|
+
from submodule_sync_constants.config.constants import (
|
|
16
|
+
GH_COMMAND_TIMEOUT_SECONDS,
|
|
17
|
+
GIT_COMMAND_TIMEOUT_SECONDS,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _build_git_environment() -> dict[str, str]:
|
|
22
|
+
return {
|
|
23
|
+
each_name: each_content
|
|
24
|
+
for each_name, each_content in os.environ.items()
|
|
25
|
+
if not each_name.upper().startswith("GIT_")
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _run_fixture_git(
|
|
30
|
+
repository_path: Path,
|
|
31
|
+
*all_arguments: str,
|
|
32
|
+
) -> subprocess.CompletedProcess[str]:
|
|
33
|
+
return subprocess.run(
|
|
34
|
+
["git", *all_arguments],
|
|
35
|
+
cwd=repository_path,
|
|
36
|
+
check=False,
|
|
37
|
+
capture_output=True,
|
|
38
|
+
text=True,
|
|
39
|
+
env=_build_git_environment(),
|
|
40
|
+
timeout=GIT_COMMAND_TIMEOUT_SECONDS,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _require_fixture_git(repository_path: Path, *all_arguments: str) -> str:
|
|
45
|
+
completed_process = _run_fixture_git(repository_path, *all_arguments)
|
|
46
|
+
assert completed_process.returncode == 0, completed_process.stderr
|
|
47
|
+
return completed_process.stdout.strip()
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _configure_fixture_identity(repository_path: Path) -> None:
|
|
51
|
+
_require_fixture_git(repository_path, "config", "user.name", "Fixture User")
|
|
52
|
+
_require_fixture_git(
|
|
53
|
+
repository_path,
|
|
54
|
+
"config",
|
|
55
|
+
"user.email",
|
|
56
|
+
"fixture@example.invalid",
|
|
57
|
+
)
|
|
58
|
+
_require_fixture_git(repository_path, "config", "commit.gpgsign", "false")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _initialize_fixture_repository(repository_path: Path) -> None:
|
|
62
|
+
repository_path.mkdir(parents=True)
|
|
63
|
+
_require_fixture_git(repository_path, "init", "-b", "main")
|
|
64
|
+
_configure_fixture_identity(repository_path)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _create_submodule_fixture(tmp_path: Path) -> tuple[Path, Path]:
|
|
68
|
+
source_repository = tmp_path / "source-repository"
|
|
69
|
+
parent_repository = tmp_path / "parent-repository"
|
|
70
|
+
_initialize_fixture_repository(source_repository)
|
|
71
|
+
(source_repository / "README.md").write_text("source\n", encoding="utf-8")
|
|
72
|
+
_require_fixture_git(source_repository, "add", "README.md")
|
|
73
|
+
_require_fixture_git(source_repository, "commit", "-m", "Create source")
|
|
74
|
+
_initialize_fixture_repository(parent_repository)
|
|
75
|
+
(parent_repository / "README.md").write_text("parent\n", encoding="utf-8")
|
|
76
|
+
_require_fixture_git(parent_repository, "add", "README.md")
|
|
77
|
+
_require_fixture_git(parent_repository, "commit", "-m", "Create parent")
|
|
78
|
+
_require_fixture_git(
|
|
79
|
+
parent_repository,
|
|
80
|
+
"-c",
|
|
81
|
+
"protocol.file.allow=always",
|
|
82
|
+
"submodule",
|
|
83
|
+
"add",
|
|
84
|
+
str(source_repository),
|
|
85
|
+
"nested/sub[module]",
|
|
86
|
+
)
|
|
87
|
+
_require_fixture_git(parent_repository, "commit", "-am", "Add submodule")
|
|
88
|
+
submodule_repository = parent_repository / "nested" / "sub[module]"
|
|
89
|
+
_configure_fixture_identity(submodule_repository)
|
|
90
|
+
return parent_repository, submodule_repository
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _commit_child_change(submodule_repository: Path) -> str:
|
|
94
|
+
(submodule_repository / "change.txt").write_text("change\n", encoding="utf-8")
|
|
95
|
+
_require_fixture_git(submodule_repository, "add", "change.txt")
|
|
96
|
+
_require_fixture_git(submodule_repository, "commit", "-m", "Record child change")
|
|
97
|
+
return _require_fixture_git(submodule_repository, "rev-parse", "HEAD")
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _assert_parent_pointer_commit(
|
|
101
|
+
parent_repository: Path,
|
|
102
|
+
child_commit_hash: str,
|
|
103
|
+
) -> None:
|
|
104
|
+
recorded_pointer = _require_fixture_git(
|
|
105
|
+
parent_repository,
|
|
106
|
+
"rev-parse",
|
|
107
|
+
"HEAD:nested/sub[module]",
|
|
108
|
+
)
|
|
109
|
+
changed_path = _require_fixture_git(
|
|
110
|
+
parent_repository,
|
|
111
|
+
"diff",
|
|
112
|
+
"HEAD^",
|
|
113
|
+
"HEAD",
|
|
114
|
+
"--name-only",
|
|
115
|
+
)
|
|
116
|
+
parent_commit_body = _require_fixture_git(
|
|
117
|
+
parent_repository,
|
|
118
|
+
"log",
|
|
119
|
+
"-1",
|
|
120
|
+
"--pretty=%B",
|
|
121
|
+
)
|
|
122
|
+
assert recorded_pointer == child_commit_hash
|
|
123
|
+
assert changed_path == "nested/sub[module]"
|
|
124
|
+
assert "Submodule commit: Record child change" in parent_commit_body
|
|
125
|
+
assert "Co-Authored-By" not in parent_commit_body
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def test_sync_updates_bracketed_pointer_and_preserves_staged_path(
|
|
129
|
+
tmp_path: Path,
|
|
130
|
+
) -> None:
|
|
131
|
+
parent_repository, submodule_repository = _create_submodule_fixture(tmp_path)
|
|
132
|
+
staged_note_path = parent_repository / "staged-note.txt"
|
|
133
|
+
staged_note_path.write_text("keep staged\n", encoding="utf-8")
|
|
134
|
+
_require_fixture_git(parent_repository, "add", "staged-note.txt")
|
|
135
|
+
child_commit_hash = _commit_child_change(submodule_repository)
|
|
136
|
+
|
|
137
|
+
sync_report = submodule_sync.sync_repository(submodule_repository)
|
|
138
|
+
|
|
139
|
+
assert sync_report.status is submodule_sync.SyncStatus.UPDATED
|
|
140
|
+
assert sync_report.commit == child_commit_hash
|
|
141
|
+
assert sync_report.submodule_path == "nested/sub[module]"
|
|
142
|
+
assert (
|
|
143
|
+
_require_fixture_git(parent_repository, "status", "--short")
|
|
144
|
+
== "A staged-note.txt"
|
|
145
|
+
)
|
|
146
|
+
_assert_parent_pointer_commit(parent_repository, child_commit_hash)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def test_second_sync_is_unchanged_without_new_commit(tmp_path: Path) -> None:
|
|
150
|
+
parent_repository, submodule_repository = _create_submodule_fixture(tmp_path)
|
|
151
|
+
_commit_child_change(submodule_repository)
|
|
152
|
+
|
|
153
|
+
first_report = submodule_sync.sync_repository(submodule_repository)
|
|
154
|
+
parent_commit = _require_fixture_git(parent_repository, "rev-parse", "HEAD")
|
|
155
|
+
second_report = submodule_sync.sync_repository(submodule_repository)
|
|
156
|
+
|
|
157
|
+
assert first_report.status is submodule_sync.SyncStatus.UPDATED
|
|
158
|
+
assert second_report.status is submodule_sync.SyncStatus.UNCHANGED
|
|
159
|
+
assert second_report.parent_commit == parent_commit
|
|
160
|
+
assert _require_fixture_git(parent_repository, "rev-parse", "HEAD") == parent_commit
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def test_standalone_repository_is_a_noop(tmp_path: Path) -> None:
|
|
164
|
+
standalone_repository = tmp_path / "standalone"
|
|
165
|
+
_initialize_fixture_repository(standalone_repository)
|
|
166
|
+
(standalone_repository / "note.txt").write_text("unchanged\n", encoding="utf-8")
|
|
167
|
+
_require_fixture_git(standalone_repository, "add", "note.txt")
|
|
168
|
+
before_status = _require_fixture_git(standalone_repository, "status", "--short")
|
|
169
|
+
|
|
170
|
+
sync_report = submodule_sync.sync_repository(standalone_repository)
|
|
171
|
+
|
|
172
|
+
assert sync_report.status is submodule_sync.SyncStatus.NOT_SUBMODULE
|
|
173
|
+
assert sync_report.repository == standalone_repository.resolve().as_posix()
|
|
174
|
+
assert (
|
|
175
|
+
_require_fixture_git(standalone_repository, "status", "--short")
|
|
176
|
+
== before_status
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
@pytest.mark.parametrize(
|
|
181
|
+
("failed_git_operation", "failure_exit_code"),
|
|
182
|
+
[("add", 1), ("diff", 2), ("commit", 1)],
|
|
183
|
+
)
|
|
184
|
+
def test_git_failures_return_error_status(
|
|
185
|
+
tmp_path: Path,
|
|
186
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
187
|
+
failed_git_operation: str,
|
|
188
|
+
failure_exit_code: int,
|
|
189
|
+
) -> None:
|
|
190
|
+
_, submodule_repository = _create_submodule_fixture(tmp_path)
|
|
191
|
+
_commit_child_change(submodule_repository)
|
|
192
|
+
original_execute_git = submodule_sync._execute_git
|
|
193
|
+
|
|
194
|
+
def fail_selected_operation(
|
|
195
|
+
all_arguments: tuple[str, ...],
|
|
196
|
+
cwd: Path,
|
|
197
|
+
) -> subprocess.CompletedProcess[str]:
|
|
198
|
+
if all_arguments[0] == failed_git_operation:
|
|
199
|
+
return subprocess.CompletedProcess(
|
|
200
|
+
["git", *all_arguments],
|
|
201
|
+
failure_exit_code,
|
|
202
|
+
"",
|
|
203
|
+
f"fatal: fixture {failed_git_operation} failure",
|
|
204
|
+
)
|
|
205
|
+
return original_execute_git(all_arguments, cwd)
|
|
206
|
+
|
|
207
|
+
monkeypatch.setattr(submodule_sync, "_execute_git", fail_selected_operation)
|
|
208
|
+
sync_report = submodule_sync.sync_repository(submodule_repository)
|
|
209
|
+
|
|
210
|
+
assert sync_report.status is submodule_sync.SyncStatus.ERROR
|
|
211
|
+
assert sync_report.diagnostic == f"fatal: fixture {failed_git_operation} failure"
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def test_inherited_git_dir_does_not_redirect_commands(
|
|
215
|
+
tmp_path: Path,
|
|
216
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
217
|
+
) -> None:
|
|
218
|
+
unrelated_repository = tmp_path / "unrelated"
|
|
219
|
+
_initialize_fixture_repository(unrelated_repository)
|
|
220
|
+
parent_repository, submodule_repository = _create_submodule_fixture(tmp_path)
|
|
221
|
+
child_commit_hash = _commit_child_change(submodule_repository)
|
|
222
|
+
monkeypatch.setenv("GIT_DIR", str(unrelated_repository / ".git"))
|
|
223
|
+
|
|
224
|
+
sync_report = submodule_sync.sync_repository(submodule_repository)
|
|
225
|
+
|
|
226
|
+
assert sync_report.status is submodule_sync.SyncStatus.UPDATED
|
|
227
|
+
recorded_pointer = _require_fixture_git(
|
|
228
|
+
parent_repository,
|
|
229
|
+
"rev-parse",
|
|
230
|
+
"HEAD:nested/sub[module]",
|
|
231
|
+
)
|
|
232
|
+
assert recorded_pointer == child_commit_hash
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def test_pull_request_lookup_returns_url_on_success(
|
|
236
|
+
tmp_path: Path,
|
|
237
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
238
|
+
) -> None:
|
|
239
|
+
completed_process = subprocess.CompletedProcess(
|
|
240
|
+
["gh"],
|
|
241
|
+
0,
|
|
242
|
+
"https://github.com/example/repo/pull/1\n",
|
|
243
|
+
"",
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
def return_completed_process(
|
|
247
|
+
*all_arguments: object,
|
|
248
|
+
**all_keyword_arguments: object,
|
|
249
|
+
) -> subprocess.CompletedProcess[str]:
|
|
250
|
+
return completed_process
|
|
251
|
+
|
|
252
|
+
monkeypatch.setattr(submodule_sync.subprocess, "run", return_completed_process)
|
|
253
|
+
|
|
254
|
+
assert submodule_sync.lookup_pull_request_url(tmp_path) == (
|
|
255
|
+
"https://github.com/example/repo/pull/1"
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
@pytest.mark.parametrize(
|
|
260
|
+
"completed_process",
|
|
261
|
+
[
|
|
262
|
+
subprocess.CompletedProcess(["gh"], 1, "", "no pull request"),
|
|
263
|
+
subprocess.CompletedProcess(["gh"], 1, "", "auth failed"),
|
|
264
|
+
subprocess.CompletedProcess(["gh"], 0, "", ""),
|
|
265
|
+
],
|
|
266
|
+
)
|
|
267
|
+
def test_pull_request_lookup_soft_failures_return_none(
|
|
268
|
+
tmp_path: Path,
|
|
269
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
270
|
+
completed_process: subprocess.CompletedProcess[str],
|
|
271
|
+
) -> None:
|
|
272
|
+
def return_completed_process(
|
|
273
|
+
*all_arguments: object,
|
|
274
|
+
**all_keyword_arguments: object,
|
|
275
|
+
) -> subprocess.CompletedProcess[str]:
|
|
276
|
+
return completed_process
|
|
277
|
+
|
|
278
|
+
monkeypatch.setattr(submodule_sync.subprocess, "run", return_completed_process)
|
|
279
|
+
|
|
280
|
+
assert submodule_sync.lookup_pull_request_url(tmp_path) is None
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
@pytest.mark.parametrize(
|
|
284
|
+
"lookup_exception",
|
|
285
|
+
[
|
|
286
|
+
FileNotFoundError(),
|
|
287
|
+
subprocess.TimeoutExpired("gh", GH_COMMAND_TIMEOUT_SECONDS),
|
|
288
|
+
],
|
|
289
|
+
)
|
|
290
|
+
def test_pull_request_lookup_unavailable_returns_none(
|
|
291
|
+
tmp_path: Path,
|
|
292
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
293
|
+
lookup_exception: Exception,
|
|
294
|
+
) -> None:
|
|
295
|
+
def raise_lookup_exception(
|
|
296
|
+
*all_arguments: object,
|
|
297
|
+
**all_keyword_arguments: object,
|
|
298
|
+
) -> None:
|
|
299
|
+
raise lookup_exception
|
|
300
|
+
|
|
301
|
+
monkeypatch.setattr(submodule_sync.subprocess, "run", raise_lookup_exception)
|
|
302
|
+
|
|
303
|
+
assert submodule_sync.lookup_pull_request_url(tmp_path) is None
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import sys
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
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 sync_parent_pointer
|
|
14
|
+
from submodule_sync import SyncReport, SyncStatus
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _read_single_json_line(
|
|
18
|
+
capsys: pytest.CaptureFixture[str],
|
|
19
|
+
) -> tuple[dict[str, object], str]:
|
|
20
|
+
captured_streams = capsys.readouterr()
|
|
21
|
+
all_stdout_lines = captured_streams.out.splitlines()
|
|
22
|
+
assert len(all_stdout_lines) == 1
|
|
23
|
+
parsed_record = json.loads(all_stdout_lines[0])
|
|
24
|
+
assert isinstance(parsed_record, dict)
|
|
25
|
+
return parsed_record, captured_streams.err
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def test_invalid_repository_emits_one_json_record_and_diagnostic(
|
|
29
|
+
tmp_path: Path,
|
|
30
|
+
capsys: pytest.CaptureFixture[str],
|
|
31
|
+
) -> None:
|
|
32
|
+
exit_code = sync_parent_pointer.main(["--repository", str(tmp_path / "missing")])
|
|
33
|
+
|
|
34
|
+
sync_record, captured_error = _read_single_json_line(capsys)
|
|
35
|
+
assert exit_code == 1
|
|
36
|
+
assert sync_record["status"] == "error"
|
|
37
|
+
assert sync_record["diagnostic"]
|
|
38
|
+
assert "syncing-submodules:" in captured_error
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def test_invalid_arguments_emit_one_json_record_and_exit_two(
|
|
42
|
+
capsys: pytest.CaptureFixture[str],
|
|
43
|
+
) -> None:
|
|
44
|
+
exit_code = sync_parent_pointer.main(["--unknown"])
|
|
45
|
+
|
|
46
|
+
sync_record, captured_error = _read_single_json_line(capsys)
|
|
47
|
+
assert exit_code == 2
|
|
48
|
+
assert sync_record["status"] == "error"
|
|
49
|
+
assert "invalid command arguments" in str(sync_record["diagnostic"])
|
|
50
|
+
assert captured_error
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def test_success_emits_one_json_record(
|
|
54
|
+
tmp_path: Path,
|
|
55
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
56
|
+
capsys: pytest.CaptureFixture[str],
|
|
57
|
+
) -> None:
|
|
58
|
+
expected_report = SyncReport(
|
|
59
|
+
status=SyncStatus.NOT_SUBMODULE,
|
|
60
|
+
repository=tmp_path.as_posix(),
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
def return_expected_report(repository: Path) -> SyncReport:
|
|
64
|
+
return expected_report
|
|
65
|
+
|
|
66
|
+
monkeypatch.setattr(
|
|
67
|
+
sync_parent_pointer,
|
|
68
|
+
"sync_repository",
|
|
69
|
+
return_expected_report,
|
|
70
|
+
)
|
|
71
|
+
exit_code = sync_parent_pointer.main(["--repository", str(tmp_path)])
|
|
72
|
+
|
|
73
|
+
sync_record, captured_error = _read_single_json_line(capsys)
|
|
74
|
+
assert exit_code == 0
|
|
75
|
+
assert sync_record["status"] == "not_submodule"
|
|
76
|
+
assert captured_error == ""
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def test_native_post_commit_source_and_test_are_removed() -> None:
|
|
80
|
+
hook_directory = Path(__file__).resolve().parents[4] / "hooks" / "git-hooks"
|
|
81
|
+
|
|
82
|
+
assert not (hook_directory / "post_commit.py").exists()
|
|
83
|
+
assert not (hook_directory / "test_post_commit.py").exists()
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { strict as assert } from 'node:assert';
|
|
2
|
+
import { test } from 'node:test';
|
|
3
|
+
import { existsSync, readdirSync } from 'node:fs';
|
|
4
|
+
import { dirname, join } from 'node:path';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
import { EVER_SHIPPED_SKILL_NAMES } from './ever-shipped-skills.mjs';
|
|
7
|
+
import {
|
|
8
|
+
MANAGED_SKILLS_DIRECTORY_NAME,
|
|
9
|
+
PACKAGE_AGENTS_HOME_DIRECTORY_NAME,
|
|
10
|
+
} from './install-constants.mjs';
|
|
11
|
+
|
|
12
|
+
const THIS_DIRECTORY = dirname(fileURLToPath(import.meta.url));
|
|
13
|
+
const PACKAGE_DIRECTORY = dirname(THIS_DIRECTORY);
|
|
14
|
+
|
|
15
|
+
test('EVER_SHIPPED_SKILL_NAMES includes every current shipped skill', () => {
|
|
16
|
+
const sourceSkillsDirectory = join(
|
|
17
|
+
PACKAGE_DIRECTORY,
|
|
18
|
+
PACKAGE_AGENTS_HOME_DIRECTORY_NAME,
|
|
19
|
+
MANAGED_SKILLS_DIRECTORY_NAME,
|
|
20
|
+
);
|
|
21
|
+
const allCurrentSkillNames = readdirSync(sourceSkillsDirectory, { withFileTypes: true })
|
|
22
|
+
.filter(eachEntry => eachEntry.isDirectory())
|
|
23
|
+
.filter(eachEntry => existsSync(join(sourceSkillsDirectory, eachEntry.name, 'SKILL.md')))
|
|
24
|
+
.map(eachEntry => eachEntry.name);
|
|
25
|
+
const allUncoveredSkillNames = allCurrentSkillNames.filter(
|
|
26
|
+
eachSkillName => !EVER_SHIPPED_SKILL_NAMES.has(eachSkillName),
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
assert.deepEqual(allUncoveredSkillNames, [], 'every current skill must enter the fallback set');
|
|
30
|
+
});
|
|
@@ -58,6 +58,22 @@ test('writeAllGitHookShims creates one shim per known hook name', () => {
|
|
|
58
58
|
});
|
|
59
59
|
|
|
60
60
|
|
|
61
|
+
test('known git hooks exclude post-commit', () => {
|
|
62
|
+
assert.equal(KNOWN_GIT_HOOK_NAMES.includes('post-commit'), false);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
test('writeAllGitHookShims does not create a post-commit shim', () => {
|
|
67
|
+
const { temporaryRoot, gitHooksDirectory } = makeTemporaryGitHooksDirectory();
|
|
68
|
+
try {
|
|
69
|
+
writeAllGitHookShims({ gitHooksDirectory });
|
|
70
|
+
assert.equal(existsSync(join(gitHooksDirectory, 'post-commit')), false);
|
|
71
|
+
} finally {
|
|
72
|
+
rmSync(temporaryRoot, { recursive: true, force: true });
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
|
|
61
77
|
test('configureGlobalGitHooksPath sets the path when nothing is currently configured', () => {
|
|
62
78
|
const commandsRun = [];
|
|
63
79
|
const gitConfigReaderReturningEmpty = () => '';
|
|
@@ -205,4 +221,3 @@ test('writeGitHookShim rejects hooks directory that is a symlink (loopP5c-5)', (
|
|
|
205
221
|
}
|
|
206
222
|
});
|
|
207
223
|
|
|
208
|
-
|
package/bin/install.mjs
CHANGED
|
@@ -6,7 +6,10 @@ import { homedir } from 'node:os';
|
|
|
6
6
|
import { execSync, execFileSync, spawnSync } from 'node:child_process';
|
|
7
7
|
import { fileURLToPath } from 'node:url';
|
|
8
8
|
import { createRequire } from 'node:module';
|
|
9
|
-
import {
|
|
9
|
+
import {
|
|
10
|
+
installAllGitHooks,
|
|
11
|
+
KNOWN_GIT_HOOK_NAMES,
|
|
12
|
+
} from './git_hooks_installer.mjs';
|
|
10
13
|
import { installMypyIniForClaudeHooks } from './install_mypy_ini.mjs';
|
|
11
14
|
import { expandHomeDirectoryTokensInSettings } from './expand_home_directory_tokens.mjs';
|
|
12
15
|
import { EVER_SHIPPED_SKILL_NAMES } from './ever-shipped-skills.mjs';
|
|
@@ -2368,7 +2371,7 @@ function executeInstallPlanMutations(plan, transactionHelpers) {
|
|
|
2368
2371
|
} else {
|
|
2369
2372
|
console.warn(` Git hooks: ${gitHookInstallationResult.hooksPathConfigurationResult.reason}`);
|
|
2370
2373
|
}
|
|
2371
|
-
console.log(` Git hook shims: ${gitHookInstallationResult.createdShimPaths.length} files (
|
|
2374
|
+
console.log(` Git hook shims: ${gitHookInstallationResult.createdShimPaths.length} files (${KNOWN_GIT_HOOK_NAMES.join(', ')})`);
|
|
2372
2375
|
syncWrittenPaths([...allInstalledFiles, ...publishedPointerPaths]);
|
|
2373
2376
|
throwIfFault(FAULT_PHASES.AFTER_GIT_CONFIG);
|
|
2374
2377
|
throwIfFault(FAULT_PHASES.AFTER_LINK_PUBLICATION);
|
|
@@ -13,15 +13,8 @@ import {
|
|
|
13
13
|
import { tmpdir } from 'node:os';
|
|
14
14
|
import { join, dirname, basename } from 'node:path';
|
|
15
15
|
import { fileURLToPath } from 'node:url';
|
|
16
|
-
import {
|
|
17
|
-
MANAGED_SKILLS_DIRECTORY_NAME,
|
|
18
|
-
PACKAGE_AGENTS_HOME_DIRECTORY_NAME,
|
|
19
|
-
} from './install-constants.mjs';
|
|
20
|
-
import { EVER_SHIPPED_SKILL_NAMES } from './ever-shipped-skills.mjs';
|
|
21
|
-
|
|
22
16
|
const THIS_DIRECTORY = dirname(fileURLToPath(import.meta.url));
|
|
23
17
|
const INSTALLER_PATH = join(THIS_DIRECTORY, 'install.mjs');
|
|
24
|
-
const PACKAGE_DIRECTORY = dirname(THIS_DIRECTORY);
|
|
25
18
|
const INSTALLER_PROCESS_TIMEOUT_MS = 60_000;
|
|
26
19
|
const EXCLUDED_PACKAGE_COPY_DIRECTORY = 'node_modules';
|
|
27
20
|
|
|
@@ -50,6 +43,11 @@ const PRIOR_RUN_BACKUP_DIRECTORY_NAMES = [
|
|
|
50
43
|
];
|
|
51
44
|
const SKILLS_DIRECTORY_NAME = 'skills';
|
|
52
45
|
const HOOKS_DIRECTORY_NAME = 'hooks';
|
|
46
|
+
const GIT_HOOKS_DIRECTORY_NAME = 'git-hooks';
|
|
47
|
+
const PRE_COMMIT_HOOK_NAME = 'pre-commit';
|
|
48
|
+
const PRE_PUSH_HOOK_NAME = 'pre-push';
|
|
49
|
+
const POST_COMMIT_HOOK_NAME = 'post-commit';
|
|
50
|
+
const FOREIGN_HOOK_NAME = 'foreign-hook';
|
|
53
51
|
const SETTINGS_FILE_NAME = 'settings.json';
|
|
54
52
|
const MANIFEST_FILE_NAME = '.claude-dev-env-manifest.json';
|
|
55
53
|
const CLAUDE_HUB_FILE_NAME = 'CLAUDE.md';
|
|
@@ -117,23 +115,6 @@ function createSandbox(homeDirectoryPrefix = 'cdev-prune-home-') {
|
|
|
117
115
|
return { homeDirectory, claudeDirectory, skillsDirectory, manifestPath };
|
|
118
116
|
}
|
|
119
117
|
|
|
120
|
-
test('the retired-skill fallback covers every current shipped skill', () => {
|
|
121
|
-
const sourceSkillsDirectory = join(
|
|
122
|
-
PACKAGE_DIRECTORY,
|
|
123
|
-
PACKAGE_AGENTS_HOME_DIRECTORY_NAME,
|
|
124
|
-
MANAGED_SKILLS_DIRECTORY_NAME,
|
|
125
|
-
);
|
|
126
|
-
const allCurrentSkillNames = readdirSync(sourceSkillsDirectory, { withFileTypes: true })
|
|
127
|
-
.filter(eachEntry => eachEntry.isDirectory())
|
|
128
|
-
.filter(eachEntry => existsSync(join(sourceSkillsDirectory, eachEntry.name, 'SKILL.md')))
|
|
129
|
-
.map(eachEntry => eachEntry.name);
|
|
130
|
-
const allUncoveredSkillNames = allCurrentSkillNames.filter(
|
|
131
|
-
eachSkillName => !EVER_SHIPPED_SKILL_NAMES.has(eachSkillName),
|
|
132
|
-
);
|
|
133
|
-
|
|
134
|
-
assert.deepEqual(allUncoveredSkillNames, [], 'every current skill must enter the fallback set');
|
|
135
|
-
});
|
|
136
|
-
|
|
137
118
|
test('a core install runs the spaced-path Write dispatcher and captures its red denial', () => {
|
|
138
119
|
const sandbox = createSandbox('cdev prune & (home)-');
|
|
139
120
|
try {
|
|
@@ -1003,6 +984,37 @@ test('a full reinstall leaves a manifest-recorded path under no managed root in
|
|
|
1003
984
|
}
|
|
1004
985
|
});
|
|
1005
986
|
|
|
987
|
+
test('a reinstall removes a retired post-commit shim and preserves a foreign git hook', () => {
|
|
988
|
+
const sandbox = createSandbox();
|
|
989
|
+
try {
|
|
990
|
+
runInstaller(sandbox.homeDirectory, []);
|
|
991
|
+
const gitHooksDirectory = join(
|
|
992
|
+
sandbox.claudeDirectory, HOOKS_DIRECTORY_NAME, GIT_HOOKS_DIRECTORY_NAME,
|
|
993
|
+
);
|
|
994
|
+
const postCommitShimPath = join(gitHooksDirectory, POST_COMMIT_HOOK_NAME);
|
|
995
|
+
const foreignHookPath = join(gitHooksDirectory, FOREIGN_HOOK_NAME);
|
|
996
|
+
const foreignHookContentBytes = Buffer.from('foreign hook content\n', 'utf8');
|
|
997
|
+
writeFileWithParents(postCommitShimPath, 'generated by an earlier install\n');
|
|
998
|
+
writeFileWithParents(foreignHookPath, foreignHookContentBytes);
|
|
999
|
+
appendManifestFiles(sandbox.manifestPath, [postCommitShimPath]);
|
|
1000
|
+
|
|
1001
|
+
runInstaller(sandbox.homeDirectory, []);
|
|
1002
|
+
|
|
1003
|
+
const freshManifest = readManifest(sandbox.manifestPath);
|
|
1004
|
+
assert.equal(existsSync(postCommitShimPath), false);
|
|
1005
|
+
assert.deepEqual(readFileSync(foreignHookPath), foreignHookContentBytes);
|
|
1006
|
+
assert.equal(freshManifest.files.includes(postCommitShimPath), false);
|
|
1007
|
+
assert.equal(freshManifest.files.includes(foreignHookPath), false);
|
|
1008
|
+
for (const gitHookName of [PRE_COMMIT_HOOK_NAME, PRE_PUSH_HOOK_NAME]) {
|
|
1009
|
+
const generatedShimPath = join(gitHooksDirectory, gitHookName);
|
|
1010
|
+
assert.equal(existsSync(generatedShimPath), true);
|
|
1011
|
+
assert.equal(freshManifest.files.includes(generatedShimPath), true);
|
|
1012
|
+
}
|
|
1013
|
+
} finally {
|
|
1014
|
+
rmSync(sandbox.homeDirectory, { recursive: true, force: true });
|
|
1015
|
+
}
|
|
1016
|
+
});
|
|
1017
|
+
|
|
1006
1018
|
test('a full reinstall drops a retired hook script and its settings entry in one run', () => {
|
|
1007
1019
|
const sandbox = createSandbox();
|
|
1008
1020
|
try {
|
|
@@ -1042,6 +1054,65 @@ test('a full reinstall drops a retired hook script and its settings entry in one
|
|
|
1042
1054
|
}
|
|
1043
1055
|
});
|
|
1044
1056
|
|
|
1057
|
+
test('a full reinstall removes the retired Everything path rewriter and keeps a foreign hook neighbor', () => {
|
|
1058
|
+
const sandbox = createSandbox();
|
|
1059
|
+
try {
|
|
1060
|
+
runInstaller(sandbox.homeDirectory, []);
|
|
1061
|
+
const retiredHookPath = join(
|
|
1062
|
+
sandbox.claudeDirectory,
|
|
1063
|
+
HOOKS_DIRECTORY_NAME,
|
|
1064
|
+
'blocking',
|
|
1065
|
+
'es_exe_path_rewriter.py',
|
|
1066
|
+
);
|
|
1067
|
+
writeFileWithParents(retiredHookPath, 'a hook an earlier install wrote\n');
|
|
1068
|
+
appendManifestFiles(sandbox.manifestPath, [retiredHookPath]);
|
|
1069
|
+
|
|
1070
|
+
const foreignHookPath = join(
|
|
1071
|
+
sandbox.claudeDirectory,
|
|
1072
|
+
HOOKS_DIRECTORY_NAME,
|
|
1073
|
+
'blocking',
|
|
1074
|
+
'foreign_search_hook.py',
|
|
1075
|
+
);
|
|
1076
|
+
const foreignHookBytes = Buffer.from('a user hook the installer never wrote\n');
|
|
1077
|
+
writeFileWithParents(foreignHookPath, foreignHookBytes);
|
|
1078
|
+
|
|
1079
|
+
runInstaller(sandbox.homeDirectory, []);
|
|
1080
|
+
|
|
1081
|
+
const freshManifest = readManifest(sandbox.manifestPath);
|
|
1082
|
+
const installedDispatcherConstantsPath = join(
|
|
1083
|
+
sandbox.claudeDirectory,
|
|
1084
|
+
HOOKS_DIRECTORY_NAME,
|
|
1085
|
+
'hooks_constants',
|
|
1086
|
+
'bash_pre_tool_use_dispatcher_constants.py',
|
|
1087
|
+
);
|
|
1088
|
+
assert.equal(existsSync(retiredHookPath), false, 'the retired search hook leaves the installed hooks');
|
|
1089
|
+
assert.equal(
|
|
1090
|
+
freshManifest.files.includes(retiredHookPath),
|
|
1091
|
+
false,
|
|
1092
|
+
'the fresh manifest drops the retired search hook',
|
|
1093
|
+
);
|
|
1094
|
+
assert.equal(
|
|
1095
|
+
freshManifest.files.includes(foreignHookPath),
|
|
1096
|
+
false,
|
|
1097
|
+
'the fresh manifest does not claim the foreign hook',
|
|
1098
|
+
);
|
|
1099
|
+
assert.deepEqual(
|
|
1100
|
+
readFileSync(foreignHookPath),
|
|
1101
|
+
foreignHookBytes,
|
|
1102
|
+
'the foreign hook keeps its original bytes',
|
|
1103
|
+
);
|
|
1104
|
+
assert.equal(
|
|
1105
|
+
readFileSync(installedDispatcherConstantsPath, 'utf8').includes(
|
|
1106
|
+
'es_exe_path_rewriter.py',
|
|
1107
|
+
),
|
|
1108
|
+
false,
|
|
1109
|
+
'the installed dispatcher constants omit the retired search hook',
|
|
1110
|
+
);
|
|
1111
|
+
} finally {
|
|
1112
|
+
rmSync(sandbox.homeDirectory, { recursive: true, force: true });
|
|
1113
|
+
}
|
|
1114
|
+
});
|
|
1115
|
+
|
|
1045
1116
|
test('a full reinstall removes a retired hook entry under an event type the current config leaves out', () => {
|
|
1046
1117
|
const sandbox = createSandbox();
|
|
1047
1118
|
try {
|
package/bin/install.test.mjs
CHANGED
|
@@ -51,6 +51,13 @@ import {
|
|
|
51
51
|
} from './expand_home_directory_tokens.mjs';
|
|
52
52
|
|
|
53
53
|
|
|
54
|
+
test('installer reports the authoritative Git hook names', () => {
|
|
55
|
+
const installerSource = readFileSync(new URL('./install.mjs', import.meta.url), 'utf8');
|
|
56
|
+
assert.match(installerSource, /KNOWN_GIT_HOOK_NAMES\.join\(', '\)/);
|
|
57
|
+
assert.doesNotMatch(installerSource, /pre-commit, pre-push, post-commit/);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
|
|
54
61
|
function createTemporaryGitRepository() {
|
|
55
62
|
const repositoryRoot = mkdtempSync(join(tmpdir(), 'cdev-installer-validation-'));
|
|
56
63
|
const gitOptions = { cwd: repositoryRoot, stdio: 'ignore' };
|