claude-dev-env 2.4.0 → 2.7.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/CLAUDE.md +26 -59
- package/_shared/pr-loop/scripts/_claude_permissions_common.py +84 -0
- package/_shared/pr-loop/scripts/code_rules_gate.py +6 -3
- package/_shared/pr-loop/scripts/code_rules_gate_parts/CLAUDE.md +12 -2
- package/_shared/pr-loop/scripts/code_rules_gate_parts/baseline_import_isolation.py +309 -0
- package/_shared/pr-loop/scripts/code_rules_gate_parts/staged_test_regression.py +540 -0
- package/_shared/pr-loop/scripts/code_rules_gate_parts/staged_test_running.py +206 -70
- package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/__init__.py +1 -0
- package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/_repo_test_helpers.py +76 -0
- package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_baseline_import_isolation.py +248 -0
- package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_staged_test_regression.py +309 -0
- package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_staged_test_running.py +91 -58
- package/_shared/pr-loop/scripts/grant_project_claude_permissions.py +306 -306
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/claude_permissions_constants.py +44 -0
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/code_rules_gate_constants.py +202 -0
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/copilot_quota_constants.py +24 -24
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/stale_worktree_rule_sweep_constants.py +107 -107
- package/_shared/pr-loop/scripts/revoke_project_claude_permissions.py +290 -48
- package/_shared/pr-loop/scripts/tests/test_claude_permissions_common.py +42 -2
- package/_shared/pr-loop/scripts/tests/test_claude_permissions_constants.py +36 -0
- package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +100 -1
- package/_shared/pr-loop/scripts/tests/test_fix_hookspath.py +497 -497
- package/_shared/pr-loop/scripts/tests/test_revoke_project_claude_permissions.py +311 -2
- package/_shared/pr-loop/scripts/tests/test_stale_worktree_rule_sweep.py +301 -301
- package/_shared/pr-loop/scripts/tests/test_stale_worktree_rule_sweep_constants.py +85 -85
- package/_shared/pr-loop/worker-spawn.md +1 -1
- package/agents/CLAUDE.md +3 -2
- package/agents/caveman.md +0 -1
- package/agents/clasp-deployment-orchestrator.md +0 -1
- package/agents/clean-coder.md +0 -1
- package/agents/code-advisor.md +0 -1
- package/agents/code-quality-agent.md +1 -2
- package/agents/code-verifier.md +36 -8
- package/agents/deep-research.md +0 -1
- package/agents/docs-agent.md +0 -1
- package/agents/git-commit-crafter.md +0 -1
- package/agents/issue-tracker.md +42 -0
- package/agents/plan-packet-validator.md +0 -1
- package/agents/pr-description-writer.md +0 -1
- package/agents/test_agent_frontmatter.py +67 -18
- package/audit-rubrics/category_rubrics/category-o-docstring-vs-impl-drift.md +143 -141
- package/bin/CLAUDE.md +68 -5
- package/bin/codex-compat.mjs +104 -0
- package/bin/codex-compat.test.mjs +51 -0
- package/bin/ever-shipped-skills.mjs +1 -0
- package/bin/install-constants.mjs +88 -0
- package/bin/install.mjs +1138 -114
- package/bin/install.prune.test.mjs +869 -19
- package/bin/install.test.mjs +906 -2
- package/codex-capability-map.json +13 -0
- package/commands/implement.md +1 -1
- package/commands/right-size.md +1 -1
- package/docs/CLAUDE.md +1 -0
- package/docs/CODE_RULES.md +2 -0
- package/docs/codex-compatibility.md +25 -0
- package/docs/host-pool-health-monitor.md +102 -0
- package/docs/nas-ssh-invocation.md +96 -12
- package/docs/references/CLAUDE.md +4 -2
- package/docs/references/advisor-tool.md +13 -0
- package/docs/references/code-review-enforcement.md +35 -0
- package/docs/references/team-advisor-skill.md +14 -0
- package/hooks/blocking/CLAUDE.md +4 -0
- package/hooks/blocking/code_review_pr_create_gate.py +7 -3
- package/hooks/blocking/code_review_push_gate.py +9 -4
- package/hooks/blocking/code_review_stamp_directory_write_blocker.py +8 -0
- package/hooks/blocking/config/__init__.py +5 -5
- package/hooks/blocking/config/code_review_enforcement_constants.py +40 -7
- package/hooks/blocking/config/test_code_review_enforcement_constants.py +58 -0
- package/hooks/blocking/config/verified_commit_constants.py +160 -159
- package/hooks/blocking/eli11_reply_enforcer.py +479 -0
- package/hooks/blocking/gh_body_arg_blocker.py +1 -1
- package/hooks/blocking/nas_ssh_binary_enforcer.py +8 -46
- package/hooks/blocking/orchestrator_refresh_reschedule_gate.py +256 -0
- package/hooks/blocking/pre_tool_use_dispatcher.py +24 -24
- package/hooks/blocking/shell_substitution_blocker.py +129 -0
- package/hooks/blocking/state_description_blocker.py +1 -1
- package/hooks/blocking/stop_dispatcher.py +1 -1
- package/hooks/blocking/test_bash_pre_tool_use_dispatcher.py +2 -3
- package/hooks/blocking/test_code_review_pr_create_gate.py +14 -0
- package/hooks/blocking/test_code_review_push_gate.py +16 -0
- package/hooks/blocking/test_code_review_stamp_directory_write_blocker.py +19 -0
- package/hooks/blocking/test_eli11_reply_enforcer.py +457 -0
- package/hooks/blocking/test_orchestrator_refresh_reschedule_gate.py +231 -0
- package/hooks/blocking/test_pre_tool_use_dispatcher.py +10 -1
- package/hooks/blocking/test_shell_substitution_blocker.py +124 -0
- package/hooks/blocking/test_stop_dispatcher.py +23 -0
- package/hooks/blocking/test_unscoped_search_blocker.py +102 -0
- package/hooks/blocking/test_verdict_directory_write_blocker.py +804 -808
- package/hooks/blocking/test_verification_verdict_store.py +54 -0
- package/hooks/blocking/test_verified_commit_gate.py +581 -581
- package/hooks/blocking/test_verified_commit_message_accuracy_blocker.py +131 -131
- package/hooks/blocking/unscoped_search_blocker.py +391 -0
- package/hooks/blocking/verdict_directory_write_blocker.py +687 -687
- package/hooks/blocking/verification_verdict_store.py +1039 -1036
- package/hooks/blocking/verified_commit_message_accuracy_blocker.py +167 -167
- package/hooks/blocking/verifier_verdict_minter.py +280 -280
- package/hooks/git-hooks/CLAUDE.md +3 -0
- package/hooks/git-hooks/conftest.py +30 -0
- package/hooks/git-hooks/gate_utils.py +2 -2
- package/hooks/git-hooks/git_hooks_constants/__init__.py +41 -2
- package/hooks/git-hooks/pre_push.py +75 -4
- package/hooks/git-hooks/pre_push_base_reference.py +166 -0
- package/hooks/git-hooks/test_config.py +0 -15
- package/hooks/git-hooks/test_gate_utils.py +3 -15
- package/hooks/git-hooks/test_pre_commit.py +1 -15
- package/hooks/git-hooks/test_pre_push.py +257 -23
- package/hooks/git-hooks/test_pre_push_base_reference.py +339 -0
- package/hooks/hooks.json +10 -12
- package/hooks/hooks_constants/CLAUDE.md +7 -2
- package/hooks/hooks_constants/bash_pre_tool_use_dispatcher_constants.py +4 -4
- package/hooks/hooks_constants/eli11_reply_enforcer_constants.py +101 -0
- package/hooks/hooks_constants/enter_worktree_prefetch_constants.py +18 -18
- package/hooks/hooks_constants/nas_ssh_binary_enforcer_constants.py +2 -8
- package/hooks/hooks_constants/orchestrator_refresh_reschedule_gate_constants.py +48 -0
- package/hooks/hooks_constants/ruff_integration_constants.py +16 -0
- package/hooks/hooks_constants/shell_command_segments.py +82 -0
- package/hooks/hooks_constants/shell_substitution_blocker_constants.py +67 -0
- package/hooks/hooks_constants/stop_dispatcher_constants.py +1 -0
- package/hooks/hooks_constants/test_bash_pre_tool_use_dispatcher_constants.py +5 -6
- package/hooks/hooks_constants/test_stop_dispatcher_constants.py +1 -0
- package/hooks/hooks_constants/unscoped_search_blocker_constants.py +153 -0
- package/hooks/lifecycle/enter_worktree_origin_prefetch.py +163 -146
- package/hooks/lifecycle/test_enter_worktree_origin_prefetch.py +185 -178
- package/hooks/pyproject.toml +1 -0
- package/hooks/validators/CLAUDE.md +1 -0
- package/hooks/validators/config/__init__.py +0 -0
- package/hooks/validators/config/directory_exemption_constants.py +183 -0
- package/hooks/validators/config/test_directory_exemption_constants.py +21 -0
- package/hooks/validators/conftest.py +4 -0
- package/hooks/validators/ruff_integration.py +49 -5
- package/hooks/validators/run_all_validators.py +206 -9
- package/hooks/validators/test_directory_exemption_constants.py +185 -0
- package/hooks/validators/test_python_antipattern_checks.py +110 -5
- package/hooks/validators/test_ruff_integration.py +92 -1
- package/hooks/validators/test_run_all_validators.py +115 -68
- package/hooks/validators/test_run_all_validators_pretooluse.py +159 -1
- package/package.json +13 -3
- package/rules/CLAUDE.md +17 -22
- package/rules/agent-spawn-protocol.md +6 -6
- package/rules/anti-corollary-tests.md +1 -1
- package/rules/bdd.md +1 -1
- package/rules/cleanup-temp-files.md +10 -4
- package/rules/code-standards.md +7 -0
- package/rules/conservative-action.md +1 -5
- package/rules/context7.md +0 -4
- package/rules/destructive-commands.md +47 -0
- package/rules/doc-inventory-integrity.md +48 -0
- package/rules/doc-prose-cuts.md +58 -0
- package/rules/docstring-prose-matches-implementation.md +53 -44
- package/rules/durable-post-artifacts.md +0 -4
- package/rules/eli11-replies.md +31 -0
- package/rules/explore-thoroughly.md +4 -4
- package/rules/falsify-before-green.md +68 -0
- package/rules/file-global-constants.md +1 -1
- package/rules/filesystem-search.md +51 -0
- package/rules/gh-cli-conventions.md +27 -0
- package/rules/git-workflow.md +26 -0
- package/rules/hedging-claims.md +9 -0
- package/rules/long-horizon-autonomy.md +0 -4
- package/rules/measurement-denominators.md +48 -0
- package/rules/nas-ssh-invocation.md +23 -5
- package/rules/parallel-tools.md +2 -2
- package/rules/plain-illustrative-docstrings.md +3 -7
- package/rules/plain-language.md +2 -0
- package/rules/proof-of-work-pr-comments.md +0 -4
- package/rules/re-stage-before-commit.md +2 -0
- package/rules/research-mode.md +10 -0
- package/rules/shell-invocation.md +21 -0
- package/rules/testing.md +4 -0
- package/rules/verified-commit-gate-skip.md +3 -27
- package/rules/verify-before-asking.md +5 -0
- package/rules/windows-filesystem-safe.md +1 -1
- package/rules/workers-done-before-complete.md +4 -0
- package/scripts/CLAUDE.md +1 -0
- package/scripts/Capture-PoolHealth.ps1 +410 -0
- package/scripts/Migrate-ShellPolicy.ps1 +1 -1
- package/scripts/_code_review_test_support.py +404 -0
- package/scripts/claude_chain_runner.py +141 -1
- package/scripts/codex_capability_bridge.py +171 -0
- package/scripts/codex_compat_materializer.py +1087 -0
- package/scripts/codex_compat_watcher.py +502 -0
- package/scripts/conftest.py +16 -1
- package/scripts/dev_env_scripts_constants/CLAUDE.md +1 -1
- package/scripts/dev_env_scripts_constants/claude_chain_constants.py +9 -0
- package/scripts/dev_env_scripts_constants/code_review_constants.py +37 -0
- package/scripts/invoke_code_review.py +11 -4
- package/scripts/resolve_worker_spawn.py +626 -626
- package/scripts/spawn_grok_batch.py +672 -672
- package/scripts/sync_to_cursor/rules.py +0 -10
- package/scripts/test_claude_chain_runner.py +131 -0
- package/scripts/test_invoke_code_review.py +85 -908
- package/scripts/test_invoke_code_review_chain.py +70 -0
- package/scripts/test_invoke_code_review_cli.py +192 -0
- package/scripts/test_invoke_code_review_contract.py +256 -0
- package/scripts/test_invoke_code_review_git.py +123 -0
- package/scripts/test_invoke_code_review_mode.py +99 -0
- package/scripts/test_resolve_worker_spawn.py +1014 -1014
- package/scripts/tests/test_code_review_constants.py +80 -0
- package/scripts/tests/test_codex_capability_bridge.py +91 -0
- package/scripts/tests/test_codex_compat_materializer.py +632 -0
- package/scripts/tests/test_codex_compat_watcher.py +599 -0
- package/scripts/tests/test_sync_to_cursor.py +0 -1
- package/skills/CLAUDE.md +2 -0
- package/skills/auditing-claude-config/SKILL.md +114 -114
- package/skills/autoconverge/SKILL.md +427 -427
- package/skills/autoconverge/reference/convergence.md +24 -3
- package/skills/autoconverge/workflow/CLAUDE.md +1 -0
- package/skills/autoconverge/workflow/converge.clean-audit.test.mjs +3 -3
- package/skills/autoconverge/workflow/converge.contract.test.mjs +1263 -1263
- package/skills/autoconverge/workflow/converge.mjs +168 -1
- package/skills/autoconverge/workflow/converge.p2-advance.test.mjs +202 -0
- package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a11d903476b803493.jsonl +2 -2
- package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a26213978adeef6fb.jsonl +2 -2
- package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a3def0d15ed9d9110.jsonl +2 -2
- package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a41f41b1b708ee3b7.jsonl +2 -2
- package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a758b880abecc3ff7.jsonl +2 -2
- package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a8897b89656b1bd16.jsonl +2 -2
- package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-abd463d744a1437bc.jsonl +2 -2
- package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-ad19d027ae8ee1816.jsonl +2 -2
- package/skills/autoconverge/workflow/fixtures/wf_run/workflows/wf_881252e6-700.json +265 -265
- package/skills/bugteam/reference/copilot-gap-analysis.md +1 -1
- package/skills/closeout/SKILL.md +33 -50
- package/skills/codex-review/scripts/codex_review_scripts_constants/run_constants.py +8 -0
- package/skills/codex-review/scripts/run_codex_review.py +233 -1
- package/skills/codex-review/scripts/test_run_codex_review.py +189 -0
- package/skills/condensing-instructions/SKILL.md +72 -0
- package/skills/copilot-review/SKILL.md +119 -119
- package/skills/e-code-review/SKILL.md +52 -0
- package/skills/e-code-review/reference/fix.md +54 -0
- package/skills/e-code-review/reference/loop.md +43 -0
- package/skills/e-code-review/reference/low.md +57 -0
- package/skills/e-code-review/reference/medium.md +153 -0
- package/skills/e-code-review/reference/xhigh.md +182 -0
- package/skills/e-simplify/SKILL.md +97 -0
- package/skills/fresh-branch/CLAUDE.md +1 -1
- package/skills/fresh-branch/SKILL.md +5 -6
- package/skills/fresh-branch/scripts/create_fresh_branch.py +42 -24
- package/skills/fresh-branch/scripts/fresh_branch_scripts_constants/fresh_branch_cli_constants.py +1 -3
- package/skills/fresh-branch/scripts/test_create_fresh_branch.py +30 -126
- package/skills/issue-tracker/SKILL.md +92 -0
- package/skills/issue-tracker/reference/epic-and-sub-issue-model.md +55 -0
- package/skills/issue-tracker/reference/handoff-schema.md +64 -0
- package/skills/issue-tracker/reference/operation-matrix.md +41 -0
- package/skills/orchestrator/SKILL.md +177 -22
- package/skills/orchestrator/scripts/status_gate.py +625 -0
- package/skills/orchestrator/scripts/status_gate_constants/__init__.py +1 -0
- package/skills/orchestrator/scripts/status_gate_constants/config/__init__.py +1 -0
- package/skills/orchestrator/scripts/status_gate_constants/config/constants.py +47 -0
- package/skills/orchestrator/scripts/test_status_gate.py +439 -0
- package/skills/orchestrator-refresh/SKILL.md +129 -35
- package/skills/plan-to-pr/SKILL.md +155 -0
- package/skills/plan-to-pr/reference/final-validation-tasks.md +15 -0
- package/skills/plan-to-pr/reference/model-routing.md +36 -0
- package/skills/plan-to-pr/reference/packet-contract.md +43 -0
- package/skills/plan-to-pr/reference/packet-schema.json +57 -0
- package/skills/plan-to-pr/reference/process-inventory.md +22 -0
- package/skills/plan-to-pr/reference/review-loop.md +33 -0
- package/skills/plan-to-pr/reference/run-record.schema.json +27 -0
- package/skills/plan-to-pr/reference/self-audit-tasks.md +15 -0
- package/skills/plan-to-pr/reference/task-seeds.md +14 -0
- package/skills/plan-to-pr/reference/task-ticket.md +38 -0
- package/skills/plan-to-pr/scripts/config/__init__.py +1 -0
- package/skills/plan-to-pr/scripts/config/constants.py +193 -0
- package/skills/plan-to-pr/scripts/create_packet.py +173 -0
- package/skills/plan-to-pr/scripts/test_create_packet.py +102 -0
- package/skills/plan-to-pr/scripts/test_validate_packet.py +256 -0
- package/skills/plan-to-pr/scripts/test_validate_protocol.py +135 -0
- package/skills/plan-to-pr/scripts/test_validate_run.py +158 -0
- package/skills/plan-to-pr/scripts/validate_packet.py +655 -0
- package/skills/plan-to-pr/scripts/validate_protocol.py +622 -0
- package/skills/plan-to-pr/scripts/validate_run.py +173 -0
- package/skills/plan-to-pr/test_skill_contract.py +207 -0
- package/skills/plan-to-pr/test_task_ticket_contract.py +151 -0
- package/skills/pr-converge/SKILL.md +472 -469
- package/skills/pr-converge/reference/examples.md +3 -3
- package/skills/pr-converge/reference/fix-protocol.md +1 -1
- package/skills/pr-converge/reference/ground-rules.md +7 -4
- package/skills/pr-converge/reference/multi-pr-orchestration.md +4 -1
- package/skills/pr-converge/reference/per-tick.md +5 -5
- package/skills/pr-converge/reference/progress-checklist.md +1 -1
- package/skills/pr-converge/scripts/check_convergence_gates.py +279 -279
- package/skills/pr-converge/scripts/test_check_convergence_codex.py +507 -507
- package/skills/pr-converge/scripts/test_check_convergence_gates.py +84 -84
- package/skills/pr-converge/test_step5_host_branch.py +1 -1
- package/skills/pr-fix-protocol/SKILL.md +1 -1
- package/skills/privacy-hygiene/SKILL.md +68 -68
- package/skills/privacy-hygiene/reference/sweep-procedure.md +1 -1
- package/skills/prototype/workflows/promotion.md +1 -1
- package/skills/release-notes-html/SKILL.md +164 -0
- package/skills/session-log/SKILL.md +1 -1
- package/skills/task-build/CLAUDE.md +8 -7
- package/skills/task-build/SKILL.md +16 -8
- package/skills/task-build/reference/tool-routing.md +19 -0
- package/rules/claude-md-orphan-file.md +0 -28
- package/rules/cleanup-command-forms.md +0 -23
- package/rules/code-reviews.md +0 -11
- package/rules/env-var-table-code-drift.md +0 -10
- package/rules/gh-body-file.md +0 -5
- package/rules/gh-paginate.md +0 -3
- package/rules/hook-prose-matches-detector.md +0 -15
- package/rules/no-historical-clutter.md +0 -26
- package/rules/no-inline-destructive-literals.md +0 -9
- package/rules/no-justification-noise.md +0 -61
- package/rules/package-inventory-stale-entry.md +0 -25
- package/rules/right-sized-engineering.md +0 -28
- package/rules/self-contained-docs.md +0 -17
- package/rules/shell-invocation-policy.md +0 -5
- package/rules/tdd.md +0 -7
- package/skills/closeout/reference/issue-body-templates.md +0 -108
|
@@ -1,497 +1,497 @@
|
|
|
1
|
-
"""Tests for shared fix_hookspath.py extracted from skills/bugteam/scripts/.
|
|
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
|
-
- main() maps unexpected git stderr hard-fail RuntimeError to exit 1
|
|
10
|
-
- handles paths with spaces
|
|
11
|
-
"""
|
|
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.parent / "fix_hookspath.py"
|
|
24
|
-
spec = importlib.util.spec_from_file_location("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
|
-
fix_hookspath = _load_fix_module()
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
def _make_isolated_git_environment(home_directory: Path) -> dict[str, str]:
|
|
36
|
-
isolated_environment = os.environ.copy()
|
|
37
|
-
isolated_environment["HOME"] = str(home_directory)
|
|
38
|
-
isolated_environment["USERPROFILE"] = str(home_directory)
|
|
39
|
-
isolated_environment["XDG_CONFIG_HOME"] = str(home_directory / ".config")
|
|
40
|
-
isolated_environment["GIT_CONFIG_GLOBAL"] = str(home_directory / ".gitconfig")
|
|
41
|
-
isolated_environment["GIT_CONFIG_NOSYSTEM"] = "1"
|
|
42
|
-
return isolated_environment
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
def _initialize_repository(repository_path: Path, environment: dict[str, str]) -> None:
|
|
46
|
-
repository_path.mkdir(parents=True, exist_ok=True)
|
|
47
|
-
subprocess.run(
|
|
48
|
-
["git", "init", "--quiet", str(repository_path)],
|
|
49
|
-
check=True,
|
|
50
|
-
env=environment,
|
|
51
|
-
)
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
def _set_local_hooks_path(
|
|
55
|
-
repository_path: Path,
|
|
56
|
-
hooks_path_value: str,
|
|
57
|
-
environment: dict[str, str],
|
|
58
|
-
) -> None:
|
|
59
|
-
subprocess.run(
|
|
60
|
-
[
|
|
61
|
-
"git",
|
|
62
|
-
"-C",
|
|
63
|
-
str(repository_path),
|
|
64
|
-
"config",
|
|
65
|
-
"--local",
|
|
66
|
-
"core.hooksPath",
|
|
67
|
-
hooks_path_value,
|
|
68
|
-
],
|
|
69
|
-
check=True,
|
|
70
|
-
env=environment,
|
|
71
|
-
)
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
def _set_global_hooks_path(hooks_path_value: str, environment: dict[str, str]) -> None:
|
|
75
|
-
subprocess.run(
|
|
76
|
-
["git", "config", "--global", "core.hooksPath", hooks_path_value],
|
|
77
|
-
check=True,
|
|
78
|
-
env=environment,
|
|
79
|
-
)
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
def _read_local_hooks_path(repository_path: Path, environment: dict[str, str]) -> str:
|
|
83
|
-
completed_process = subprocess.run(
|
|
84
|
-
[
|
|
85
|
-
"git",
|
|
86
|
-
"-C",
|
|
87
|
-
str(repository_path),
|
|
88
|
-
"config",
|
|
89
|
-
"--local",
|
|
90
|
-
"--get",
|
|
91
|
-
"core.hooksPath",
|
|
92
|
-
],
|
|
93
|
-
capture_output=True,
|
|
94
|
-
text=True,
|
|
95
|
-
check=False,
|
|
96
|
-
env=environment,
|
|
97
|
-
)
|
|
98
|
-
return completed_process.stdout.strip()
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
def _read_global_hooks_path(environment: dict[str, str]) -> str:
|
|
102
|
-
completed_process = subprocess.run(
|
|
103
|
-
["git", "config", "--global", "--get", "core.hooksPath"],
|
|
104
|
-
capture_output=True,
|
|
105
|
-
text=True,
|
|
106
|
-
check=False,
|
|
107
|
-
env=environment,
|
|
108
|
-
)
|
|
109
|
-
return completed_process.stdout.strip()
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
def _create_canonical_hooks_directory(home_directory: Path) -> Path:
|
|
113
|
-
canonical_hooks_directory = home_directory / ".claude" / "hooks" / "git-hooks"
|
|
114
|
-
canonical_hooks_directory.mkdir(parents=True)
|
|
115
|
-
return canonical_hooks_directory
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
def test_should_remove_local_override_and_pass_preflight(tmp_path: Path) -> None:
|
|
119
|
-
home_directory = tmp_path / "home"
|
|
120
|
-
home_directory.mkdir()
|
|
121
|
-
environment = _make_isolated_git_environment(home_directory)
|
|
122
|
-
canonical_hooks_directory = _create_canonical_hooks_directory(home_directory)
|
|
123
|
-
_set_global_hooks_path(str(canonical_hooks_directory), environment)
|
|
124
|
-
repository_path = tmp_path / "synthetic-repo"
|
|
125
|
-
_initialize_repository(repository_path, environment)
|
|
126
|
-
stale_local_value = str(repository_path / ".git" / "hooks")
|
|
127
|
-
_set_local_hooks_path(repository_path, stale_local_value, environment)
|
|
128
|
-
|
|
129
|
-
exit_code = fix_hookspath.main(
|
|
130
|
-
["--repo-root", str(repository_path)],
|
|
131
|
-
all_environment_overrides=environment,
|
|
132
|
-
)
|
|
133
|
-
|
|
134
|
-
assert exit_code == 0
|
|
135
|
-
assert _read_local_hooks_path(repository_path, environment) == ""
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
def test_should_set_global_hooks_path_when_missing(tmp_path: Path) -> None:
|
|
139
|
-
home_directory = tmp_path / "home"
|
|
140
|
-
home_directory.mkdir()
|
|
141
|
-
environment = _make_isolated_git_environment(home_directory)
|
|
142
|
-
canonical_hooks_directory = _create_canonical_hooks_directory(home_directory)
|
|
143
|
-
repository_path = tmp_path / "synthetic-repo"
|
|
144
|
-
_initialize_repository(repository_path, environment)
|
|
145
|
-
stale_local_value = str(repository_path / ".git" / "hooks")
|
|
146
|
-
_set_local_hooks_path(repository_path, stale_local_value, environment)
|
|
147
|
-
|
|
148
|
-
exit_code = fix_hookspath.main(
|
|
149
|
-
["--repo-root", str(repository_path)],
|
|
150
|
-
all_environment_overrides=environment,
|
|
151
|
-
)
|
|
152
|
-
|
|
153
|
-
assert exit_code == 0
|
|
154
|
-
global_value_after_fix = _read_global_hooks_path(environment)
|
|
155
|
-
assert (
|
|
156
|
-
global_value_after_fix.replace("\\", "/")
|
|
157
|
-
.rstrip("/")
|
|
158
|
-
.endswith("hooks/git-hooks")
|
|
159
|
-
)
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
def test_should_be_idempotent(tmp_path: Path) -> None:
|
|
163
|
-
home_directory = tmp_path / "home"
|
|
164
|
-
home_directory.mkdir()
|
|
165
|
-
environment = _make_isolated_git_environment(home_directory)
|
|
166
|
-
canonical_hooks_directory = _create_canonical_hooks_directory(home_directory)
|
|
167
|
-
_set_global_hooks_path(str(canonical_hooks_directory), environment)
|
|
168
|
-
repository_path = tmp_path / "synthetic-repo"
|
|
169
|
-
_initialize_repository(repository_path, environment)
|
|
170
|
-
stale_local_value = str(repository_path / ".git" / "hooks")
|
|
171
|
-
_set_local_hooks_path(repository_path, stale_local_value, environment)
|
|
172
|
-
|
|
173
|
-
first_exit_code = fix_hookspath.main(
|
|
174
|
-
["--repo-root", str(repository_path)],
|
|
175
|
-
all_environment_overrides=environment,
|
|
176
|
-
)
|
|
177
|
-
second_exit_code = fix_hookspath.main(
|
|
178
|
-
["--repo-root", str(repository_path)],
|
|
179
|
-
all_environment_overrides=environment,
|
|
180
|
-
)
|
|
181
|
-
|
|
182
|
-
assert first_exit_code == 0
|
|
183
|
-
assert second_exit_code == 0
|
|
184
|
-
assert _read_local_hooks_path(repository_path, environment) == ""
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
def test_should_no_op_when_already_clean(tmp_path: Path) -> None:
|
|
188
|
-
home_directory = tmp_path / "home"
|
|
189
|
-
home_directory.mkdir()
|
|
190
|
-
environment = _make_isolated_git_environment(home_directory)
|
|
191
|
-
canonical_hooks_directory = _create_canonical_hooks_directory(home_directory)
|
|
192
|
-
_set_global_hooks_path(str(canonical_hooks_directory), environment)
|
|
193
|
-
repository_path = tmp_path / "synthetic-repo"
|
|
194
|
-
_initialize_repository(repository_path, environment)
|
|
195
|
-
|
|
196
|
-
exit_code = fix_hookspath.main(
|
|
197
|
-
["--repo-root", str(repository_path)],
|
|
198
|
-
all_environment_overrides=environment,
|
|
199
|
-
)
|
|
200
|
-
|
|
201
|
-
assert exit_code == 0
|
|
202
|
-
assert _read_local_hooks_path(repository_path, environment) == ""
|
|
203
|
-
assert (
|
|
204
|
-
_read_global_hooks_path(environment)
|
|
205
|
-
.replace("\\", "/")
|
|
206
|
-
.rstrip("/")
|
|
207
|
-
.endswith("hooks/git-hooks")
|
|
208
|
-
)
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
def test_should_exit_nonzero_when_canonical_hooks_directory_missing(
|
|
212
|
-
tmp_path: Path,
|
|
213
|
-
capsys: pytest.CaptureFixture[str],
|
|
214
|
-
) -> None:
|
|
215
|
-
home_directory = tmp_path / "home"
|
|
216
|
-
home_directory.mkdir()
|
|
217
|
-
environment = _make_isolated_git_environment(home_directory)
|
|
218
|
-
repository_path = tmp_path / "synthetic-repo"
|
|
219
|
-
_initialize_repository(repository_path, environment)
|
|
220
|
-
stale_local_value = str(repository_path / ".git" / "hooks")
|
|
221
|
-
_set_local_hooks_path(repository_path, stale_local_value, environment)
|
|
222
|
-
|
|
223
|
-
exit_code = fix_hookspath.main(
|
|
224
|
-
["--repo-root", str(repository_path)],
|
|
225
|
-
all_environment_overrides=environment,
|
|
226
|
-
)
|
|
227
|
-
|
|
228
|
-
assert exit_code != 0
|
|
229
|
-
captured_streams = capsys.readouterr()
|
|
230
|
-
assert "hooks/git-hooks" in captured_streams.err.replace("\\", "/")
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
def test_main_exits_one_on_unexpected_git_stderr(
|
|
234
|
-
tmp_path: Path,
|
|
235
|
-
capsys: pytest.CaptureFixture[str],
|
|
236
|
-
monkeypatch: pytest.MonkeyPatch,
|
|
237
|
-
) -> None:
|
|
238
|
-
"""main() must catch hard-fail RuntimeError from git stderr and exit 1 cleanly."""
|
|
239
|
-
home_directory = tmp_path / "home"
|
|
240
|
-
home_directory.mkdir()
|
|
241
|
-
environment = _make_isolated_git_environment(home_directory)
|
|
242
|
-
_create_canonical_hooks_directory(home_directory)
|
|
243
|
-
repository_path = tmp_path / "synthetic-repo"
|
|
244
|
-
_initialize_repository(repository_path, environment)
|
|
245
|
-
unexpected_git_stderr = (
|
|
246
|
-
"fatal: not a git repository (or any parent up to mount point /)"
|
|
247
|
-
)
|
|
248
|
-
failing_completed_process = subprocess.CompletedProcess(
|
|
249
|
-
args=["git"],
|
|
250
|
-
returncode=128,
|
|
251
|
-
stdout="",
|
|
252
|
-
stderr=unexpected_git_stderr,
|
|
253
|
-
)
|
|
254
|
-
monkeypatch.setattr(
|
|
255
|
-
fix_hookspath.subprocess,
|
|
256
|
-
"run",
|
|
257
|
-
lambda *_args, **_kwargs: failing_completed_process,
|
|
258
|
-
)
|
|
259
|
-
|
|
260
|
-
exit_code = fix_hookspath.main(
|
|
261
|
-
["--repo-root", str(repository_path)],
|
|
262
|
-
all_environment_overrides=environment,
|
|
263
|
-
)
|
|
264
|
-
|
|
265
|
-
assert exit_code == 1
|
|
266
|
-
captured_streams = capsys.readouterr()
|
|
267
|
-
assert "not a git repository" in captured_streams.err
|
|
268
|
-
assert "Traceback" not in captured_streams.err
|
|
269
|
-
assert "Traceback" not in captured_streams.out
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
def test_is_canonical_hooks_path_still_recognizes_canonical_suffix() -> None:
|
|
273
|
-
canonical_value_with_suffix = "/home/example/.claude/hooks/git-hooks"
|
|
274
|
-
assert fix_hookspath.is_canonical_hooks_path(canonical_value_with_suffix)
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
def test_resolve_canonical_hooks_directory_uses_home_env_overrides(
|
|
278
|
-
tmp_path: Path,
|
|
279
|
-
) -> None:
|
|
280
|
-
fake_home = tmp_path / "fake_home"
|
|
281
|
-
fake_home.mkdir()
|
|
282
|
-
overrides = {"HOME": str(fake_home), "USERPROFILE": str(fake_home)}
|
|
283
|
-
resolved = fix_hookspath.resolve_canonical_hooks_directory(overrides)
|
|
284
|
-
assert resolved == fake_home / ".claude" / "hooks" / "git-hooks"
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
def test_list_local_core_hooks_path_values_raises_on_unexpected_git_failure(
|
|
288
|
-
tmp_path: Path,
|
|
289
|
-
monkeypatch: pytest.MonkeyPatch,
|
|
290
|
-
) -> None:
|
|
291
|
-
"""When git -C ... config --get-all exits non-zero with stderr, raise RuntimeError."""
|
|
292
|
-
failing_completed_process = subprocess.CompletedProcess(
|
|
293
|
-
args=["git"],
|
|
294
|
-
returncode=128,
|
|
295
|
-
stdout="",
|
|
296
|
-
stderr="fatal: not a git repository (or any parent up to mount point /)",
|
|
297
|
-
)
|
|
298
|
-
monkeypatch.setattr(
|
|
299
|
-
fix_hookspath.subprocess, "run", lambda *_args, **_kwargs: failing_completed_process
|
|
300
|
-
)
|
|
301
|
-
|
|
302
|
-
with pytest.raises(RuntimeError, match="not a git repository"):
|
|
303
|
-
fix_hookspath.list_local_core_hooks_path_values(
|
|
304
|
-
tmp_path / "any-repo",
|
|
305
|
-
None,
|
|
306
|
-
)
|
|
307
|
-
|
|
308
|
-
def test_list_local_core_hooks_path_values_quiet_when_stderr_empty(
|
|
309
|
-
tmp_path: Path,
|
|
310
|
-
capsys: pytest.CaptureFixture[str],
|
|
311
|
-
monkeypatch: pytest.MonkeyPatch,
|
|
312
|
-
) -> None:
|
|
313
|
-
"""`git config --get-all` exits 1 with empty stderr when the key is simply
|
|
314
|
-
unset. That is the dominant happy path and must NOT emit a diagnostic."""
|
|
315
|
-
unset_completed_process = subprocess.CompletedProcess(
|
|
316
|
-
args=["git"], returncode=1, stdout="", stderr=""
|
|
317
|
-
)
|
|
318
|
-
monkeypatch.setattr(
|
|
319
|
-
fix_hookspath.subprocess, "run", lambda *_args, **_kwargs: unset_completed_process
|
|
320
|
-
)
|
|
321
|
-
|
|
322
|
-
returned_values = fix_hookspath.list_local_core_hooks_path_values(
|
|
323
|
-
tmp_path / "any-repo", None
|
|
324
|
-
)
|
|
325
|
-
|
|
326
|
-
assert returned_values == []
|
|
327
|
-
captured_streams = capsys.readouterr()
|
|
328
|
-
assert captured_streams.err == ""
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
def test_read_global_core_hooks_path_raises_on_unexpected_git_failure(
|
|
332
|
-
monkeypatch: pytest.MonkeyPatch,
|
|
333
|
-
) -> None:
|
|
334
|
-
"""When the global git-config read exits non-zero with stderr, raise RuntimeError."""
|
|
335
|
-
failing_completed_process = subprocess.CompletedProcess(
|
|
336
|
-
args=["git"],
|
|
337
|
-
returncode=128,
|
|
338
|
-
stdout="",
|
|
339
|
-
stderr="fatal: bad config line 1 in file /home/example/.gitconfig",
|
|
340
|
-
)
|
|
341
|
-
monkeypatch.setattr(
|
|
342
|
-
fix_hookspath.subprocess, "run", lambda *_args, **_kwargs: failing_completed_process
|
|
343
|
-
)
|
|
344
|
-
|
|
345
|
-
with pytest.raises(RuntimeError, match="bad config"):
|
|
346
|
-
fix_hookspath.read_global_core_hooks_path(None)
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
def test_read_global_core_hooks_path_quiet_when_stderr_empty(
|
|
350
|
-
capsys: pytest.CaptureFixture[str],
|
|
351
|
-
monkeypatch: pytest.MonkeyPatch,
|
|
352
|
-
) -> None:
|
|
353
|
-
"""`git config --global --get` exits 1 with empty stderr when the key is
|
|
354
|
-
simply unset. That is the dominant happy path and must NOT emit a diagnostic."""
|
|
355
|
-
unset_completed_process = subprocess.CompletedProcess(
|
|
356
|
-
args=["git"], returncode=1, stdout="", stderr=""
|
|
357
|
-
)
|
|
358
|
-
monkeypatch.setattr(
|
|
359
|
-
fix_hookspath.subprocess, "run", lambda *_args, **_kwargs: unset_completed_process
|
|
360
|
-
)
|
|
361
|
-
|
|
362
|
-
returned_value = fix_hookspath.read_global_core_hooks_path(None)
|
|
363
|
-
|
|
364
|
-
assert returned_value == ""
|
|
365
|
-
captured_streams = capsys.readouterr()
|
|
366
|
-
assert captured_streams.err == ""
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
def test_should_handle_paths_with_spaces(tmp_path: Path) -> None:
|
|
370
|
-
home_directory = tmp_path / "home with space"
|
|
371
|
-
home_directory.mkdir()
|
|
372
|
-
environment = _make_isolated_git_environment(home_directory)
|
|
373
|
-
canonical_hooks_directory = _create_canonical_hooks_directory(home_directory)
|
|
374
|
-
_set_global_hooks_path(str(canonical_hooks_directory), environment)
|
|
375
|
-
repository_path = tmp_path / "repo with space"
|
|
376
|
-
_initialize_repository(repository_path, environment)
|
|
377
|
-
stale_local_value = str(repository_path / ".git" / "hooks")
|
|
378
|
-
_set_local_hooks_path(repository_path, stale_local_value, environment)
|
|
379
|
-
|
|
380
|
-
exit_code = fix_hookspath.main(
|
|
381
|
-
["--repo-root", str(repository_path)],
|
|
382
|
-
all_environment_overrides=environment,
|
|
383
|
-
)
|
|
384
|
-
|
|
385
|
-
assert exit_code == 0
|
|
386
|
-
assert _read_local_hooks_path(repository_path, environment) == ""
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
def test_unset_local_core_hooks_path_removes_local_override(tmp_path: Path) -> None:
|
|
390
|
-
home_directory = tmp_path / "home"
|
|
391
|
-
home_directory.mkdir()
|
|
392
|
-
environment = _make_isolated_git_environment(home_directory)
|
|
393
|
-
repository_path = tmp_path / "synthetic-repo"
|
|
394
|
-
_initialize_repository(repository_path, environment)
|
|
395
|
-
stale_local_value = str(repository_path / ".git" / "hooks")
|
|
396
|
-
_set_local_hooks_path(repository_path, stale_local_value, environment)
|
|
397
|
-
assert _read_local_hooks_path(repository_path, environment) == stale_local_value
|
|
398
|
-
|
|
399
|
-
exit_code = fix_hookspath.unset_local_core_hooks_path(repository_path, environment)
|
|
400
|
-
|
|
401
|
-
assert exit_code == 0
|
|
402
|
-
assert _read_local_hooks_path(repository_path, environment) == ""
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
def test_set_global_core_hooks_path_writes_global_value(tmp_path: Path) -> None:
|
|
406
|
-
home_directory = tmp_path / "home"
|
|
407
|
-
home_directory.mkdir()
|
|
408
|
-
environment = _make_isolated_git_environment(home_directory)
|
|
409
|
-
canonical_hooks_directory = _create_canonical_hooks_directory(home_directory)
|
|
410
|
-
target_value = str(canonical_hooks_directory).replace("\\", "/")
|
|
411
|
-
|
|
412
|
-
exit_code = fix_hookspath.set_global_core_hooks_path(target_value, environment)
|
|
413
|
-
|
|
414
|
-
assert exit_code == 0
|
|
415
|
-
global_value_after_set = _read_global_hooks_path(environment)
|
|
416
|
-
assert (
|
|
417
|
-
global_value_after_set.replace("\\", "/")
|
|
418
|
-
.rstrip("/")
|
|
419
|
-
.endswith("hooks/git-hooks")
|
|
420
|
-
)
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
def test_normalize_hooks_path_converts_backslashes_and_strips_trailing_slash() -> None:
|
|
424
|
-
windows_style_path = r"C:\Users\example\.claude\hooks\git-hooks\\"
|
|
425
|
-
unix_style_path = "/home/example/.claude/hooks/git-hooks/"
|
|
426
|
-
|
|
427
|
-
assert fix_hookspath.normalize_hooks_path(windows_style_path) == (
|
|
428
|
-
"C:/Users/example/.claude/hooks/git-hooks"
|
|
429
|
-
)
|
|
430
|
-
assert fix_hookspath.normalize_hooks_path(unix_style_path) == (
|
|
431
|
-
"/home/example/.claude/hooks/git-hooks"
|
|
432
|
-
)
|
|
433
|
-
assert fix_hookspath.normalize_hooks_path("already/normalized") == "already/normalized"
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
def test_rerun_preflight_returns_subprocess_exit_code(
|
|
437
|
-
tmp_path: Path,
|
|
438
|
-
monkeypatch: pytest.MonkeyPatch,
|
|
439
|
-
) -> None:
|
|
440
|
-
repository_path = tmp_path / "synthetic-repo"
|
|
441
|
-
repository_path.mkdir()
|
|
442
|
-
recorded_commands: list[list[str]] = []
|
|
443
|
-
|
|
444
|
-
def fake_run(
|
|
445
|
-
command: list[str],
|
|
446
|
-
check: bool = False,
|
|
447
|
-
env: dict[str, str] | None = None,
|
|
448
|
-
**_kwargs: object,
|
|
449
|
-
) -> subprocess.CompletedProcess[str]:
|
|
450
|
-
recorded_commands.append(list(command))
|
|
451
|
-
return subprocess.CompletedProcess(
|
|
452
|
-
args=list(command),
|
|
453
|
-
returncode=0,
|
|
454
|
-
stdout="",
|
|
455
|
-
stderr="",
|
|
456
|
-
)
|
|
457
|
-
|
|
458
|
-
monkeypatch.setattr(fix_hookspath.subprocess, "run", fake_run)
|
|
459
|
-
|
|
460
|
-
exit_code = fix_hookspath.rerun_preflight(repository_path, None)
|
|
461
|
-
|
|
462
|
-
assert exit_code == 0
|
|
463
|
-
assert len(recorded_commands) == 1
|
|
464
|
-
invoked_command = recorded_commands[0]
|
|
465
|
-
assert any(str(each_argument).endswith("preflight.py") for each_argument in invoked_command)
|
|
466
|
-
assert "--no-pytest" in invoked_command
|
|
467
|
-
assert "--repo-root" in invoked_command
|
|
468
|
-
assert str(repository_path) in invoked_command
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
def test_rerun_preflight_propagates_nonzero_exit_code(
|
|
472
|
-
tmp_path: Path,
|
|
473
|
-
monkeypatch: pytest.MonkeyPatch,
|
|
474
|
-
) -> None:
|
|
475
|
-
repository_path = tmp_path / "synthetic-repo"
|
|
476
|
-
repository_path.mkdir()
|
|
477
|
-
preflight_failure_exit_code = 2
|
|
478
|
-
|
|
479
|
-
def fake_run(
|
|
480
|
-
command: list[str],
|
|
481
|
-
check: bool = False,
|
|
482
|
-
env: dict[str, str] | None = None,
|
|
483
|
-
**_kwargs: object,
|
|
484
|
-
) -> subprocess.CompletedProcess[str]:
|
|
485
|
-
return subprocess.CompletedProcess(
|
|
486
|
-
args=list(command),
|
|
487
|
-
returncode=preflight_failure_exit_code,
|
|
488
|
-
stdout="",
|
|
489
|
-
stderr="preflight failed",
|
|
490
|
-
)
|
|
491
|
-
|
|
492
|
-
monkeypatch.setattr(fix_hookspath.subprocess, "run", fake_run)
|
|
493
|
-
|
|
494
|
-
exit_code = fix_hookspath.rerun_preflight(repository_path, None)
|
|
495
|
-
|
|
496
|
-
assert exit_code == preflight_failure_exit_code
|
|
497
|
-
|
|
1
|
+
"""Tests for shared fix_hookspath.py extracted from skills/bugteam/scripts/.
|
|
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
|
+
- main() maps unexpected git stderr hard-fail RuntimeError to exit 1
|
|
10
|
+
- handles paths with spaces
|
|
11
|
+
"""
|
|
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.parent / "fix_hookspath.py"
|
|
24
|
+
spec = importlib.util.spec_from_file_location("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
|
+
fix_hookspath = _load_fix_module()
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _make_isolated_git_environment(home_directory: Path) -> dict[str, str]:
|
|
36
|
+
isolated_environment = os.environ.copy()
|
|
37
|
+
isolated_environment["HOME"] = str(home_directory)
|
|
38
|
+
isolated_environment["USERPROFILE"] = str(home_directory)
|
|
39
|
+
isolated_environment["XDG_CONFIG_HOME"] = str(home_directory / ".config")
|
|
40
|
+
isolated_environment["GIT_CONFIG_GLOBAL"] = str(home_directory / ".gitconfig")
|
|
41
|
+
isolated_environment["GIT_CONFIG_NOSYSTEM"] = "1"
|
|
42
|
+
return isolated_environment
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _initialize_repository(repository_path: Path, environment: dict[str, str]) -> None:
|
|
46
|
+
repository_path.mkdir(parents=True, exist_ok=True)
|
|
47
|
+
subprocess.run(
|
|
48
|
+
["git", "init", "--quiet", str(repository_path)],
|
|
49
|
+
check=True,
|
|
50
|
+
env=environment,
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _set_local_hooks_path(
|
|
55
|
+
repository_path: Path,
|
|
56
|
+
hooks_path_value: str,
|
|
57
|
+
environment: dict[str, str],
|
|
58
|
+
) -> None:
|
|
59
|
+
subprocess.run(
|
|
60
|
+
[
|
|
61
|
+
"git",
|
|
62
|
+
"-C",
|
|
63
|
+
str(repository_path),
|
|
64
|
+
"config",
|
|
65
|
+
"--local",
|
|
66
|
+
"core.hooksPath",
|
|
67
|
+
hooks_path_value,
|
|
68
|
+
],
|
|
69
|
+
check=True,
|
|
70
|
+
env=environment,
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _set_global_hooks_path(hooks_path_value: str, environment: dict[str, str]) -> None:
|
|
75
|
+
subprocess.run(
|
|
76
|
+
["git", "config", "--global", "core.hooksPath", hooks_path_value],
|
|
77
|
+
check=True,
|
|
78
|
+
env=environment,
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _read_local_hooks_path(repository_path: Path, environment: dict[str, str]) -> str:
|
|
83
|
+
completed_process = subprocess.run(
|
|
84
|
+
[
|
|
85
|
+
"git",
|
|
86
|
+
"-C",
|
|
87
|
+
str(repository_path),
|
|
88
|
+
"config",
|
|
89
|
+
"--local",
|
|
90
|
+
"--get",
|
|
91
|
+
"core.hooksPath",
|
|
92
|
+
],
|
|
93
|
+
capture_output=True,
|
|
94
|
+
text=True,
|
|
95
|
+
check=False,
|
|
96
|
+
env=environment,
|
|
97
|
+
)
|
|
98
|
+
return completed_process.stdout.strip()
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _read_global_hooks_path(environment: dict[str, str]) -> str:
|
|
102
|
+
completed_process = subprocess.run(
|
|
103
|
+
["git", "config", "--global", "--get", "core.hooksPath"],
|
|
104
|
+
capture_output=True,
|
|
105
|
+
text=True,
|
|
106
|
+
check=False,
|
|
107
|
+
env=environment,
|
|
108
|
+
)
|
|
109
|
+
return completed_process.stdout.strip()
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _create_canonical_hooks_directory(home_directory: Path) -> Path:
|
|
113
|
+
canonical_hooks_directory = home_directory / ".claude" / "hooks" / "git-hooks"
|
|
114
|
+
canonical_hooks_directory.mkdir(parents=True)
|
|
115
|
+
return canonical_hooks_directory
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def test_should_remove_local_override_and_pass_preflight(tmp_path: Path) -> None:
|
|
119
|
+
home_directory = tmp_path / "home"
|
|
120
|
+
home_directory.mkdir()
|
|
121
|
+
environment = _make_isolated_git_environment(home_directory)
|
|
122
|
+
canonical_hooks_directory = _create_canonical_hooks_directory(home_directory)
|
|
123
|
+
_set_global_hooks_path(str(canonical_hooks_directory), environment)
|
|
124
|
+
repository_path = tmp_path / "synthetic-repo"
|
|
125
|
+
_initialize_repository(repository_path, environment)
|
|
126
|
+
stale_local_value = str(repository_path / ".git" / "hooks")
|
|
127
|
+
_set_local_hooks_path(repository_path, stale_local_value, environment)
|
|
128
|
+
|
|
129
|
+
exit_code = fix_hookspath.main(
|
|
130
|
+
["--repo-root", str(repository_path)],
|
|
131
|
+
all_environment_overrides=environment,
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
assert exit_code == 0
|
|
135
|
+
assert _read_local_hooks_path(repository_path, environment) == ""
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def test_should_set_global_hooks_path_when_missing(tmp_path: Path) -> None:
|
|
139
|
+
home_directory = tmp_path / "home"
|
|
140
|
+
home_directory.mkdir()
|
|
141
|
+
environment = _make_isolated_git_environment(home_directory)
|
|
142
|
+
canonical_hooks_directory = _create_canonical_hooks_directory(home_directory)
|
|
143
|
+
repository_path = tmp_path / "synthetic-repo"
|
|
144
|
+
_initialize_repository(repository_path, environment)
|
|
145
|
+
stale_local_value = str(repository_path / ".git" / "hooks")
|
|
146
|
+
_set_local_hooks_path(repository_path, stale_local_value, environment)
|
|
147
|
+
|
|
148
|
+
exit_code = fix_hookspath.main(
|
|
149
|
+
["--repo-root", str(repository_path)],
|
|
150
|
+
all_environment_overrides=environment,
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
assert exit_code == 0
|
|
154
|
+
global_value_after_fix = _read_global_hooks_path(environment)
|
|
155
|
+
assert (
|
|
156
|
+
global_value_after_fix.replace("\\", "/")
|
|
157
|
+
.rstrip("/")
|
|
158
|
+
.endswith("hooks/git-hooks")
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def test_should_be_idempotent(tmp_path: Path) -> None:
|
|
163
|
+
home_directory = tmp_path / "home"
|
|
164
|
+
home_directory.mkdir()
|
|
165
|
+
environment = _make_isolated_git_environment(home_directory)
|
|
166
|
+
canonical_hooks_directory = _create_canonical_hooks_directory(home_directory)
|
|
167
|
+
_set_global_hooks_path(str(canonical_hooks_directory), environment)
|
|
168
|
+
repository_path = tmp_path / "synthetic-repo"
|
|
169
|
+
_initialize_repository(repository_path, environment)
|
|
170
|
+
stale_local_value = str(repository_path / ".git" / "hooks")
|
|
171
|
+
_set_local_hooks_path(repository_path, stale_local_value, environment)
|
|
172
|
+
|
|
173
|
+
first_exit_code = fix_hookspath.main(
|
|
174
|
+
["--repo-root", str(repository_path)],
|
|
175
|
+
all_environment_overrides=environment,
|
|
176
|
+
)
|
|
177
|
+
second_exit_code = fix_hookspath.main(
|
|
178
|
+
["--repo-root", str(repository_path)],
|
|
179
|
+
all_environment_overrides=environment,
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
assert first_exit_code == 0
|
|
183
|
+
assert second_exit_code == 0
|
|
184
|
+
assert _read_local_hooks_path(repository_path, environment) == ""
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def test_should_no_op_when_already_clean(tmp_path: Path) -> None:
|
|
188
|
+
home_directory = tmp_path / "home"
|
|
189
|
+
home_directory.mkdir()
|
|
190
|
+
environment = _make_isolated_git_environment(home_directory)
|
|
191
|
+
canonical_hooks_directory = _create_canonical_hooks_directory(home_directory)
|
|
192
|
+
_set_global_hooks_path(str(canonical_hooks_directory), environment)
|
|
193
|
+
repository_path = tmp_path / "synthetic-repo"
|
|
194
|
+
_initialize_repository(repository_path, environment)
|
|
195
|
+
|
|
196
|
+
exit_code = fix_hookspath.main(
|
|
197
|
+
["--repo-root", str(repository_path)],
|
|
198
|
+
all_environment_overrides=environment,
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
assert exit_code == 0
|
|
202
|
+
assert _read_local_hooks_path(repository_path, environment) == ""
|
|
203
|
+
assert (
|
|
204
|
+
_read_global_hooks_path(environment)
|
|
205
|
+
.replace("\\", "/")
|
|
206
|
+
.rstrip("/")
|
|
207
|
+
.endswith("hooks/git-hooks")
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def test_should_exit_nonzero_when_canonical_hooks_directory_missing(
|
|
212
|
+
tmp_path: Path,
|
|
213
|
+
capsys: pytest.CaptureFixture[str],
|
|
214
|
+
) -> None:
|
|
215
|
+
home_directory = tmp_path / "home"
|
|
216
|
+
home_directory.mkdir()
|
|
217
|
+
environment = _make_isolated_git_environment(home_directory)
|
|
218
|
+
repository_path = tmp_path / "synthetic-repo"
|
|
219
|
+
_initialize_repository(repository_path, environment)
|
|
220
|
+
stale_local_value = str(repository_path / ".git" / "hooks")
|
|
221
|
+
_set_local_hooks_path(repository_path, stale_local_value, environment)
|
|
222
|
+
|
|
223
|
+
exit_code = fix_hookspath.main(
|
|
224
|
+
["--repo-root", str(repository_path)],
|
|
225
|
+
all_environment_overrides=environment,
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
assert exit_code != 0
|
|
229
|
+
captured_streams = capsys.readouterr()
|
|
230
|
+
assert "hooks/git-hooks" in captured_streams.err.replace("\\", "/")
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def test_main_exits_one_on_unexpected_git_stderr(
|
|
234
|
+
tmp_path: Path,
|
|
235
|
+
capsys: pytest.CaptureFixture[str],
|
|
236
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
237
|
+
) -> None:
|
|
238
|
+
"""main() must catch hard-fail RuntimeError from git stderr and exit 1 cleanly."""
|
|
239
|
+
home_directory = tmp_path / "home"
|
|
240
|
+
home_directory.mkdir()
|
|
241
|
+
environment = _make_isolated_git_environment(home_directory)
|
|
242
|
+
_create_canonical_hooks_directory(home_directory)
|
|
243
|
+
repository_path = tmp_path / "synthetic-repo"
|
|
244
|
+
_initialize_repository(repository_path, environment)
|
|
245
|
+
unexpected_git_stderr = (
|
|
246
|
+
"fatal: not a git repository (or any parent up to mount point /)"
|
|
247
|
+
)
|
|
248
|
+
failing_completed_process = subprocess.CompletedProcess(
|
|
249
|
+
args=["git"],
|
|
250
|
+
returncode=128,
|
|
251
|
+
stdout="",
|
|
252
|
+
stderr=unexpected_git_stderr,
|
|
253
|
+
)
|
|
254
|
+
monkeypatch.setattr(
|
|
255
|
+
fix_hookspath.subprocess,
|
|
256
|
+
"run",
|
|
257
|
+
lambda *_args, **_kwargs: failing_completed_process,
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
exit_code = fix_hookspath.main(
|
|
261
|
+
["--repo-root", str(repository_path)],
|
|
262
|
+
all_environment_overrides=environment,
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
assert exit_code == 1
|
|
266
|
+
captured_streams = capsys.readouterr()
|
|
267
|
+
assert "not a git repository" in captured_streams.err
|
|
268
|
+
assert "Traceback" not in captured_streams.err
|
|
269
|
+
assert "Traceback" not in captured_streams.out
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def test_is_canonical_hooks_path_still_recognizes_canonical_suffix() -> None:
|
|
273
|
+
canonical_value_with_suffix = "/home/example/.claude/hooks/git-hooks"
|
|
274
|
+
assert fix_hookspath.is_canonical_hooks_path(canonical_value_with_suffix)
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def test_resolve_canonical_hooks_directory_uses_home_env_overrides(
|
|
278
|
+
tmp_path: Path,
|
|
279
|
+
) -> None:
|
|
280
|
+
fake_home = tmp_path / "fake_home"
|
|
281
|
+
fake_home.mkdir()
|
|
282
|
+
overrides = {"HOME": str(fake_home), "USERPROFILE": str(fake_home)}
|
|
283
|
+
resolved = fix_hookspath.resolve_canonical_hooks_directory(overrides)
|
|
284
|
+
assert resolved == fake_home / ".claude" / "hooks" / "git-hooks"
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def test_list_local_core_hooks_path_values_raises_on_unexpected_git_failure(
|
|
288
|
+
tmp_path: Path,
|
|
289
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
290
|
+
) -> None:
|
|
291
|
+
"""When git -C ... config --get-all exits non-zero with stderr, raise RuntimeError."""
|
|
292
|
+
failing_completed_process = subprocess.CompletedProcess(
|
|
293
|
+
args=["git"],
|
|
294
|
+
returncode=128,
|
|
295
|
+
stdout="",
|
|
296
|
+
stderr="fatal: not a git repository (or any parent up to mount point /)",
|
|
297
|
+
)
|
|
298
|
+
monkeypatch.setattr(
|
|
299
|
+
fix_hookspath.subprocess, "run", lambda *_args, **_kwargs: failing_completed_process
|
|
300
|
+
)
|
|
301
|
+
|
|
302
|
+
with pytest.raises(RuntimeError, match="not a git repository"):
|
|
303
|
+
fix_hookspath.list_local_core_hooks_path_values(
|
|
304
|
+
tmp_path / "any-repo",
|
|
305
|
+
None,
|
|
306
|
+
)
|
|
307
|
+
|
|
308
|
+
def test_list_local_core_hooks_path_values_quiet_when_stderr_empty(
|
|
309
|
+
tmp_path: Path,
|
|
310
|
+
capsys: pytest.CaptureFixture[str],
|
|
311
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
312
|
+
) -> None:
|
|
313
|
+
"""`git config --get-all` exits 1 with empty stderr when the key is simply
|
|
314
|
+
unset. That is the dominant happy path and must NOT emit a diagnostic."""
|
|
315
|
+
unset_completed_process = subprocess.CompletedProcess(
|
|
316
|
+
args=["git"], returncode=1, stdout="", stderr=""
|
|
317
|
+
)
|
|
318
|
+
monkeypatch.setattr(
|
|
319
|
+
fix_hookspath.subprocess, "run", lambda *_args, **_kwargs: unset_completed_process
|
|
320
|
+
)
|
|
321
|
+
|
|
322
|
+
returned_values = fix_hookspath.list_local_core_hooks_path_values(
|
|
323
|
+
tmp_path / "any-repo", None
|
|
324
|
+
)
|
|
325
|
+
|
|
326
|
+
assert returned_values == []
|
|
327
|
+
captured_streams = capsys.readouterr()
|
|
328
|
+
assert captured_streams.err == ""
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def test_read_global_core_hooks_path_raises_on_unexpected_git_failure(
|
|
332
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
333
|
+
) -> None:
|
|
334
|
+
"""When the global git-config read exits non-zero with stderr, raise RuntimeError."""
|
|
335
|
+
failing_completed_process = subprocess.CompletedProcess(
|
|
336
|
+
args=["git"],
|
|
337
|
+
returncode=128,
|
|
338
|
+
stdout="",
|
|
339
|
+
stderr="fatal: bad config line 1 in file /home/example/.gitconfig",
|
|
340
|
+
)
|
|
341
|
+
monkeypatch.setattr(
|
|
342
|
+
fix_hookspath.subprocess, "run", lambda *_args, **_kwargs: failing_completed_process
|
|
343
|
+
)
|
|
344
|
+
|
|
345
|
+
with pytest.raises(RuntimeError, match="bad config"):
|
|
346
|
+
fix_hookspath.read_global_core_hooks_path(None)
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def test_read_global_core_hooks_path_quiet_when_stderr_empty(
|
|
350
|
+
capsys: pytest.CaptureFixture[str],
|
|
351
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
352
|
+
) -> None:
|
|
353
|
+
"""`git config --global --get` exits 1 with empty stderr when the key is
|
|
354
|
+
simply unset. That is the dominant happy path and must NOT emit a diagnostic."""
|
|
355
|
+
unset_completed_process = subprocess.CompletedProcess(
|
|
356
|
+
args=["git"], returncode=1, stdout="", stderr=""
|
|
357
|
+
)
|
|
358
|
+
monkeypatch.setattr(
|
|
359
|
+
fix_hookspath.subprocess, "run", lambda *_args, **_kwargs: unset_completed_process
|
|
360
|
+
)
|
|
361
|
+
|
|
362
|
+
returned_value = fix_hookspath.read_global_core_hooks_path(None)
|
|
363
|
+
|
|
364
|
+
assert returned_value == ""
|
|
365
|
+
captured_streams = capsys.readouterr()
|
|
366
|
+
assert captured_streams.err == ""
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def test_should_handle_paths_with_spaces(tmp_path: Path) -> None:
|
|
370
|
+
home_directory = tmp_path / "home with space"
|
|
371
|
+
home_directory.mkdir()
|
|
372
|
+
environment = _make_isolated_git_environment(home_directory)
|
|
373
|
+
canonical_hooks_directory = _create_canonical_hooks_directory(home_directory)
|
|
374
|
+
_set_global_hooks_path(str(canonical_hooks_directory), environment)
|
|
375
|
+
repository_path = tmp_path / "repo with space"
|
|
376
|
+
_initialize_repository(repository_path, environment)
|
|
377
|
+
stale_local_value = str(repository_path / ".git" / "hooks")
|
|
378
|
+
_set_local_hooks_path(repository_path, stale_local_value, environment)
|
|
379
|
+
|
|
380
|
+
exit_code = fix_hookspath.main(
|
|
381
|
+
["--repo-root", str(repository_path)],
|
|
382
|
+
all_environment_overrides=environment,
|
|
383
|
+
)
|
|
384
|
+
|
|
385
|
+
assert exit_code == 0
|
|
386
|
+
assert _read_local_hooks_path(repository_path, environment) == ""
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def test_unset_local_core_hooks_path_removes_local_override(tmp_path: Path) -> None:
|
|
390
|
+
home_directory = tmp_path / "home"
|
|
391
|
+
home_directory.mkdir()
|
|
392
|
+
environment = _make_isolated_git_environment(home_directory)
|
|
393
|
+
repository_path = tmp_path / "synthetic-repo"
|
|
394
|
+
_initialize_repository(repository_path, environment)
|
|
395
|
+
stale_local_value = str(repository_path / ".git" / "hooks")
|
|
396
|
+
_set_local_hooks_path(repository_path, stale_local_value, environment)
|
|
397
|
+
assert _read_local_hooks_path(repository_path, environment) == stale_local_value
|
|
398
|
+
|
|
399
|
+
exit_code = fix_hookspath.unset_local_core_hooks_path(repository_path, environment)
|
|
400
|
+
|
|
401
|
+
assert exit_code == 0
|
|
402
|
+
assert _read_local_hooks_path(repository_path, environment) == ""
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
def test_set_global_core_hooks_path_writes_global_value(tmp_path: Path) -> None:
|
|
406
|
+
home_directory = tmp_path / "home"
|
|
407
|
+
home_directory.mkdir()
|
|
408
|
+
environment = _make_isolated_git_environment(home_directory)
|
|
409
|
+
canonical_hooks_directory = _create_canonical_hooks_directory(home_directory)
|
|
410
|
+
target_value = str(canonical_hooks_directory).replace("\\", "/")
|
|
411
|
+
|
|
412
|
+
exit_code = fix_hookspath.set_global_core_hooks_path(target_value, environment)
|
|
413
|
+
|
|
414
|
+
assert exit_code == 0
|
|
415
|
+
global_value_after_set = _read_global_hooks_path(environment)
|
|
416
|
+
assert (
|
|
417
|
+
global_value_after_set.replace("\\", "/")
|
|
418
|
+
.rstrip("/")
|
|
419
|
+
.endswith("hooks/git-hooks")
|
|
420
|
+
)
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
def test_normalize_hooks_path_converts_backslashes_and_strips_trailing_slash() -> None:
|
|
424
|
+
windows_style_path = r"C:\Users\example\.claude\hooks\git-hooks\\"
|
|
425
|
+
unix_style_path = "/home/example/.claude/hooks/git-hooks/"
|
|
426
|
+
|
|
427
|
+
assert fix_hookspath.normalize_hooks_path(windows_style_path) == (
|
|
428
|
+
"C:/Users/example/.claude/hooks/git-hooks"
|
|
429
|
+
)
|
|
430
|
+
assert fix_hookspath.normalize_hooks_path(unix_style_path) == (
|
|
431
|
+
"/home/example/.claude/hooks/git-hooks"
|
|
432
|
+
)
|
|
433
|
+
assert fix_hookspath.normalize_hooks_path("already/normalized") == "already/normalized"
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
def test_rerun_preflight_returns_subprocess_exit_code(
|
|
437
|
+
tmp_path: Path,
|
|
438
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
439
|
+
) -> None:
|
|
440
|
+
repository_path = tmp_path / "synthetic-repo"
|
|
441
|
+
repository_path.mkdir()
|
|
442
|
+
recorded_commands: list[list[str]] = []
|
|
443
|
+
|
|
444
|
+
def fake_run(
|
|
445
|
+
command: list[str],
|
|
446
|
+
check: bool = False,
|
|
447
|
+
env: dict[str, str] | None = None,
|
|
448
|
+
**_kwargs: object,
|
|
449
|
+
) -> subprocess.CompletedProcess[str]:
|
|
450
|
+
recorded_commands.append(list(command))
|
|
451
|
+
return subprocess.CompletedProcess(
|
|
452
|
+
args=list(command),
|
|
453
|
+
returncode=0,
|
|
454
|
+
stdout="",
|
|
455
|
+
stderr="",
|
|
456
|
+
)
|
|
457
|
+
|
|
458
|
+
monkeypatch.setattr(fix_hookspath.subprocess, "run", fake_run)
|
|
459
|
+
|
|
460
|
+
exit_code = fix_hookspath.rerun_preflight(repository_path, None)
|
|
461
|
+
|
|
462
|
+
assert exit_code == 0
|
|
463
|
+
assert len(recorded_commands) == 1
|
|
464
|
+
invoked_command = recorded_commands[0]
|
|
465
|
+
assert any(str(each_argument).endswith("preflight.py") for each_argument in invoked_command)
|
|
466
|
+
assert "--no-pytest" in invoked_command
|
|
467
|
+
assert "--repo-root" in invoked_command
|
|
468
|
+
assert str(repository_path) in invoked_command
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
def test_rerun_preflight_propagates_nonzero_exit_code(
|
|
472
|
+
tmp_path: Path,
|
|
473
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
474
|
+
) -> None:
|
|
475
|
+
repository_path = tmp_path / "synthetic-repo"
|
|
476
|
+
repository_path.mkdir()
|
|
477
|
+
preflight_failure_exit_code = 2
|
|
478
|
+
|
|
479
|
+
def fake_run(
|
|
480
|
+
command: list[str],
|
|
481
|
+
check: bool = False,
|
|
482
|
+
env: dict[str, str] | None = None,
|
|
483
|
+
**_kwargs: object,
|
|
484
|
+
) -> subprocess.CompletedProcess[str]:
|
|
485
|
+
return subprocess.CompletedProcess(
|
|
486
|
+
args=list(command),
|
|
487
|
+
returncode=preflight_failure_exit_code,
|
|
488
|
+
stdout="",
|
|
489
|
+
stderr="preflight failed",
|
|
490
|
+
)
|
|
491
|
+
|
|
492
|
+
monkeypatch.setattr(fix_hookspath.subprocess, "run", fake_run)
|
|
493
|
+
|
|
494
|
+
exit_code = fix_hookspath.rerun_preflight(repository_path, None)
|
|
495
|
+
|
|
496
|
+
assert exit_code == preflight_failure_exit_code
|
|
497
|
+
|