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.
Files changed (28) hide show
  1. package/hooks/blocking/code_rules_enforcer.py +109 -0
  2. package/hooks/blocking/test_windows_rmtree_blocker.py +155 -0
  3. package/hooks/blocking/windows_rmtree_blocker.py +102 -0
  4. package/hooks/config/hook_log_extractor_constants.py +13 -0
  5. package/hooks/config/session_env_cleanup_constants.py +20 -0
  6. package/hooks/config/test_hook_log_extractor_constants.py +27 -0
  7. package/hooks/config/test_session_env_cleanup_constants.py +60 -0
  8. package/hooks/diagnostic/hook_log_stop_wrapper.py +107 -19
  9. package/hooks/diagnostic/test_hook_log_stop_wrapper.py +258 -11
  10. package/hooks/hooks.json +15 -0
  11. package/hooks/session/session_env_cleanup.py +130 -0
  12. package/hooks/session/test_session_env_cleanup.py +280 -0
  13. package/package.json +1 -1
  14. package/rules/windows-filesystem-safe.md +91 -0
  15. package/skills/bugteam/PROMPTS.md +39 -0
  16. package/skills/bugteam/SKILL.md +49 -1
  17. package/skills/bugteam/SKILL_EVALS.md +1 -1
  18. package/skills/bugteam/reference/copilot-gap-analysis.md +496 -0
  19. package/skills/bugteam/reference/teardown-publish-permissions.md +1 -1
  20. package/skills/bugteam/scripts/README.md +17 -0
  21. package/skills/bugteam/scripts/bugteam_code_rules_gate.py +94 -0
  22. package/skills/bugteam/scripts/bugteam_fix_hookspath.py +260 -0
  23. package/skills/bugteam/scripts/config/__init__.py +0 -0
  24. package/skills/bugteam/scripts/config/bugteam_fix_hookspath_constants.py +17 -0
  25. package/skills/bugteam/scripts/test_bugteam_fix_hookspath.py +267 -0
  26. package/skills/logifix/SKILL.md +69 -0
  27. package/skills/logifix/scripts/logifix.ps1 +205 -0
  28. 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) == ""