diffguard 0.1.3__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.
diffguard/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """DiffGuard — Structured semantic summaries of git diffs."""
2
+
3
+ __version__ = "0.1.3"
diffguard/cli.py ADDED
@@ -0,0 +1,676 @@
1
+ """DiffGuard CLI entry point."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import logging
7
+ import re
8
+ import sys
9
+
10
+ import click
11
+
12
+ from diffguard import __version__
13
+ from diffguard.engine.deps import find_references
14
+ from diffguard.engine.pipeline import FileContentProvider, run_pipeline
15
+ from diffguard.git import get_diff, get_file_at_ref
16
+ from diffguard.schema import FileChange, DiffGuardOutput, SymbolChange
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+ # Exit codes
21
+ EXIT_SUCCESS = 0 # No high-signal findings (silence)
22
+ EXIT_FINDINGS = 1 # Findings present — agent should read output
23
+ EXIT_ERROR = 2 # Something went wrong
24
+ EXIT_NO_CHANGES = 3 # No changes in diff (summarize command)
25
+ EXIT_PARTIAL = 4 # Parse errors in some files (summarize command)
26
+
27
+
28
+ def _make_content_provider(repo_path: str) -> FileContentProvider:
29
+ """Create a file content provider bound to a repo path."""
30
+
31
+ def _get(ref: str, file_path: str) -> str | None:
32
+ return get_file_at_ref(ref, file_path, repo_path=repo_path)
33
+
34
+ return _get
35
+
36
+
37
+ def _format_output(
38
+ output: DiffGuardOutput,
39
+ fmt: str,
40
+ tier: str,
41
+ ) -> str:
42
+ """Format pipeline output according to --format flag."""
43
+ if fmt == "json":
44
+ return output.model_dump_json(indent=2)
45
+ # Non-JSON: the format flag determines which tier to show
46
+ if fmt in ("oneliner", "short", "detailed"):
47
+ return str(getattr(output.tiered, fmt))
48
+ # Fallback to tier
49
+ return str(getattr(output.tiered, tier))
50
+
51
+
52
+ @click.group()
53
+ @click.version_option(__version__, "--version", "-v")
54
+ def main() -> None:
55
+ """DiffGuard — Catches the structural breaks that pass code review. Analyzes git diffs to surface high-signal changes."""
56
+
57
+
58
+ @main.command()
59
+ @click.argument("ref_range", required=False, default=None)
60
+ @click.option(
61
+ "--diff",
62
+ "diff_source",
63
+ default=None,
64
+ help="Read unified diff from stdin. Use '--diff -' for pipe input.",
65
+ )
66
+ @click.option(
67
+ "--format",
68
+ "fmt",
69
+ type=click.Choice(["json", "oneliner", "short", "detailed"]),
70
+ default="json",
71
+ help="Output format (default: json).",
72
+ )
73
+ @click.option(
74
+ "--tier",
75
+ type=click.Choice(["oneliner", "short", "detailed"]),
76
+ default="detailed",
77
+ help="Summary tier for JSON output (default: detailed).",
78
+ )
79
+ @click.option(
80
+ "--skip-generated",
81
+ "--no-generated",
82
+ is_flag=True,
83
+ default=False,
84
+ help="Skip generated file detection.",
85
+ )
86
+ @click.option(
87
+ "--include-tests",
88
+ is_flag=True,
89
+ default=False,
90
+ help="Include test file changes in summary text output.",
91
+ )
92
+ @click.option(
93
+ "--show-skipped",
94
+ is_flag=True,
95
+ default=False,
96
+ help="Show skipped (unsupported/binary/generated) files in summary text.",
97
+ )
98
+ @click.option(
99
+ "--repo",
100
+ default=".",
101
+ help="Repository path (default: current directory).",
102
+ )
103
+ def summarize(
104
+ ref_range: str | None,
105
+ diff_source: str | None,
106
+ fmt: str,
107
+ tier: str,
108
+ skip_generated: bool,
109
+ include_tests: bool,
110
+ show_skipped: bool,
111
+ repo: str,
112
+ ) -> None:
113
+ """Summarize git changes.
114
+
115
+ REF_RANGE: Git ref range like HEAD~1..HEAD or main..feature.
116
+ Default: unstaged changes.
117
+ """
118
+ try:
119
+ diff_text: str
120
+ range_label: str
121
+ content_provider: FileContentProvider | None
122
+
123
+ if diff_source == "-":
124
+ diff_text = sys.stdin.read()
125
+ range_label = "stdin"
126
+ content_provider = None
127
+ elif ref_range is not None:
128
+ diff_text = get_diff(ref_range, repo_path=repo)
129
+ range_label = ref_range
130
+ content_provider = _make_content_provider(repo)
131
+ else:
132
+ diff_text = get_diff("HEAD", repo_path=repo)
133
+ range_label = "HEAD (unstaged)"
134
+ content_provider = _make_content_provider(repo)
135
+
136
+ if not diff_text.strip():
137
+ click.echo("No changes found.", err=True)
138
+ sys.exit(EXIT_NO_CHANGES)
139
+
140
+ output = run_pipeline(
141
+ diff_text,
142
+ range_label,
143
+ content_provider,
144
+ skip_generated=skip_generated,
145
+ include_tests=include_tests,
146
+ show_skipped=show_skipped,
147
+ )
148
+
149
+ has_parse_errors = any(fc.parse_error for fc in output.files)
150
+
151
+ text = _format_output(output, fmt, tier)
152
+ click.echo(text)
153
+
154
+ if has_parse_errors:
155
+ sys.exit(EXIT_PARTIAL)
156
+ sys.exit(EXIT_SUCCESS)
157
+
158
+ except Exception as exc:
159
+ logger.debug("CLI error", exc_info=True)
160
+ click.echo(f"Error: {exc}", err=True)
161
+ sys.exit(EXIT_ERROR)
162
+
163
+
164
+ # ---------------------------------------------------------------------------
165
+ # context command
166
+ # ---------------------------------------------------------------------------
167
+
168
+
169
+ def _has_high_signal_changes(
170
+ output: DiffGuardOutput,
171
+ dep_refs: list | None = None,
172
+ ) -> bool:
173
+ """Check if there are any high-signal changes worth reporting."""
174
+ for fc in output.files:
175
+ for sc in fc.changes:
176
+ # Signature changes (before != after)
177
+ if sc.before_signature and sc.after_signature:
178
+ return True
179
+ # Breaking changes
180
+ if sc.breaking:
181
+ return True
182
+ # Removed symbols
183
+ if sc.kind.endswith("_removed"):
184
+ return True
185
+ # Moved symbols
186
+ if sc.kind == "moved":
187
+ return True
188
+
189
+ # External dependency references only matter if we already found
190
+ # high-signal changes for those symbols (checked above).
191
+ # Don't trigger on dep_refs alone — body-only changes with callers
192
+ # are not high-signal.
193
+
194
+ return False
195
+
196
+
197
+ def _categorize_change(sc: SymbolChange) -> str:
198
+ """Return a category label for a symbol change."""
199
+ from diffguard.engine.signatures import classify_signature_change
200
+
201
+ if sc.kind.endswith("_removed"):
202
+ return "SYMBOL REMOVED"
203
+ if sc.kind == "moved":
204
+ return "SYMBOL MOVED"
205
+ if sc.before_signature and sc.after_signature:
206
+ return classify_signature_change(sc.before_signature, sc.after_signature)
207
+ return "CHANGED"
208
+
209
+
210
+ def _review_hint_for_category(category: str) -> str:
211
+ """Return a review hint string for a given category."""
212
+ hints = {
213
+ "PARAMETER REMOVED": "These callers will break — removed parameter no longer accepted",
214
+ "PARAMETER ADDED (BREAKING)": "These callers will break — missing required argument",
215
+ "RETURN TYPE CHANGED": "Callers depending on the return type may break",
216
+ "DEFAULT VALUE CHANGED": "Verify callers expect the new default value",
217
+ "BREAKING SIGNATURE CHANGE": "Check all callers handle the new signature",
218
+ "SIGNATURE CHANGED": "Review the signature change for compatibility",
219
+ "SYMBOL REMOVED": "Ensure no remaining callers depend on this symbol",
220
+ "SYMBOL MOVED": "Update imports in dependent files",
221
+ }
222
+ return hints.get(category, "Review this change")
223
+
224
+
225
+ def _shorten_paths(paths: list[str]) -> list[str]:
226
+ """Strip common prefix from a list of paths."""
227
+ if not paths:
228
+ return paths
229
+ if len(paths) == 1:
230
+ # Just use filename
231
+ return [paths[0].rsplit("/", 1)[-1]]
232
+ # Find common prefix
233
+ parts = [p.split("/") for p in paths]
234
+ prefix_len = 0
235
+ for i in range(min(len(p) for p in parts)):
236
+ if len(set(p[i] for p in parts)) == 1:
237
+ prefix_len = i + 1
238
+ else:
239
+ break
240
+ if prefix_len > 0:
241
+ return ["/".join(p[prefix_len:]) for p in parts]
242
+ return paths
243
+
244
+
245
+ def _format_context_output(
246
+ output: DiffGuardOutput,
247
+ ref_range: str,
248
+ dep_refs: list | None = None,
249
+ ) -> str:
250
+ """Format pipeline output as actionable review instructions."""
251
+ from diffguard.engine.deps import Reference
252
+ from diffguard.engine.summarizer import is_test_file
253
+
254
+ # Collect high-signal changes
255
+ items: list[tuple[FileChange, SymbolChange]] = []
256
+ for fc in output.files:
257
+ for sc in fc.changes:
258
+ if (
259
+ (sc.before_signature and sc.after_signature)
260
+ or sc.breaking
261
+ or sc.kind.endswith("_removed")
262
+ or sc.kind == "moved"
263
+ ):
264
+ items.append((fc, sc))
265
+
266
+ if not items:
267
+ return ""
268
+
269
+ # Build dep lookup: symbol_name -> list of references
270
+ dep_map: dict[str, list[Reference]] = {}
271
+ if dep_refs:
272
+ for ref in dep_refs:
273
+ dep_map.setdefault(ref.symbol_name, []).append(ref)
274
+
275
+ lines: list[str] = [f"⚠ DiffGuard: {len(items)} change{'s' if len(items) != 1 else ''} need{'s' if len(items) == 1 else ''} review"]
276
+ lines.append("")
277
+
278
+ for idx, (fc, sc) in enumerate(items, 1):
279
+ category = _categorize_change(sc)
280
+ sig_text = _sig_display(sc)
281
+ line_ref = f":{sc.line}" if sc.line else ""
282
+
283
+ lines.append(f"{idx}. {category}: {sig_text}")
284
+ lines.append(f" File: {fc.path}{line_ref}")
285
+
286
+ # Impact section
287
+ call_refs = dep_map.get(sc.name, [])
288
+ call_refs = [r for r in call_refs if r.context == "call"]
289
+
290
+ # Separate test vs prod callers
291
+ test_refs = [r for r in call_refs if is_test_file(r.file_path)]
292
+ prod_refs = [r for r in call_refs if not is_test_file(r.file_path)]
293
+
294
+ if sc.breaking:
295
+ if prod_refs:
296
+ lines.append(f" Impact: {len(prod_refs)} caller{'s' if len(prod_refs) != 1 else ''} rely on the default:")
297
+ for r in prod_refs[:5]:
298
+ short_path = r.file_path.rsplit("/", 1)[-1]
299
+ lines.append(f" {short_path}:{r.line} `{r.source_line}`")
300
+ else:
301
+ lines.append(" Impact: Breaking change")
302
+ elif sc.before_signature and sc.after_signature and not sc.breaking:
303
+ if prod_refs:
304
+ caller_parts = []
305
+ by_file: dict[str, int] = {}
306
+ for r in prod_refs:
307
+ fname = r.file_path.rsplit("/", 1)[-1]
308
+ by_file[fname] = by_file.get(fname, 0) + 1
309
+ caller_parts = [f"{f} ({n} call{'s' if n != 1 else ''})" for f, n in by_file.items()]
310
+ lines.append(" Impact: Backward-compatible (new kwarg has default)")
311
+ lines.append(f" Callers: {', '.join(caller_parts)}")
312
+ else:
313
+ lines.append(" Impact: Backward-compatible (new kwarg has default)")
314
+ elif sc.kind.endswith("_removed"):
315
+ if prod_refs:
316
+ lines.append(f" Impact: {len(prod_refs)} caller{'s' if len(prod_refs) != 1 else ''} will break:")
317
+ for r in prod_refs[:5]:
318
+ short_path = r.file_path.rsplit("/", 1)[-1]
319
+ lines.append(f" {short_path}:{r.line} `{r.source_line}`")
320
+ else:
321
+ lines.append(" Impact: Symbol removed")
322
+
323
+ # Show test callers compactly
324
+ if test_refs:
325
+ # Group by file
326
+ by_file: dict[str, int] = {}
327
+ for r in test_refs:
328
+ fname = r.file_path.rsplit("/", 1)[-1]
329
+ by_file[fname] = by_file.get(fname, 0) + 1
330
+ parts = [f"{f} ({n} call{'s' if n != 1 else ''})" for f, n in by_file.items()]
331
+ lines.append(f" Callers: {', '.join(parts)}")
332
+
333
+ # Review instruction
334
+ lines.append(f" Review: {_review_hint_for_category(category)}")
335
+
336
+ lines.append("")
337
+
338
+ return "\n".join(lines).rstrip()
339
+
340
+
341
+ def _sig_display(sc: SymbolChange) -> str:
342
+ """Format signature change display — compact, one-line."""
343
+
344
+ def _compact_sig(sig: str) -> str:
345
+ """Extract just the def/class line and collapse to one line."""
346
+ # Strip decorators — find the first 'def ' or 'class ' line
347
+ for line in sig.split("\n"):
348
+ stripped = line.strip()
349
+ if stripped.startswith(("def ", "class ", "func ", "function ")):
350
+ # If multi-line params, collapse them
351
+ if "(" in stripped and ")" not in stripped:
352
+ # Grab remaining lines until closing paren
353
+ start = sig.index(stripped)
354
+ rest = sig[start:]
355
+ paren_depth = 0
356
+ result_chars = []
357
+ for ch in rest:
358
+ if ch == "(":
359
+ paren_depth += 1
360
+ elif ch == ")":
361
+ paren_depth -= 1
362
+ if ch == "\n":
363
+ ch = " "
364
+ result_chars.append(ch)
365
+ if paren_depth == 0 and ch == ")":
366
+ # Grab return type if present
367
+ remaining = rest[len("".join(result_chars)) :]
368
+ arrow = remaining.split("\n")[0].strip()
369
+ if arrow.startswith("->"):
370
+ result_chars.append(f" {arrow}")
371
+ break
372
+ return " ".join("".join(result_chars).split())
373
+ return stripped
374
+ # Fallback: collapse whole thing
375
+ return " ".join(sig.split())
376
+
377
+ def _strip_keyword(sig: str) -> str:
378
+ """Strip leading def/class/func/function keyword for compact display."""
379
+ for kw in ("def ", "class ", "func ", "function "):
380
+ if sig.startswith(kw):
381
+ sig = sig[len(kw):]
382
+ break
383
+ # Strip return type annotation and trailing colon for compactness
384
+ sig = re.sub(r"\)\s*->.*$", ")", sig)
385
+ sig = sig.rstrip(":")
386
+ return sig
387
+
388
+ if sc.before_signature and sc.after_signature:
389
+ before = _strip_keyword(_compact_sig(sc.before_signature))
390
+ after = _strip_keyword(_compact_sig(sc.after_signature))
391
+ return f"{before} → {after}"
392
+ if sc.signature:
393
+ return _strip_keyword(_compact_sig(sc.signature))
394
+ return f"`{sc.name}`"
395
+
396
+
397
+ def _build_json_output(
398
+ output: DiffGuardOutput,
399
+ ref_range: str,
400
+ dep_refs: list | None = None,
401
+ ) -> str:
402
+ """Build structured JSON output for the review command."""
403
+ from diffguard.engine.deps import Reference
404
+ from diffguard.engine.summarizer import is_test_file
405
+
406
+ dep_map: dict[str, list[Reference]] = {}
407
+ if dep_refs:
408
+ for ref in dep_refs:
409
+ dep_map.setdefault(ref.symbol_name, []).append(ref)
410
+
411
+ findings = []
412
+ for fc in output.files:
413
+ for sc in fc.changes:
414
+ if not (
415
+ (sc.before_signature and sc.after_signature)
416
+ or sc.breaking
417
+ or sc.kind.endswith("_removed")
418
+ or sc.kind == "moved"
419
+ ):
420
+ continue
421
+
422
+ category = _categorize_change(sc)
423
+
424
+ call_refs = dep_map.get(sc.name, [])
425
+ call_refs = [r for r in call_refs if r.context == "call"]
426
+ test_refs = [r for r in call_refs if is_test_file(r.file_path)]
427
+ prod_refs = [r for r in call_refs if not is_test_file(r.file_path)]
428
+
429
+ callers = []
430
+ for r in (prod_refs + test_refs)[:10]:
431
+ callers.append({
432
+ "file": r.file_path,
433
+ "line": r.line,
434
+ "source": r.source_line,
435
+ })
436
+
437
+ finding: dict = {
438
+ "category": category.replace(" ", "_"),
439
+ "symbol": sc.name,
440
+ "file": fc.path,
441
+ "line": sc.line,
442
+ }
443
+ if sc.before_signature:
444
+ finding["before_signature"] = sc.before_signature.strip()
445
+ if sc.after_signature:
446
+ finding["after_signature"] = sc.after_signature.strip()
447
+
448
+ finding["impact"] = {
449
+ "production_callers": len(prod_refs),
450
+ "test_callers": len(test_refs),
451
+ "callers": callers,
452
+ }
453
+
454
+ finding["review_hint"] = _review_hint_for_category(category)
455
+
456
+ findings.append(finding)
457
+
458
+ symbols_changed = sum(len(fc.changes) for fc in output.files)
459
+ result = {
460
+ "version": "0.1.0",
461
+ "ref_range": ref_range,
462
+ "findings": findings,
463
+ "stats": {
464
+ "files_analyzed": len(output.files),
465
+ "symbols_changed": symbols_changed,
466
+ "silence_reason": None if findings else "no high-signal changes",
467
+ },
468
+ }
469
+ return json.dumps(result, indent=2)
470
+
471
+
472
+ def _run_review(ref_range: str, repo: str, deps: bool, verbose: bool, fmt: str) -> None:
473
+ """Shared implementation for review/context commands."""
474
+ try:
475
+ diff_text = get_diff(ref_range, repo_path=repo)
476
+
477
+ if not diff_text.strip():
478
+ if fmt == "json":
479
+ click.echo(json.dumps({
480
+ "version": "0.1.0",
481
+ "ref_range": ref_range,
482
+ "findings": [],
483
+ "stats": {"files_analyzed": 0, "symbols_changed": 0, "silence_reason": "no changes in diff"},
484
+ }, indent=2))
485
+ else:
486
+ click.echo("No changes found.", err=True)
487
+ sys.exit(EXIT_SUCCESS)
488
+
489
+ content_provider = _make_content_provider(repo)
490
+ output = run_pipeline(diff_text, ref_range, content_provider)
491
+
492
+ dep_refs = None
493
+ if deps:
494
+ changed_symbols = []
495
+ changed_files = set()
496
+ for fc in output.files:
497
+ changed_files.add(fc.path)
498
+ for sc in fc.changes:
499
+ changed_symbols.append(sc.name)
500
+
501
+ if changed_symbols:
502
+ parts = ref_range.split("..")
503
+ after_ref = parts[1] if len(parts) == 2 else ref_range # noqa: PLR2004
504
+ dep_refs = find_references(
505
+ repo_path=repo,
506
+ changed_symbols=changed_symbols,
507
+ ref=after_ref,
508
+ changed_files=changed_files,
509
+ )
510
+
511
+ has_findings = _has_high_signal_changes(output, dep_refs)
512
+
513
+ if fmt == "json":
514
+ click.echo(_build_json_output(output, ref_range, dep_refs))
515
+ sys.exit(EXIT_FINDINGS if has_findings else EXIT_SUCCESS)
516
+
517
+ # Text format
518
+ if not verbose and not has_findings:
519
+ sys.exit(EXIT_SUCCESS)
520
+
521
+ text = _format_context_output(output, ref_range, dep_refs)
522
+ if text:
523
+ click.echo(text)
524
+ sys.exit(EXIT_FINDINGS)
525
+ sys.exit(EXIT_SUCCESS)
526
+
527
+ except SystemExit:
528
+ raise
529
+ except Exception as exc:
530
+ logger.debug("CLI error", exc_info=True)
531
+ click.echo(f"Error: {exc}", err=True)
532
+ sys.exit(EXIT_ERROR)
533
+
534
+
535
+ @main.command()
536
+ @click.argument("ref_range", required=False, default=None)
537
+ @click.option("--repo", default=".", help="Repository path (default: current directory).")
538
+ @click.option("--deps/--no-deps", default=True, help="Enable dependency scanning (default: enabled).")
539
+ @click.option("--verbose", is_flag=True, default=False, help="Show full output even when no high-signal changes.")
540
+ @click.option(
541
+ "--format",
542
+ "fmt",
543
+ type=click.Choice(["text", "json"]),
544
+ default="text",
545
+ help="Output format: 'text' for human-readable review, 'json' for structured output.",
546
+ )
547
+ def review(ref_range: str | None, repo: str, deps: bool, verbose: bool, fmt: str) -> None:
548
+ """Analyze git changes and surface high-signal findings for code review.
549
+
550
+ REF_RANGE: Git ref range like HEAD~3..HEAD or main..feature.
551
+ Default: HEAD~1..HEAD (last commit).
552
+
553
+ Detects signature changes, breaking changes, removed/moved symbols,
554
+ and finds callers that may be affected.
555
+
556
+ \b
557
+ Exit codes:
558
+ 0 — No high-signal findings (silence)
559
+ 1 — Findings present (read the output)
560
+ 2 — Error
561
+ """
562
+ if ref_range is None:
563
+ ref_range = "HEAD~1..HEAD"
564
+ _run_review(ref_range, repo, deps, verbose, fmt)
565
+
566
+
567
+ @main.command(hidden=True)
568
+ @click.argument("ref_range", required=False, default=None)
569
+ @click.option("--repo", default=".", help="Repository path (default: current directory).")
570
+ @click.option("--deps/--no-deps", default=True, help="Enable dependency scanning (default: enabled).")
571
+ @click.option("--verbose", is_flag=True, default=False, help="Show full output even when no high-signal changes.")
572
+ @click.option(
573
+ "--format",
574
+ "fmt",
575
+ type=click.Choice(["text", "json"]),
576
+ default="text",
577
+ help="Output format: 'text' for human-readable review, 'json' for structured output.",
578
+ )
579
+ def context(ref_range: str | None, repo: str, deps: bool, verbose: bool, fmt: str) -> None:
580
+ """Alias for 'review' (deprecated)."""
581
+ if ref_range is None:
582
+ ref_range = "HEAD~1..HEAD"
583
+ _run_review(ref_range, repo, deps, verbose, fmt)
584
+
585
+
586
+ _PRE_PUSH_HOOK = """\
587
+ #!/bin/sh
588
+ # DiffGuard pre-push hook — runs diffguard review on pushed changes
589
+ # Installed by: diffguard install-hook
590
+
591
+ remote="$1"
592
+ z40=0000000000000000000000000000000000000000
593
+
594
+ while read local_ref local_sha remote_ref remote_sha; do
595
+ if [ "$remote_sha" = "$z40" ]; then
596
+ # New branch — compare against main/master
597
+ base=$(git rev-parse --verify refs/heads/main 2>/dev/null || git rev-parse --verify refs/heads/master 2>/dev/null || echo "")
598
+ if [ -z "$base" ]; then
599
+ continue
600
+ fi
601
+ range="$base..$local_sha"
602
+ else
603
+ range="$remote_sha..$local_sha"
604
+ fi
605
+
606
+ echo "Running diffguard review $range ..."
607
+ diffguard review "$range"
608
+ status=$?
609
+ if [ $status -eq 1 ]; then
610
+ echo ""
611
+ echo "DiffGuard found changes that need review (see above)."
612
+ echo "Push anyway with: git push --no-verify"
613
+ exit 1
614
+ fi
615
+ done
616
+
617
+ exit 0
618
+ """
619
+
620
+
621
+ @main.command("install-hook")
622
+ @click.option("--repo", default=".", help="Repository path (default: current directory).")
623
+ @click.option(
624
+ "--hook-type",
625
+ type=click.Choice(["pre-push", "pre-commit"]),
626
+ default="pre-push",
627
+ help="Git hook type to install (default: pre-push).",
628
+ )
629
+ @click.option("--force", is_flag=True, default=False, help="Overwrite existing hook.")
630
+ def install_hook(repo: str, hook_type: str, force: bool) -> None:
631
+ """Install a git hook that runs diffguard review before push/commit."""
632
+ import os
633
+ import stat
634
+
635
+ git_dir = os.path.join(repo, ".git")
636
+ if not os.path.isdir(git_dir):
637
+ click.echo(f"Error: {repo} is not a git repository", err=True)
638
+ sys.exit(EXIT_ERROR)
639
+
640
+ hooks_dir = os.path.join(git_dir, "hooks")
641
+ os.makedirs(hooks_dir, exist_ok=True)
642
+
643
+ hook_path = os.path.join(hooks_dir, hook_type)
644
+ if os.path.exists(hook_path) and not force:
645
+ click.echo(f"Hook already exists: {hook_path}", err=True)
646
+ click.echo("Use --force to overwrite.", err=True)
647
+ sys.exit(EXIT_ERROR)
648
+
649
+ hook_content = _PRE_PUSH_HOOK
650
+ if hook_type == "pre-commit":
651
+ hook_content = """\
652
+ #!/bin/sh
653
+ # DiffGuard pre-commit hook — runs diffguard review on staged changes
654
+ # Installed by: diffguard install-hook
655
+
656
+ echo "Running diffguard review HEAD ..."
657
+ diffguard review HEAD
658
+ status=$?
659
+ if [ $status -eq 1 ]; then
660
+ echo ""
661
+ echo "DiffGuard found changes that need review (see above)."
662
+ echo "Commit anyway with: git commit --no-verify"
663
+ exit 1
664
+ fi
665
+
666
+ exit 0
667
+ """
668
+
669
+ with open(hook_path, "w") as f:
670
+ f.write(hook_content)
671
+
672
+ # Make executable
673
+ st = os.stat(hook_path)
674
+ os.chmod(hook_path, st.st_mode | stat.S_IEXEC | stat.S_IXGRP | stat.S_IXOTH)
675
+
676
+ click.echo(f"Installed {hook_type} hook: {hook_path}")
@@ -0,0 +1 @@
1
+ """DiffGuard analysis engine."""