doit-toolkit-cli 0.1.10__py3-none-any.whl

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.

Potentially problematic release.


This version of doit-toolkit-cli might be problematic. Click here for more details.

Files changed (135) hide show
  1. doit_cli/__init__.py +1356 -0
  2. doit_cli/cli/__init__.py +26 -0
  3. doit_cli/cli/analytics_command.py +616 -0
  4. doit_cli/cli/context_command.py +213 -0
  5. doit_cli/cli/diagram_command.py +304 -0
  6. doit_cli/cli/fixit_command.py +641 -0
  7. doit_cli/cli/hooks_command.py +211 -0
  8. doit_cli/cli/init_command.py +613 -0
  9. doit_cli/cli/memory_command.py +293 -0
  10. doit_cli/cli/roadmapit_command.py +10 -0
  11. doit_cli/cli/status_command.py +117 -0
  12. doit_cli/cli/sync_prompts_command.py +248 -0
  13. doit_cli/cli/validate_command.py +196 -0
  14. doit_cli/cli/verify_command.py +204 -0
  15. doit_cli/cli/workflow_mixin.py +224 -0
  16. doit_cli/cli/xref_command.py +555 -0
  17. doit_cli/formatters/__init__.py +8 -0
  18. doit_cli/formatters/base.py +38 -0
  19. doit_cli/formatters/json_formatter.py +126 -0
  20. doit_cli/formatters/markdown_formatter.py +97 -0
  21. doit_cli/formatters/rich_formatter.py +257 -0
  22. doit_cli/main.py +51 -0
  23. doit_cli/models/__init__.py +139 -0
  24. doit_cli/models/agent.py +74 -0
  25. doit_cli/models/analytics_models.py +384 -0
  26. doit_cli/models/context_config.py +464 -0
  27. doit_cli/models/crossref_models.py +182 -0
  28. doit_cli/models/diagram_models.py +363 -0
  29. doit_cli/models/fixit_models.py +355 -0
  30. doit_cli/models/hook_config.py +125 -0
  31. doit_cli/models/project.py +91 -0
  32. doit_cli/models/results.py +121 -0
  33. doit_cli/models/search_models.py +228 -0
  34. doit_cli/models/status_models.py +195 -0
  35. doit_cli/models/sync_models.py +146 -0
  36. doit_cli/models/template.py +77 -0
  37. doit_cli/models/validation_models.py +175 -0
  38. doit_cli/models/workflow_models.py +319 -0
  39. doit_cli/prompts/__init__.py +5 -0
  40. doit_cli/prompts/fixit_prompts.py +344 -0
  41. doit_cli/prompts/interactive.py +390 -0
  42. doit_cli/rules/__init__.py +5 -0
  43. doit_cli/rules/builtin_rules.py +160 -0
  44. doit_cli/services/__init__.py +79 -0
  45. doit_cli/services/agent_detector.py +168 -0
  46. doit_cli/services/analytics_service.py +218 -0
  47. doit_cli/services/architecture_generator.py +290 -0
  48. doit_cli/services/backup_service.py +204 -0
  49. doit_cli/services/config_loader.py +113 -0
  50. doit_cli/services/context_loader.py +1123 -0
  51. doit_cli/services/coverage_calculator.py +142 -0
  52. doit_cli/services/crossref_service.py +237 -0
  53. doit_cli/services/cycle_time_calculator.py +134 -0
  54. doit_cli/services/date_inferrer.py +349 -0
  55. doit_cli/services/diagram_service.py +337 -0
  56. doit_cli/services/drift_detector.py +109 -0
  57. doit_cli/services/entity_parser.py +301 -0
  58. doit_cli/services/er_diagram_generator.py +197 -0
  59. doit_cli/services/fixit_service.py +699 -0
  60. doit_cli/services/github_service.py +192 -0
  61. doit_cli/services/hook_manager.py +258 -0
  62. doit_cli/services/hook_validator.py +528 -0
  63. doit_cli/services/input_validator.py +322 -0
  64. doit_cli/services/memory_search.py +527 -0
  65. doit_cli/services/mermaid_validator.py +334 -0
  66. doit_cli/services/prompt_transformer.py +91 -0
  67. doit_cli/services/prompt_writer.py +133 -0
  68. doit_cli/services/query_interpreter.py +428 -0
  69. doit_cli/services/report_exporter.py +219 -0
  70. doit_cli/services/report_generator.py +256 -0
  71. doit_cli/services/requirement_parser.py +112 -0
  72. doit_cli/services/roadmap_summarizer.py +209 -0
  73. doit_cli/services/rule_engine.py +443 -0
  74. doit_cli/services/scaffolder.py +215 -0
  75. doit_cli/services/score_calculator.py +172 -0
  76. doit_cli/services/section_parser.py +204 -0
  77. doit_cli/services/spec_scanner.py +327 -0
  78. doit_cli/services/state_manager.py +355 -0
  79. doit_cli/services/status_reporter.py +143 -0
  80. doit_cli/services/task_parser.py +347 -0
  81. doit_cli/services/template_manager.py +710 -0
  82. doit_cli/services/template_reader.py +158 -0
  83. doit_cli/services/user_journey_generator.py +214 -0
  84. doit_cli/services/user_story_parser.py +232 -0
  85. doit_cli/services/validation_service.py +188 -0
  86. doit_cli/services/validator.py +232 -0
  87. doit_cli/services/velocity_tracker.py +173 -0
  88. doit_cli/services/workflow_engine.py +405 -0
  89. doit_cli/templates/agent-file-template.md +28 -0
  90. doit_cli/templates/checklist-template.md +39 -0
  91. doit_cli/templates/commands/doit.checkin.md +363 -0
  92. doit_cli/templates/commands/doit.constitution.md +187 -0
  93. doit_cli/templates/commands/doit.documentit.md +485 -0
  94. doit_cli/templates/commands/doit.fixit.md +181 -0
  95. doit_cli/templates/commands/doit.implementit.md +265 -0
  96. doit_cli/templates/commands/doit.planit.md +262 -0
  97. doit_cli/templates/commands/doit.reviewit.md +355 -0
  98. doit_cli/templates/commands/doit.roadmapit.md +389 -0
  99. doit_cli/templates/commands/doit.scaffoldit.md +458 -0
  100. doit_cli/templates/commands/doit.specit.md +521 -0
  101. doit_cli/templates/commands/doit.taskit.md +304 -0
  102. doit_cli/templates/commands/doit.testit.md +277 -0
  103. doit_cli/templates/config/context.yaml +134 -0
  104. doit_cli/templates/config/hooks.yaml +93 -0
  105. doit_cli/templates/config/validation-rules.yaml +64 -0
  106. doit_cli/templates/github-issue-templates/epic.yml +78 -0
  107. doit_cli/templates/github-issue-templates/feature.yml +116 -0
  108. doit_cli/templates/github-issue-templates/task.yml +129 -0
  109. doit_cli/templates/hooks/.gitkeep +0 -0
  110. doit_cli/templates/hooks/post-commit.sh +25 -0
  111. doit_cli/templates/hooks/post-merge.sh +75 -0
  112. doit_cli/templates/hooks/pre-commit.sh +17 -0
  113. doit_cli/templates/hooks/pre-push.sh +18 -0
  114. doit_cli/templates/memory/completed_roadmap.md +50 -0
  115. doit_cli/templates/memory/constitution.md +125 -0
  116. doit_cli/templates/memory/roadmap.md +61 -0
  117. doit_cli/templates/plan-template.md +146 -0
  118. doit_cli/templates/scripts/bash/check-prerequisites.sh +166 -0
  119. doit_cli/templates/scripts/bash/common.sh +156 -0
  120. doit_cli/templates/scripts/bash/create-new-feature.sh +297 -0
  121. doit_cli/templates/scripts/bash/setup-plan.sh +61 -0
  122. doit_cli/templates/scripts/bash/update-agent-context.sh +675 -0
  123. doit_cli/templates/scripts/powershell/check-prerequisites.ps1 +148 -0
  124. doit_cli/templates/scripts/powershell/common.ps1 +137 -0
  125. doit_cli/templates/scripts/powershell/create-new-feature.ps1 +283 -0
  126. doit_cli/templates/scripts/powershell/setup-plan.ps1 +61 -0
  127. doit_cli/templates/scripts/powershell/update-agent-context.ps1 +406 -0
  128. doit_cli/templates/spec-template.md +159 -0
  129. doit_cli/templates/tasks-template.md +313 -0
  130. doit_cli/templates/vscode-settings.json +14 -0
  131. doit_toolkit_cli-0.1.10.dist-info/METADATA +324 -0
  132. doit_toolkit_cli-0.1.10.dist-info/RECORD +135 -0
  133. doit_toolkit_cli-0.1.10.dist-info/WHEEL +4 -0
  134. doit_toolkit_cli-0.1.10.dist-info/entry_points.txt +2 -0
  135. doit_toolkit_cli-0.1.10.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,528 @@
1
+ """Hook validation service for workflow enforcement."""
2
+
3
+ import fnmatch
4
+ import os
5
+ import re
6
+ import subprocess
7
+ from datetime import datetime
8
+ from pathlib import Path
9
+ from typing import Optional
10
+
11
+ from ..models.hook_config import HookConfig
12
+
13
+
14
+ class ValidationResult:
15
+ """Result of a hook validation check."""
16
+
17
+ def __init__(
18
+ self,
19
+ success: bool,
20
+ message: str = "",
21
+ suggestion: str = "",
22
+ ):
23
+ self.success = success
24
+ self.message = message
25
+ self.suggestion = suggestion
26
+
27
+ def __bool__(self) -> bool:
28
+ return self.success
29
+
30
+
31
+ class HookValidator:
32
+ """Validates workflow compliance for Git hooks."""
33
+
34
+ # Regex pattern for feature branch naming (e.g., 025-feature-name)
35
+ BRANCH_PATTERN = re.compile(r"^(\d{3})-(.+)$")
36
+
37
+ # Protected branches that skip validation
38
+ DEFAULT_PROTECTED_BRANCHES = ["main", "develop", "master"]
39
+
40
+ # Allowed spec statuses for code commits
41
+ ALLOWED_SPEC_STATUSES = ["In Progress", "Complete", "Approved"]
42
+
43
+ def __init__(
44
+ self,
45
+ project_root: Optional[Path] = None,
46
+ config: Optional[HookConfig] = None,
47
+ ):
48
+ """Initialize the validator.
49
+
50
+ Args:
51
+ project_root: Root directory of the project (defaults to cwd)
52
+ config: Hook configuration (loads from file if not provided)
53
+ """
54
+ self.project_root = project_root or Path.cwd()
55
+ self.config = config or self._load_config()
56
+ self.specs_dir = self.project_root / "specs"
57
+ self.logs_dir = self.project_root / ".doit" / "logs"
58
+
59
+ def _load_config(self) -> HookConfig:
60
+ """Load configuration from file or return defaults."""
61
+ config_path = self.project_root / ".doit" / "config" / "hooks.yaml"
62
+ if config_path.exists():
63
+ return HookConfig.load_from_file(config_path)
64
+ return HookConfig.load_default()
65
+
66
+ def get_current_branch(self) -> Optional[str]:
67
+ """Get the current Git branch name.
68
+
69
+ Returns:
70
+ Branch name or None if not on a branch (detached HEAD)
71
+ """
72
+ try:
73
+ result = subprocess.run(
74
+ ["git", "rev-parse", "--abbrev-ref", "HEAD"],
75
+ capture_output=True,
76
+ text=True,
77
+ cwd=self.project_root,
78
+ )
79
+ if result.returncode == 0:
80
+ branch = result.stdout.strip()
81
+ if branch == "HEAD":
82
+ return None # Detached HEAD state
83
+ return branch
84
+ except (subprocess.SubprocessError, FileNotFoundError):
85
+ pass
86
+ return None
87
+
88
+ def get_staged_files(self) -> list[str]:
89
+ """Get list of staged files for commit.
90
+
91
+ Returns:
92
+ List of staged file paths relative to project root
93
+ """
94
+ try:
95
+ result = subprocess.run(
96
+ ["git", "diff", "--cached", "--name-only"],
97
+ capture_output=True,
98
+ text=True,
99
+ cwd=self.project_root,
100
+ )
101
+ if result.returncode == 0:
102
+ return [f for f in result.stdout.strip().split("\n") if f]
103
+ except (subprocess.SubprocessError, FileNotFoundError):
104
+ pass
105
+ return []
106
+
107
+ def is_protected_branch(self, branch: str) -> bool:
108
+ """Check if branch is protected (skip validation).
109
+
110
+ Args:
111
+ branch: Branch name to check
112
+
113
+ Returns:
114
+ True if branch should skip validation
115
+ """
116
+ # Check default protected branches
117
+ if branch in self.DEFAULT_PROTECTED_BRANCHES:
118
+ return True
119
+
120
+ # Check configured exempt branches
121
+ exempt = self.config.pre_commit.exempt_branches
122
+ for pattern in exempt:
123
+ if fnmatch.fnmatch(branch, pattern):
124
+ return True
125
+
126
+ return False
127
+
128
+ def is_exempt_path(self, file_path: str) -> bool:
129
+ """Check if file path is exempt from validation.
130
+
131
+ Args:
132
+ file_path: File path to check
133
+
134
+ Returns:
135
+ True if file should skip validation
136
+ """
137
+ exempt = self.config.pre_commit.exempt_paths
138
+ for pattern in exempt:
139
+ if fnmatch.fnmatch(file_path, pattern):
140
+ return True
141
+ return False
142
+
143
+ def all_files_exempt(self, files: list[str]) -> bool:
144
+ """Check if all staged files are exempt from validation.
145
+
146
+ Args:
147
+ files: List of file paths
148
+
149
+ Returns:
150
+ True if all files match exempt patterns
151
+ """
152
+ if not files:
153
+ return True
154
+ return all(self.is_exempt_path(f) for f in files)
155
+
156
+ def extract_branch_spec_name(self, branch: str) -> Optional[str]:
157
+ """Extract spec directory name from branch name.
158
+
159
+ Args:
160
+ branch: Git branch name
161
+
162
+ Returns:
163
+ Spec directory name or None if pattern doesn't match
164
+ """
165
+ match = self.BRANCH_PATTERN.match(branch)
166
+ if match:
167
+ return branch # Full branch name is the spec directory
168
+ return None
169
+
170
+ def get_spec_path(self, branch: str) -> Optional[Path]:
171
+ """Get the expected spec.md path for a branch.
172
+
173
+ Args:
174
+ branch: Git branch name
175
+
176
+ Returns:
177
+ Path to expected spec.md or None if branch pattern doesn't match
178
+ """
179
+ spec_name = self.extract_branch_spec_name(branch)
180
+ if spec_name:
181
+ return self.specs_dir / spec_name / "spec.md"
182
+ return None
183
+
184
+ def spec_exists(self, branch: str) -> bool:
185
+ """Check if spec.md exists for the branch.
186
+
187
+ Args:
188
+ branch: Git branch name
189
+
190
+ Returns:
191
+ True if spec.md exists
192
+ """
193
+ spec_path = self.get_spec_path(branch)
194
+ return spec_path is not None and spec_path.exists()
195
+
196
+ def get_spec_status(self, branch: str) -> Optional[str]:
197
+ """Get the status field from spec.md.
198
+
199
+ Args:
200
+ branch: Git branch name
201
+
202
+ Returns:
203
+ Status string or None if not found
204
+ """
205
+ spec_path = self.get_spec_path(branch)
206
+ if not spec_path or not spec_path.exists():
207
+ return None
208
+
209
+ try:
210
+ content = spec_path.read_text()
211
+ # Look for **Status**: value pattern
212
+ match = re.search(r"\*\*Status\*\*:\s*(\w+(?:\s+\w+)*)", content)
213
+ if match:
214
+ return match.group(1).strip()
215
+ except (OSError, IOError):
216
+ pass
217
+ return None
218
+
219
+ def is_spec_status_valid(self, status: Optional[str]) -> bool:
220
+ """Check if spec status allows code commits.
221
+
222
+ Args:
223
+ status: Spec status string
224
+
225
+ Returns:
226
+ True if status allows code commits
227
+ """
228
+ if status is None:
229
+ return False
230
+
231
+ # Check configured allowed statuses or use defaults
232
+ allowed = self.config.pre_commit.allowed_statuses
233
+ if allowed:
234
+ return status in allowed
235
+ return status in self.ALLOWED_SPEC_STATUSES
236
+
237
+ def has_code_changes(self, files: list[str]) -> bool:
238
+ """Check if staged files include code (not just spec/docs).
239
+
240
+ Args:
241
+ files: List of staged file paths
242
+
243
+ Returns:
244
+ True if any file is not a spec/doc file
245
+ """
246
+ spec_doc_patterns = ["specs/**", "docs/**", "*.md", ".github/**"]
247
+ for f in files:
248
+ is_spec_doc = any(fnmatch.fnmatch(f, p) for p in spec_doc_patterns)
249
+ if not is_spec_doc:
250
+ return True
251
+ return False
252
+
253
+ def validate_pre_commit(self) -> ValidationResult:
254
+ """Validate pre-commit hook requirements.
255
+
256
+ Returns:
257
+ ValidationResult with success status and messages
258
+ """
259
+ # Check if pre-commit validation is enabled
260
+ if not self.config.pre_commit.enabled:
261
+ return ValidationResult(True, "Pre-commit validation disabled")
262
+
263
+ # Get current branch
264
+ branch = self.get_current_branch()
265
+ if branch is None:
266
+ return ValidationResult(
267
+ True,
268
+ "Detached HEAD state - skipping validation",
269
+ )
270
+
271
+ # Check if protected branch
272
+ if self.is_protected_branch(branch):
273
+ return ValidationResult(
274
+ True,
275
+ f"Protected branch '{branch}' - skipping validation",
276
+ )
277
+
278
+ # Get staged files
279
+ staged_files = self.get_staged_files()
280
+
281
+ # Check if all files are exempt
282
+ if self.all_files_exempt(staged_files):
283
+ return ValidationResult(
284
+ True,
285
+ "All staged files match exempt patterns - skipping validation",
286
+ )
287
+
288
+ # Check if require_spec is enabled
289
+ if not self.config.pre_commit.require_spec:
290
+ return ValidationResult(True, "Spec requirement disabled")
291
+
292
+ # Extract spec name from branch
293
+ spec_name = self.extract_branch_spec_name(branch)
294
+ if spec_name is None:
295
+ # Branch doesn't follow naming convention - warn but allow
296
+ return ValidationResult(
297
+ True,
298
+ f"Branch '{branch}' doesn't follow naming convention (###-feature-name)",
299
+ "Consider using standard branch naming for better workflow tracking",
300
+ )
301
+
302
+ # Check if spec exists
303
+ spec_path = self.get_spec_path(branch)
304
+ if not self.spec_exists(branch):
305
+ return ValidationResult(
306
+ False,
307
+ f"Missing specification for branch: {branch}",
308
+ f"Expected: {spec_path}\n\nTo fix: Run `doit specit \"Your feature description\"` first\n\nOr bypass with: git commit --no-verify (not recommended)",
309
+ )
310
+
311
+ # Check spec status for code changes
312
+ if self.has_code_changes(staged_files):
313
+ status = self.get_spec_status(branch)
314
+ if not self.is_spec_status_valid(status):
315
+ allowed = self.config.pre_commit.allowed_statuses or self.ALLOWED_SPEC_STATUSES
316
+ return ValidationResult(
317
+ False,
318
+ f"Specification has invalid status: {status or 'Unknown'}",
319
+ f"Allowed statuses: {', '.join(allowed)}\n\nTo fix: Update spec.md status to 'In Progress' before committing code",
320
+ )
321
+
322
+ # Run spec validation if enabled
323
+ if self.config.pre_commit.validate_spec:
324
+ validation_result = self._validate_spec_quality(spec_path)
325
+ if not validation_result.success:
326
+ return validation_result
327
+
328
+ return ValidationResult(True, "Pre-commit validation passed")
329
+
330
+ def _validate_spec_quality(self, spec_path: Path) -> ValidationResult:
331
+ """Validate spec quality using validation rules.
332
+
333
+ Args:
334
+ spec_path: Path to the spec.md file.
335
+
336
+ Returns:
337
+ ValidationResult with success status and messages.
338
+ """
339
+ try:
340
+ from .validation_service import ValidationService
341
+
342
+ service = ValidationService()
343
+ result = service.validate_file(spec_path)
344
+
345
+ threshold = self.config.pre_commit.validate_spec_threshold
346
+
347
+ if result.error_count > 0:
348
+ # Format issues summary
349
+ issues_summary = []
350
+ for issue in result.issues:
351
+ if issue.severity.value == "error":
352
+ issues_summary.append(f" - {issue.message}")
353
+
354
+ return ValidationResult(
355
+ False,
356
+ f"Specification validation failed with {result.error_count} error(s)\n\n"
357
+ + "\n".join(issues_summary[:5]) # Show first 5 errors
358
+ + (f"\n ... and {result.error_count - 5} more" if result.error_count > 5 else ""),
359
+ f"Quality score: {result.quality_score}/100 (threshold: {threshold})\n\n"
360
+ f"To fix: Run `doit validate {spec_path}` to see all issues\n\n"
361
+ "Or bypass with: git commit --no-verify (not recommended)",
362
+ )
363
+
364
+ if result.quality_score < threshold:
365
+ return ValidationResult(
366
+ False,
367
+ f"Specification quality score ({result.quality_score}) below threshold ({threshold})",
368
+ f"Warnings: {result.warning_count}\n\n"
369
+ f"To fix: Run `doit validate {spec_path}` to see issues and improve the spec\n\n"
370
+ "Or bypass with: git commit --no-verify (not recommended)",
371
+ )
372
+
373
+ except ImportError:
374
+ # ValidationService not available - skip validation
375
+ pass
376
+ except Exception as e:
377
+ # Log error but don't block commit for validation failures
378
+ pass
379
+
380
+ return ValidationResult(True, "Spec validation passed")
381
+
382
+ def validate_pre_push(self) -> ValidationResult:
383
+ """Validate pre-push hook requirements.
384
+
385
+ Returns:
386
+ ValidationResult with success status and messages
387
+ """
388
+ # Check if pre-push validation is enabled
389
+ if not self.config.pre_push.enabled:
390
+ return ValidationResult(True, "Pre-push validation disabled")
391
+
392
+ # Get current branch
393
+ branch = self.get_current_branch()
394
+ if branch is None:
395
+ return ValidationResult(
396
+ True,
397
+ "Detached HEAD state - skipping validation",
398
+ )
399
+
400
+ # Check if protected branch
401
+ if self.is_protected_branch(branch):
402
+ return ValidationResult(
403
+ True,
404
+ f"Protected branch '{branch}' - skipping validation",
405
+ )
406
+
407
+ # Extract spec name from branch
408
+ spec_name = self.extract_branch_spec_name(branch)
409
+ if spec_name is None:
410
+ return ValidationResult(
411
+ True,
412
+ f"Branch '{branch}' doesn't follow naming convention",
413
+ )
414
+
415
+ # Check for required artifacts
416
+ spec_dir = self.specs_dir / spec_name
417
+
418
+ # Check spec.md
419
+ if self.config.pre_push.require_spec:
420
+ spec_path = spec_dir / "spec.md"
421
+ if not spec_path.exists():
422
+ return ValidationResult(
423
+ False,
424
+ f"Missing required artifact: spec.md",
425
+ f"Expected: {spec_path}\n\nTo fix: Run `doit specit \"Your feature description\"` first",
426
+ )
427
+
428
+ # Check plan.md
429
+ if self.config.pre_push.require_plan:
430
+ plan_path = spec_dir / "plan.md"
431
+ if not plan_path.exists():
432
+ return ValidationResult(
433
+ False,
434
+ f"Missing required artifact: plan.md",
435
+ f"Expected: {plan_path}\n\nTo fix: Run `/doit.planit` to create implementation plan",
436
+ )
437
+
438
+ # Check tasks.md
439
+ if self.config.pre_push.require_tasks:
440
+ tasks_path = spec_dir / "tasks.md"
441
+ if not tasks_path.exists():
442
+ return ValidationResult(
443
+ False,
444
+ f"Missing required artifact: tasks.md",
445
+ f"Expected: {tasks_path}\n\nTo fix: Run `/doit.taskit` to generate task breakdown",
446
+ )
447
+
448
+ return ValidationResult(True, "Pre-push validation passed")
449
+
450
+ def log_bypass(self, hook_type: str, commit_hash: Optional[str] = None) -> None:
451
+ """Log a hook bypass event.
452
+
453
+ Args:
454
+ hook_type: Type of hook that was bypassed (pre-commit, pre-push)
455
+ commit_hash: Optional commit hash for post-commit logging
456
+ """
457
+ if not self.config.logging.log_bypasses:
458
+ return
459
+
460
+ self.logs_dir.mkdir(parents=True, exist_ok=True)
461
+ log_path = self.logs_dir / "hook-bypasses.log"
462
+
463
+ # Get context
464
+ branch = self.get_current_branch() or "unknown"
465
+ user = self._get_git_user()
466
+ timestamp = datetime.now().isoformat(timespec="seconds")
467
+
468
+ # Format log entry
469
+ entry = f"{timestamp} | hook: {hook_type} | branch: {branch}"
470
+ if commit_hash:
471
+ entry += f" | commit: {commit_hash}"
472
+ if user:
473
+ entry += f" | user: {user}"
474
+ entry += "\n"
475
+
476
+ # Append to log
477
+ with open(log_path, "a") as f:
478
+ f.write(entry)
479
+
480
+ def _get_git_user(self) -> Optional[str]:
481
+ """Get the current Git user email."""
482
+ try:
483
+ result = subprocess.run(
484
+ ["git", "config", "user.email"],
485
+ capture_output=True,
486
+ text=True,
487
+ cwd=self.project_root,
488
+ )
489
+ if result.returncode == 0:
490
+ return result.stdout.strip()
491
+ except (subprocess.SubprocessError, FileNotFoundError):
492
+ pass
493
+ return None
494
+
495
+ def get_bypass_report(self) -> list[dict]:
496
+ """Get bypass events from the log.
497
+
498
+ Returns:
499
+ List of bypass event dictionaries
500
+ """
501
+ log_path = self.logs_dir / "hook-bypasses.log"
502
+ if not log_path.exists():
503
+ return []
504
+
505
+ events = []
506
+ try:
507
+ with open(log_path) as f:
508
+ for line in f:
509
+ line = line.strip()
510
+ if not line:
511
+ continue
512
+
513
+ # Parse log entry
514
+ event = {}
515
+ parts = line.split(" | ")
516
+ for part in parts:
517
+ if ": " in part:
518
+ key, value = part.split(": ", 1)
519
+ event[key] = value
520
+ else:
521
+ # First part is timestamp
522
+ event["timestamp"] = part
523
+
524
+ events.append(event)
525
+ except (OSError, IOError):
526
+ pass
527
+
528
+ return events