claude-dev-env 1.31.0 → 1.33.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/hooks/blocking/code_rules_enforcer.py +109 -0
- package/hooks/blocking/test_windows_rmtree_blocker.py +155 -0
- package/hooks/blocking/windows_rmtree_blocker.py +102 -0
- package/hooks/config/hook_log_extractor_constants.py +13 -0
- package/hooks/config/session_env_cleanup_constants.py +20 -0
- package/hooks/config/test_hook_log_extractor_constants.py +27 -0
- package/hooks/config/test_session_env_cleanup_constants.py +60 -0
- package/hooks/diagnostic/hook_log_stop_wrapper.py +107 -19
- package/hooks/diagnostic/test_hook_log_stop_wrapper.py +258 -11
- package/hooks/hooks.json +15 -0
- package/hooks/session/session_env_cleanup.py +130 -0
- package/hooks/session/test_session_env_cleanup.py +280 -0
- package/package.json +1 -1
- package/rules/windows-filesystem-safe.md +91 -0
- package/skills/bugteam/PROMPTS.md +39 -0
- package/skills/bugteam/SKILL.md +49 -1
- package/skills/bugteam/SKILL_EVALS.md +1 -1
- package/skills/bugteam/reference/copilot-gap-analysis.md +496 -0
- package/skills/bugteam/reference/teardown-publish-permissions.md +1 -1
- package/skills/bugteam/scripts/README.md +17 -0
- package/skills/bugteam/scripts/bugteam_code_rules_gate.py +94 -0
- package/skills/bugteam/scripts/bugteam_fix_hookspath.py +260 -0
- package/skills/bugteam/scripts/config/__init__.py +0 -0
- package/skills/bugteam/scripts/config/bugteam_fix_hookspath_constants.py +17 -0
- package/skills/bugteam/scripts/test_bugteam_fix_hookspath.py +267 -0
- package/skills/logifix/SKILL.md +69 -0
- package/skills/logifix/scripts/logifix.ps1 +205 -0
- package/skills/rebase/SKILL.md +164 -0
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
import argparse
|
|
4
|
+
import ast
|
|
4
5
|
import importlib.util
|
|
5
6
|
import re
|
|
6
7
|
import subprocess
|
|
@@ -239,6 +240,97 @@ def is_code_path(file_path: Path) -> bool:
|
|
|
239
240
|
return suffix in {".py", ".js", ".ts", ".tsx", ".jsx"}
|
|
240
241
|
|
|
241
242
|
|
|
243
|
+
def check_database_column_string_magic(content: str, file_path: str) -> list[str]:
|
|
244
|
+
"""Flag string literals that look like database/HTTP column or key names inside function bodies.
|
|
245
|
+
|
|
246
|
+
Triggers when a snake_case string literal appears as the first element of a
|
|
247
|
+
two-element tuple inside a function body (the characteristic column-name/value
|
|
248
|
+
pair pattern). Files under ``config/`` and test files are exempt.
|
|
249
|
+
"""
|
|
250
|
+
if "/config/" in file_path.replace("\\", "/") or "\\config\\" in file_path:
|
|
251
|
+
return []
|
|
252
|
+
if "/tests/" in file_path.replace("\\", "/") or file_path.endswith(("_test.py", ".spec.py")):
|
|
253
|
+
return []
|
|
254
|
+
try:
|
|
255
|
+
tree = ast.parse(content)
|
|
256
|
+
except SyntaxError:
|
|
257
|
+
return []
|
|
258
|
+
issues: list[str] = []
|
|
259
|
+
column_key_pattern = re.compile(r"^[a-z][a-z0-9_]{2,}$")
|
|
260
|
+
for node in ast.walk(tree):
|
|
261
|
+
if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
262
|
+
continue
|
|
263
|
+
for each_child in ast.walk(node):
|
|
264
|
+
if not isinstance(each_child, ast.Tuple):
|
|
265
|
+
continue
|
|
266
|
+
if len(each_child.elts) != 2:
|
|
267
|
+
continue
|
|
268
|
+
first_element = each_child.elts[0]
|
|
269
|
+
if not isinstance(first_element, ast.Constant):
|
|
270
|
+
continue
|
|
271
|
+
if not isinstance(first_element.value, str):
|
|
272
|
+
continue
|
|
273
|
+
literal_text = first_element.value
|
|
274
|
+
if not column_key_pattern.match(literal_text):
|
|
275
|
+
continue
|
|
276
|
+
if literal_text in {"true", "false", "none", "null"}:
|
|
277
|
+
continue
|
|
278
|
+
issues.append(
|
|
279
|
+
f"Line {first_element.lineno}: Column-name string magic {literal_text!r} - extract to config"
|
|
280
|
+
)
|
|
281
|
+
if len(issues) >= 3:
|
|
282
|
+
return issues
|
|
283
|
+
return issues
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def check_wrapper_plumb_through(content: str, file_path: str) -> list[str]:
|
|
287
|
+
"""Flag public wrappers that drop optional kwargs of a same-file delegate.
|
|
288
|
+
|
|
289
|
+
Walks the AST. For every public function (name does not start with '_'),
|
|
290
|
+
if its body contains exactly one direct call to another same-file
|
|
291
|
+
function and that delegate's signature accepts optional kwargs that the
|
|
292
|
+
wrapper does not also accept, emit a finding with both line numbers.
|
|
293
|
+
"""
|
|
294
|
+
if file_path.endswith((".js", ".ts", ".tsx", ".jsx")):
|
|
295
|
+
return []
|
|
296
|
+
try:
|
|
297
|
+
tree = ast.parse(content)
|
|
298
|
+
except SyntaxError:
|
|
299
|
+
return []
|
|
300
|
+
function_signatures: dict[str, set[str]] = {}
|
|
301
|
+
for node in ast.walk(tree):
|
|
302
|
+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
303
|
+
optional_kwargs: set[str] = set()
|
|
304
|
+
for each_kwonly, each_default in zip(node.args.kwonlyargs, node.args.kw_defaults):
|
|
305
|
+
if each_default is not None:
|
|
306
|
+
optional_kwargs.add(each_kwonly.arg)
|
|
307
|
+
function_signatures[node.name] = optional_kwargs
|
|
308
|
+
issues: list[str] = []
|
|
309
|
+
for node in ast.walk(tree):
|
|
310
|
+
if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
311
|
+
continue
|
|
312
|
+
if node.name.startswith("_"):
|
|
313
|
+
continue
|
|
314
|
+
wrapper_kwargs = function_signatures.get(node.name, set())
|
|
315
|
+
for each_call in ast.walk(node):
|
|
316
|
+
if not isinstance(each_call, ast.Call):
|
|
317
|
+
continue
|
|
318
|
+
if not isinstance(each_call.func, ast.Attribute):
|
|
319
|
+
continue
|
|
320
|
+
delegate_name = each_call.func.attr
|
|
321
|
+
delegate_kwargs = function_signatures.get(delegate_name)
|
|
322
|
+
if delegate_kwargs is None:
|
|
323
|
+
continue
|
|
324
|
+
missing = delegate_kwargs - wrapper_kwargs
|
|
325
|
+
if missing:
|
|
326
|
+
issues.append(
|
|
327
|
+
f"Line {node.lineno}: Wrapper {node.name!r} drops optional kwargs {sorted(missing)!r} of delegate {delegate_name!r}"
|
|
328
|
+
)
|
|
329
|
+
if len(issues) >= 3:
|
|
330
|
+
return issues
|
|
331
|
+
return issues
|
|
332
|
+
|
|
333
|
+
|
|
242
334
|
def parse_added_line_numbers(unified_diff_text: str) -> set[int]:
|
|
243
335
|
header_regex = hunk_header_pattern()
|
|
244
336
|
added_line_numbers: set[int] = set()
|
|
@@ -404,6 +496,8 @@ def run_gate(
|
|
|
404
496
|
continue
|
|
405
497
|
relative = resolved.relative_to(repository_root.resolve())
|
|
406
498
|
issues = validate_content(content, str(relative).replace("\\", "/"), old_content=content)
|
|
499
|
+
issues.extend(check_database_column_string_magic(content, str(relative).replace("\\", "/")))
|
|
500
|
+
issues.extend(check_wrapper_plumb_through(content, str(relative).replace("\\", "/")))
|
|
407
501
|
if not issues:
|
|
408
502
|
continue
|
|
409
503
|
added_for_file = None if added_lines_map is None else added_lines_map.get(resolved)
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import subprocess
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
sys.modules.pop("config", None)
|
|
9
|
+
if str(Path(__file__).resolve().parent) not in sys.path:
|
|
10
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
11
|
+
|
|
12
|
+
from config.bugteam_fix_hookspath_constants import (
|
|
13
|
+
ALL_CANONICAL_HOOKS_DIRECTORY_COMPONENTS,
|
|
14
|
+
ALL_HOME_ENV_VAR_NAMES,
|
|
15
|
+
HOOKS_PATH_SUFFIX,
|
|
16
|
+
PREFLIGHT_NO_PYTEST_FLAG,
|
|
17
|
+
PREFLIGHT_REPO_ROOT_FLAG,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _expected_hooks_path_suffix() -> str:
|
|
22
|
+
return HOOKS_PATH_SUFFIX
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _canonical_hooks_directory_components() -> tuple[str, str, str]:
|
|
26
|
+
return ALL_CANONICAL_HOOKS_DIRECTORY_COMPONENTS
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _home_env_var_names() -> tuple[str, str]:
|
|
30
|
+
return ALL_HOME_ENV_VAR_NAMES
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def resolve_canonical_hooks_directory(
|
|
34
|
+
environment_overrides: dict[str, str] | None,
|
|
35
|
+
) -> Path:
|
|
36
|
+
components = _canonical_hooks_directory_components()
|
|
37
|
+
if environment_overrides is not None:
|
|
38
|
+
for each_env_var_name in _home_env_var_names():
|
|
39
|
+
home_value = environment_overrides.get(each_env_var_name)
|
|
40
|
+
if home_value:
|
|
41
|
+
return Path(home_value).joinpath(*components)
|
|
42
|
+
return Path.home().joinpath(*components)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def list_local_core_hooks_path_values(
|
|
46
|
+
repository_root: Path,
|
|
47
|
+
environment_overrides: dict[str, str] | None,
|
|
48
|
+
) -> list[str]:
|
|
49
|
+
git_command = [
|
|
50
|
+
"git",
|
|
51
|
+
"-C",
|
|
52
|
+
str(repository_root),
|
|
53
|
+
"config",
|
|
54
|
+
"--local",
|
|
55
|
+
"--get-all",
|
|
56
|
+
"core.hooksPath",
|
|
57
|
+
]
|
|
58
|
+
completed_process = subprocess.run(
|
|
59
|
+
git_command,
|
|
60
|
+
capture_output=True,
|
|
61
|
+
text=True,
|
|
62
|
+
encoding="utf-8",
|
|
63
|
+
errors="replace",
|
|
64
|
+
check=False,
|
|
65
|
+
env=environment_overrides,
|
|
66
|
+
)
|
|
67
|
+
if completed_process.returncode != 0:
|
|
68
|
+
return []
|
|
69
|
+
return [
|
|
70
|
+
each_line.strip()
|
|
71
|
+
for each_line in completed_process.stdout.splitlines()
|
|
72
|
+
if each_line.strip()
|
|
73
|
+
]
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def read_global_core_hooks_path(
|
|
77
|
+
environment_overrides: dict[str, str] | None,
|
|
78
|
+
) -> str:
|
|
79
|
+
git_command = ["git", "config", "--global", "--get", "core.hooksPath"]
|
|
80
|
+
completed_process = subprocess.run(
|
|
81
|
+
git_command,
|
|
82
|
+
capture_output=True,
|
|
83
|
+
text=True,
|
|
84
|
+
encoding="utf-8",
|
|
85
|
+
errors="replace",
|
|
86
|
+
check=False,
|
|
87
|
+
env=environment_overrides,
|
|
88
|
+
)
|
|
89
|
+
if completed_process.returncode != 0:
|
|
90
|
+
return ""
|
|
91
|
+
return completed_process.stdout.strip()
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def unset_local_core_hooks_path(
|
|
95
|
+
repository_root: Path,
|
|
96
|
+
environment_overrides: dict[str, str] | None,
|
|
97
|
+
) -> int:
|
|
98
|
+
git_command = [
|
|
99
|
+
"git",
|
|
100
|
+
"-C",
|
|
101
|
+
str(repository_root),
|
|
102
|
+
"config",
|
|
103
|
+
"--local",
|
|
104
|
+
"--unset-all",
|
|
105
|
+
"core.hooksPath",
|
|
106
|
+
]
|
|
107
|
+
completed_process = subprocess.run(
|
|
108
|
+
git_command,
|
|
109
|
+
capture_output=True,
|
|
110
|
+
text=True,
|
|
111
|
+
check=False,
|
|
112
|
+
env=environment_overrides,
|
|
113
|
+
)
|
|
114
|
+
return completed_process.returncode
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def set_global_core_hooks_path(
|
|
118
|
+
target_value: str,
|
|
119
|
+
environment_overrides: dict[str, str] | None,
|
|
120
|
+
) -> int:
|
|
121
|
+
git_command = ["git", "config", "--global", "core.hooksPath", target_value]
|
|
122
|
+
completed_process = subprocess.run(
|
|
123
|
+
git_command,
|
|
124
|
+
capture_output=True,
|
|
125
|
+
text=True,
|
|
126
|
+
check=False,
|
|
127
|
+
env=environment_overrides,
|
|
128
|
+
)
|
|
129
|
+
return completed_process.returncode
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def normalize_hooks_path(raw_value: str) -> str:
|
|
133
|
+
return raw_value.replace("\\", "/").rstrip("/")
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def is_canonical_hooks_path(raw_value: str) -> bool:
|
|
137
|
+
if not raw_value:
|
|
138
|
+
return False
|
|
139
|
+
return normalize_hooks_path(raw_value).endswith(_expected_hooks_path_suffix())
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def find_repository_root(start: Path) -> Path:
|
|
143
|
+
resolved_start = start.resolve()
|
|
144
|
+
candidate_paths = [resolved_start, *resolved_start.parents]
|
|
145
|
+
for each_candidate in candidate_paths:
|
|
146
|
+
marker = each_candidate / ".git"
|
|
147
|
+
if marker.is_dir() or marker.is_file():
|
|
148
|
+
return each_candidate
|
|
149
|
+
return resolved_start
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def rerun_preflight(
|
|
153
|
+
repository_root: Path,
|
|
154
|
+
environment_overrides: dict[str, str] | None,
|
|
155
|
+
) -> int:
|
|
156
|
+
preflight_script_path = Path(__file__).resolve().parent / "bugteam_preflight.py"
|
|
157
|
+
rerun_command = [
|
|
158
|
+
sys.executable,
|
|
159
|
+
str(preflight_script_path),
|
|
160
|
+
PREFLIGHT_NO_PYTEST_FLAG,
|
|
161
|
+
PREFLIGHT_REPO_ROOT_FLAG,
|
|
162
|
+
str(repository_root),
|
|
163
|
+
]
|
|
164
|
+
completed_process = subprocess.run(
|
|
165
|
+
rerun_command,
|
|
166
|
+
check=False,
|
|
167
|
+
env=environment_overrides,
|
|
168
|
+
)
|
|
169
|
+
return completed_process.returncode
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def parse_arguments(argv: list[str] | None) -> argparse.Namespace:
|
|
173
|
+
parser = argparse.ArgumentParser(
|
|
174
|
+
description=(
|
|
175
|
+
"Auto-fix core.hooksPath when bugteam preflight detects a stale override. "
|
|
176
|
+
"Removes a local-scope override and ensures global core.hooksPath points "
|
|
177
|
+
"at the canonical claude-dev-env git-hooks directory."
|
|
178
|
+
),
|
|
179
|
+
)
|
|
180
|
+
parser.add_argument(
|
|
181
|
+
"--repo-root",
|
|
182
|
+
type=Path,
|
|
183
|
+
default=None,
|
|
184
|
+
help="Repository root (default: discover from cwd).",
|
|
185
|
+
)
|
|
186
|
+
return parser.parse_args(argv)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def main(
|
|
190
|
+
argv: list[str] | None = None,
|
|
191
|
+
*,
|
|
192
|
+
environment_overrides: dict[str, str] | None = None,
|
|
193
|
+
) -> int:
|
|
194
|
+
arguments = parse_arguments(argv)
|
|
195
|
+
start_directory = Path.cwd()
|
|
196
|
+
repository_root = (
|
|
197
|
+
arguments.repo_root.resolve()
|
|
198
|
+
if arguments.repo_root is not None
|
|
199
|
+
else find_repository_root(start_directory)
|
|
200
|
+
)
|
|
201
|
+
canonical_hooks_directory = resolve_canonical_hooks_directory(environment_overrides)
|
|
202
|
+
expected_suffix = _expected_hooks_path_suffix()
|
|
203
|
+
if not canonical_hooks_directory.is_dir():
|
|
204
|
+
print(
|
|
205
|
+
"bugteam_fix_hookspath: canonical hooks directory does not exist: "
|
|
206
|
+
f"{canonical_hooks_directory}\n"
|
|
207
|
+
"Run: npx claude-dev-env .\n"
|
|
208
|
+
"Then re-run /bugteam. The directory must end in "
|
|
209
|
+
f"'{expected_suffix}' and contain the claude-dev-env git hook shims.",
|
|
210
|
+
file=sys.stderr,
|
|
211
|
+
)
|
|
212
|
+
return 1
|
|
213
|
+
local_hooks_path_values = list_local_core_hooks_path_values(
|
|
214
|
+
repository_root,
|
|
215
|
+
environment_overrides,
|
|
216
|
+
)
|
|
217
|
+
has_non_canonical_local_override = any(
|
|
218
|
+
not is_canonical_hooks_path(each_value)
|
|
219
|
+
for each_value in local_hooks_path_values
|
|
220
|
+
)
|
|
221
|
+
if has_non_canonical_local_override:
|
|
222
|
+
unset_local_returncode = unset_local_core_hooks_path(
|
|
223
|
+
repository_root, environment_overrides
|
|
224
|
+
)
|
|
225
|
+
if unset_local_returncode != 0:
|
|
226
|
+
print(
|
|
227
|
+
"bugteam_fix_hookspath: failed to unset local core.hooksPath on "
|
|
228
|
+
f"{repository_root} (git exit {unset_local_returncode}).",
|
|
229
|
+
file=sys.stderr,
|
|
230
|
+
)
|
|
231
|
+
return 1
|
|
232
|
+
print(
|
|
233
|
+
"bugteam_fix_hookspath: removed stale local core.hooksPath override on "
|
|
234
|
+
f"{repository_root}",
|
|
235
|
+
file=sys.stderr,
|
|
236
|
+
)
|
|
237
|
+
current_global_value = read_global_core_hooks_path(environment_overrides)
|
|
238
|
+
if not is_canonical_hooks_path(current_global_value):
|
|
239
|
+
canonical_target_value = str(canonical_hooks_directory).replace("\\", "/")
|
|
240
|
+
global_set_exit_code = set_global_core_hooks_path(
|
|
241
|
+
canonical_target_value,
|
|
242
|
+
environment_overrides,
|
|
243
|
+
)
|
|
244
|
+
if global_set_exit_code != 0:
|
|
245
|
+
print(
|
|
246
|
+
"bugteam_fix_hookspath: failed to set global core.hooksPath to "
|
|
247
|
+
f"{canonical_target_value} (git exit {global_set_exit_code}).",
|
|
248
|
+
file=sys.stderr,
|
|
249
|
+
)
|
|
250
|
+
return 1
|
|
251
|
+
print(
|
|
252
|
+
"bugteam_fix_hookspath: set global core.hooksPath to "
|
|
253
|
+
f"{canonical_target_value}",
|
|
254
|
+
file=sys.stderr,
|
|
255
|
+
)
|
|
256
|
+
return rerun_preflight(repository_root, environment_overrides)
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
if __name__ == "__main__":
|
|
260
|
+
raise SystemExit(main())
|
|
File without changes
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Configuration constants for bugteam_fix_hookspath auto-remediation script."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
HOOKS_PATH_SUFFIX: str = "hooks/git-hooks"
|
|
6
|
+
|
|
7
|
+
ALL_CANONICAL_HOOKS_DIRECTORY_COMPONENTS: tuple[str, str, str] = (
|
|
8
|
+
".claude",
|
|
9
|
+
"hooks",
|
|
10
|
+
"git-hooks",
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
ALL_HOME_ENV_VAR_NAMES: tuple[str, str] = ("HOME", "USERPROFILE")
|
|
14
|
+
|
|
15
|
+
PREFLIGHT_NO_PYTEST_FLAG: str = "--no-pytest"
|
|
16
|
+
|
|
17
|
+
PREFLIGHT_REPO_ROOT_FLAG: str = "--repo-root"
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
"""Tests for bugteam_fix_hookspath auto-remediation.
|
|
2
|
+
|
|
3
|
+
Covers:
|
|
4
|
+
- removes a local-scope core.hooksPath override and re-runs preflight
|
|
5
|
+
- sets global core.hooksPath when missing
|
|
6
|
+
- idempotent: second invocation produces the same final state with no errors
|
|
7
|
+
- no-op when no override exists and global is already canonical
|
|
8
|
+
- exits non-zero with a clear message when canonical hooks dir is missing
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import importlib.util
|
|
14
|
+
import os
|
|
15
|
+
import subprocess
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from types import ModuleType
|
|
18
|
+
|
|
19
|
+
import pytest
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _load_fix_module() -> ModuleType:
|
|
23
|
+
module_path = Path(__file__).parent / "bugteam_fix_hookspath.py"
|
|
24
|
+
spec = importlib.util.spec_from_file_location("bugteam_fix_hookspath", module_path)
|
|
25
|
+
assert spec is not None
|
|
26
|
+
assert spec.loader is not None
|
|
27
|
+
module = importlib.util.module_from_spec(spec)
|
|
28
|
+
spec.loader.exec_module(module)
|
|
29
|
+
return module
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
bugteam_fix_hookspath = _load_fix_module()
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _make_isolated_git_environment(home_directory: Path) -> dict[str, str]:
|
|
36
|
+
"""Build an env dict that pins git's HOME and XDG paths into a tmp directory.
|
|
37
|
+
|
|
38
|
+
Without this, real `git config --global` reads/writes hit the developer's
|
|
39
|
+
actual ~/.gitconfig — which would corrupt the host machine and make tests
|
|
40
|
+
depend on global state. Pointing HOME, USERPROFILE, XDG_CONFIG_HOME, and
|
|
41
|
+
GIT_CONFIG_GLOBAL at a temp directory isolates the test fully.
|
|
42
|
+
"""
|
|
43
|
+
isolated_environment = os.environ.copy()
|
|
44
|
+
isolated_environment["HOME"] = str(home_directory)
|
|
45
|
+
isolated_environment["USERPROFILE"] = str(home_directory)
|
|
46
|
+
isolated_environment["XDG_CONFIG_HOME"] = str(home_directory / ".config")
|
|
47
|
+
isolated_environment["GIT_CONFIG_GLOBAL"] = str(home_directory / ".gitconfig")
|
|
48
|
+
isolated_environment["GIT_CONFIG_NOSYSTEM"] = "1"
|
|
49
|
+
return isolated_environment
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _initialize_repository(repository_path: Path, environment: dict[str, str]) -> None:
|
|
53
|
+
repository_path.mkdir(parents=True, exist_ok=True)
|
|
54
|
+
subprocess.run(
|
|
55
|
+
["git", "init", "--quiet", str(repository_path)],
|
|
56
|
+
check=True,
|
|
57
|
+
env=environment,
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _set_local_hooks_path(
|
|
62
|
+
repository_path: Path,
|
|
63
|
+
hooks_path_value: str,
|
|
64
|
+
environment: dict[str, str],
|
|
65
|
+
) -> None:
|
|
66
|
+
subprocess.run(
|
|
67
|
+
[
|
|
68
|
+
"git",
|
|
69
|
+
"-C",
|
|
70
|
+
str(repository_path),
|
|
71
|
+
"config",
|
|
72
|
+
"--local",
|
|
73
|
+
"core.hooksPath",
|
|
74
|
+
hooks_path_value,
|
|
75
|
+
],
|
|
76
|
+
check=True,
|
|
77
|
+
env=environment,
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _set_global_hooks_path(hooks_path_value: str, environment: dict[str, str]) -> None:
|
|
82
|
+
subprocess.run(
|
|
83
|
+
["git", "config", "--global", "core.hooksPath", hooks_path_value],
|
|
84
|
+
check=True,
|
|
85
|
+
env=environment,
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _read_local_hooks_path(repository_path: Path, environment: dict[str, str]) -> str:
|
|
90
|
+
completed_process = subprocess.run(
|
|
91
|
+
[
|
|
92
|
+
"git",
|
|
93
|
+
"-C",
|
|
94
|
+
str(repository_path),
|
|
95
|
+
"config",
|
|
96
|
+
"--local",
|
|
97
|
+
"--get",
|
|
98
|
+
"core.hooksPath",
|
|
99
|
+
],
|
|
100
|
+
capture_output=True,
|
|
101
|
+
text=True,
|
|
102
|
+
check=False,
|
|
103
|
+
env=environment,
|
|
104
|
+
)
|
|
105
|
+
return completed_process.stdout.strip()
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _read_global_hooks_path(environment: dict[str, str]) -> str:
|
|
109
|
+
completed_process = subprocess.run(
|
|
110
|
+
["git", "config", "--global", "--get", "core.hooksPath"],
|
|
111
|
+
capture_output=True,
|
|
112
|
+
text=True,
|
|
113
|
+
check=False,
|
|
114
|
+
env=environment,
|
|
115
|
+
)
|
|
116
|
+
return completed_process.stdout.strip()
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _create_canonical_hooks_directory(home_directory: Path) -> Path:
|
|
120
|
+
canonical_hooks_directory = home_directory / ".claude" / "hooks" / "git-hooks"
|
|
121
|
+
canonical_hooks_directory.mkdir(parents=True)
|
|
122
|
+
return canonical_hooks_directory
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def test_should_remove_local_override_and_pass_preflight(tmp_path: Path) -> None:
|
|
126
|
+
home_directory = tmp_path / "home"
|
|
127
|
+
home_directory.mkdir()
|
|
128
|
+
environment = _make_isolated_git_environment(home_directory)
|
|
129
|
+
canonical_hooks_directory = _create_canonical_hooks_directory(home_directory)
|
|
130
|
+
_set_global_hooks_path(str(canonical_hooks_directory), environment)
|
|
131
|
+
repository_path = tmp_path / "synthetic-repo"
|
|
132
|
+
_initialize_repository(repository_path, environment)
|
|
133
|
+
stale_local_value = str(repository_path / ".git" / "hooks")
|
|
134
|
+
_set_local_hooks_path(repository_path, stale_local_value, environment)
|
|
135
|
+
|
|
136
|
+
exit_code = bugteam_fix_hookspath.main(
|
|
137
|
+
["--repo-root", str(repository_path)],
|
|
138
|
+
environment_overrides=environment,
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
assert exit_code == 0, (
|
|
142
|
+
"fix script must succeed when canonical global hooks dir exists"
|
|
143
|
+
)
|
|
144
|
+
assert _read_local_hooks_path(repository_path, environment) == "", (
|
|
145
|
+
"local core.hooksPath override must be removed"
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def test_should_set_global_hooks_path_when_missing(tmp_path: Path) -> None:
|
|
150
|
+
home_directory = tmp_path / "home"
|
|
151
|
+
home_directory.mkdir()
|
|
152
|
+
environment = _make_isolated_git_environment(home_directory)
|
|
153
|
+
canonical_hooks_directory = _create_canonical_hooks_directory(home_directory)
|
|
154
|
+
repository_path = tmp_path / "synthetic-repo"
|
|
155
|
+
_initialize_repository(repository_path, environment)
|
|
156
|
+
stale_local_value = str(repository_path / ".git" / "hooks")
|
|
157
|
+
_set_local_hooks_path(repository_path, stale_local_value, environment)
|
|
158
|
+
|
|
159
|
+
exit_code = bugteam_fix_hookspath.main(
|
|
160
|
+
["--repo-root", str(repository_path)],
|
|
161
|
+
environment_overrides=environment,
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
assert exit_code == 0
|
|
165
|
+
global_value_after_fix = _read_global_hooks_path(environment)
|
|
166
|
+
assert (
|
|
167
|
+
global_value_after_fix.replace("\\", "/")
|
|
168
|
+
.rstrip("/")
|
|
169
|
+
.endswith("hooks/git-hooks")
|
|
170
|
+
), (
|
|
171
|
+
"fix script must set canonical global core.hooksPath when missing; "
|
|
172
|
+
f"got '{global_value_after_fix}'"
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def test_should_be_idempotent(tmp_path: Path) -> None:
|
|
177
|
+
home_directory = tmp_path / "home"
|
|
178
|
+
home_directory.mkdir()
|
|
179
|
+
environment = _make_isolated_git_environment(home_directory)
|
|
180
|
+
canonical_hooks_directory = _create_canonical_hooks_directory(home_directory)
|
|
181
|
+
_set_global_hooks_path(str(canonical_hooks_directory), environment)
|
|
182
|
+
repository_path = tmp_path / "synthetic-repo"
|
|
183
|
+
_initialize_repository(repository_path, environment)
|
|
184
|
+
stale_local_value = str(repository_path / ".git" / "hooks")
|
|
185
|
+
_set_local_hooks_path(repository_path, stale_local_value, environment)
|
|
186
|
+
|
|
187
|
+
first_exit_code = bugteam_fix_hookspath.main(
|
|
188
|
+
["--repo-root", str(repository_path)],
|
|
189
|
+
environment_overrides=environment,
|
|
190
|
+
)
|
|
191
|
+
second_exit_code = bugteam_fix_hookspath.main(
|
|
192
|
+
["--repo-root", str(repository_path)],
|
|
193
|
+
environment_overrides=environment,
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
assert first_exit_code == 0
|
|
197
|
+
assert second_exit_code == 0, "second invocation must succeed without errors"
|
|
198
|
+
assert _read_local_hooks_path(repository_path, environment) == ""
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def test_should_no_op_when_already_clean(tmp_path: Path) -> None:
|
|
202
|
+
home_directory = tmp_path / "home"
|
|
203
|
+
home_directory.mkdir()
|
|
204
|
+
environment = _make_isolated_git_environment(home_directory)
|
|
205
|
+
canonical_hooks_directory = _create_canonical_hooks_directory(home_directory)
|
|
206
|
+
_set_global_hooks_path(str(canonical_hooks_directory), environment)
|
|
207
|
+
repository_path = tmp_path / "synthetic-repo"
|
|
208
|
+
_initialize_repository(repository_path, environment)
|
|
209
|
+
|
|
210
|
+
exit_code = bugteam_fix_hookspath.main(
|
|
211
|
+
["--repo-root", str(repository_path)],
|
|
212
|
+
environment_overrides=environment,
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
assert exit_code == 0
|
|
216
|
+
assert _read_local_hooks_path(repository_path, environment) == ""
|
|
217
|
+
assert (
|
|
218
|
+
_read_global_hooks_path(environment)
|
|
219
|
+
.replace("\\", "/")
|
|
220
|
+
.rstrip("/")
|
|
221
|
+
.endswith("hooks/git-hooks")
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def test_should_exit_nonzero_when_canonical_hooks_directory_missing(
|
|
226
|
+
tmp_path: Path,
|
|
227
|
+
capsys: pytest.CaptureFixture[str],
|
|
228
|
+
) -> None:
|
|
229
|
+
home_directory = tmp_path / "home"
|
|
230
|
+
home_directory.mkdir()
|
|
231
|
+
environment = _make_isolated_git_environment(home_directory)
|
|
232
|
+
repository_path = tmp_path / "synthetic-repo"
|
|
233
|
+
_initialize_repository(repository_path, environment)
|
|
234
|
+
stale_local_value = str(repository_path / ".git" / "hooks")
|
|
235
|
+
_set_local_hooks_path(repository_path, stale_local_value, environment)
|
|
236
|
+
|
|
237
|
+
exit_code = bugteam_fix_hookspath.main(
|
|
238
|
+
["--repo-root", str(repository_path)],
|
|
239
|
+
environment_overrides=environment,
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
assert exit_code != 0, (
|
|
243
|
+
"fix script must fail clearly when ~/.claude/hooks/git-hooks does not exist "
|
|
244
|
+
"so the user knows to run `npx claude-dev-env .`"
|
|
245
|
+
)
|
|
246
|
+
captured_streams = capsys.readouterr()
|
|
247
|
+
assert "hooks/git-hooks" in captured_streams.err.replace("\\", "/")
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def test_should_handle_paths_with_spaces(tmp_path: Path) -> None:
|
|
251
|
+
home_directory = tmp_path / "home with space"
|
|
252
|
+
home_directory.mkdir()
|
|
253
|
+
environment = _make_isolated_git_environment(home_directory)
|
|
254
|
+
canonical_hooks_directory = _create_canonical_hooks_directory(home_directory)
|
|
255
|
+
_set_global_hooks_path(str(canonical_hooks_directory), environment)
|
|
256
|
+
repository_path = tmp_path / "repo with space"
|
|
257
|
+
_initialize_repository(repository_path, environment)
|
|
258
|
+
stale_local_value = str(repository_path / ".git" / "hooks")
|
|
259
|
+
_set_local_hooks_path(repository_path, stale_local_value, environment)
|
|
260
|
+
|
|
261
|
+
exit_code = bugteam_fix_hookspath.main(
|
|
262
|
+
["--repo-root", str(repository_path)],
|
|
263
|
+
environment_overrides=environment,
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
assert exit_code == 0
|
|
267
|
+
assert _read_local_hooks_path(repository_path, environment) == ""
|