Option C Tools
Loading...
Searching...
No Matches
oct_git.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3# oct/git/oct_git.py
4
5"""
6Purpose
7-------
8Click command definitions for the ``oct git`` command group (Phase 4B).
9Contains only CLI glue — argument parsing, JSON rendering, exit-code
10translation. All computational work is delegated to
11:mod:`oct.git.quality_gate`, :mod:`oct.git.policy`, and
12:mod:`oct.core.git`.
13
14Responsibilities
15----------------
16- Define the ``git`` Click group mounted under the top-level ``cli``.
17- Implement ``oct git status`` with ``--json`` and ``--no-check`` flags.
18- Implement ``oct git check`` with ``--staged-only``, ``--all``,
19 ``--include-tests``, ``--fix``, ``--profile``, ``--json`` flags.
20- Render human-readable terminal output and machine-readable JSON.
21- Populate ``ctx.obj["_audit_record"]`` for the ``@audited`` decorator.
22
23Diagnostics
24-----------
25Domain: GIT
26Levels:
27 L2 — lifecycle: command start/end
28 L3 — details: scope/profile resolution
29 L4 — deep trace: per-file rendering decisions
30
31Contracts
32---------
33- Each command is wrapped with ``@audited`` from :mod:`oct.git.audit`.
34- Exit codes for ``oct git check`` follow blueprint §5.3:
35 0=pass, 1=lint, 2=format, 3=lint+format, 4=tests, 5=secrets.
36- ``oct git status`` always exits 0 (informational only).
37
38Dependencies
39------------
40- oct.core.diagnostics (_dbg structured logger)
41- oct.core.paths (project-relative path helper)
42- oct.core.project_root (project root detection)
43- oct.git.audit (AuditRecord, audited decorator)
44- click (CLI framework)
45"""
46
47from __future__ import annotations
48
49import json
50import sys
51from pathlib import Path
52
53import click
54
55from oct.core.diagnostics import _dbg
56from oct.core.paths import to_project_relative
57from oct.core.project_root import get_project_root, find_project_root
58from oct.git.audit import AuditRecord, audited, _utc_now_iso
59
60
61# =====================================================================
62# Constants
63# =====================================================================
64
65_LARGE_FILE_THRESHOLD_BYTES: int = 10 * 1024 * 1024 # 10 MB
66
67
68# =====================================================================
69# Git command group
70# =====================================================================
71
72
73@click.group()
74@click.pass_context
75def git(ctx):
76 """Git integration with Option C compliance checks."""
77 pass
78
79
80# =====================================================================
81# oct git status
82# =====================================================================
83
84
85def _quick_check_file(path: Path, project_root: Path) -> tuple[bool, list[str]]:
86 """Run a minimal lint check (header + docstring only) on a Python file.
87
88 Returns ``(compliant, issues_list)``. This is the "quick check" from
89 blueprint §5.2 — not the full gate, just enough for status badges.
90 """
91 from oct.linter.oct_lint import LinterContext, _lint_single_file
92
93 ctx = LinterContext(
94 project_root=project_root,
95 project_name=project_root.name,
96 diagnostics_dir=project_root / "oc_diagnostics",
97 tests_dir=project_root / "tests",
98 docs_dir=project_root / "docs",
99 )
100 # Quick-check rules: only header and docstring.
101 quick_rules = {
102 "header": True,
103 "docstring": True,
104 "diagnostics_profile": False,
105 "dbg_usage": False,
106 "dbg_import": True,
107 "func_pattern": False,
108 "tests_exist": False,
109 "type_hints": False,
110 "no_hardcoded_secrets": False,
111 "dbg_assert_safety": False,
112 }
113 try:
114 result = _lint_single_file(path, ctx, quick_rules, False)
115 issues = []
116 detail = result.get("json_detail", {}).get("checks", {})
117 for check_name in ("header", "docstring", "dbg_import"):
118 info = detail.get(check_name, {})
119 if not info.get("pass", True):
120 msg = info.get("message", check_name)
121 issues.append(msg)
122 return result.get("compliant", True), issues
123 except Exception:
124 return True, [] # If we can't check, don't penalise
125
126
127def _badge(compliant: bool | None) -> str:
128 """Return ``[OK]``, ``[!!]``, or ``---`` for status rendering."""
129 if compliant is None:
130 return "---"
131 return "[OK]" if compliant else "[!!]"
132
133
134def _is_within_project(path: Path, project_root_resolved: Path) -> bool:
135 """Return ``True`` if *path* is inside the OCT project root.
136
137 Used by ``git_status_cmd`` to filter git results down to the
138 project scope in monorepo layouts where the git repo root is an
139 ancestor of the OCT project root.
140 """
141 try:
142 path.resolve().relative_to(project_root_resolved)
143 return True
144 except ValueError:
145 return False
146
147
148def _maybe_load_workspace(root: Path):
149 """Probe for workspace mode and load the manifest if applicable.
150
151 F1.2 helper. Returns ``(workspace, in_workspace_mode)``:
152
153 - ``in_workspace_mode = True`` when *root* is itself the workspace
154 root (the directory containing ``.option_c_workspace.json``).
155 This activates per-subproject fan-out.
156 - ``in_workspace_mode = False`` when invocation came from inside a
157 subproject (project-root discovery resolved to that subproject
158 first), even if a workspace manifest exists higher up. This
159 preserves backwards-compat byte-for-byte for single-project
160 callers.
161
162 Returns ``(None, False)`` on any load failure; the caller falls
163 back to the legacy single-project rendering path.
164 """
165 func = "_maybe_load_workspace"
166 try:
167 from oct.core.workspace import (
168 find_workspace_root,
169 load_workspace,
170 )
171 except ImportError:
172 return None, False
173
174 ws_path = find_workspace_root(root)
175 if ws_path is None:
176 return None, False
177 in_workspace_mode = ws_path.resolve() == root.resolve()
178 if not in_workspace_mode:
179 return None, False
180 try:
181 ws = load_workspace(ws_path)
182 except Exception as exc:
183 _dbg(
184 "GIT", func,
185 f"workspace manifest at {ws_path} failed to load: {exc!r}; "
186 f"falling back to single-project mode",
187 1, override=True,
188 )
189 return None, False
190 return ws, True
191
192
194 *,
195 workspace,
196 staged: list[Path],
197 modified: list[Path],
198 untracked: list[Path],
199 no_check: bool,
200) -> dict:
201 """Group files into per-subproject + workspace buckets for F1.2.
202
203 Returns a dict keyed by subproject name (plus ``"workspace"`` for
204 files matching ``workspace.workspace_files``); each value is
205 ``{"staged": [...], "modified": [...], "untracked": [...]}`` of
206 annotated entries.
207
208 Files outside every subproject AND not matching
209 ``workspace_files`` are dropped — the legacy top-level
210 ``staged/modified/untracked`` keys still carry them, so no data
211 is lost; this view is just the per-subproject layout for users
212 who want it.
213 """
214 from oct.core.workspace import (
215 assign_to_subproject,
216 matches_workspace_files,
217 )
218
219 workspace_root_resolved = workspace.root.resolve()
220
221 buckets: dict[str, dict[str, list[dict]]] = {
222 sp.name: {"staged": [], "modified": [], "untracked": []}
223 for sp in workspace.subprojects
224 }
225 buckets["workspace"] = {
226 "staged": [], "modified": [], "untracked": [],
227 }
228
229 def _annotate_for_bucket(
230 path: Path, bucket_root: Path,
231 ) -> dict:
232 """Annotate *path* using *bucket_root* for relative-path display."""
233 try:
234 rel = path.resolve().relative_to(bucket_root.resolve()).as_posix()
235 except ValueError:
236 rel = path.as_posix()
237 if path.suffix != ".py":
238 return {"path": rel, "status": "non_python", "issues": []}
239 if no_check:
240 return {"path": rel, "status": "unchecked", "issues": []}
241 compliant, issues = _quick_check_file(path, bucket_root)
242 return {
243 "path": rel,
244 "status": "ok" if compliant else "violations",
245 "issues": issues,
246 }
247
248 def _annotate_workspace(path: Path) -> dict:
249 """Workspace-bucket annotation: path rendered repo-relative,
250 compliance not quick-checked (workspace files are usually
251 non-Python — README, .gitignore, etc.)."""
252 try:
253 rel = path.resolve().relative_to(
254 workspace_root_resolved,
255 ).as_posix()
256 except ValueError:
257 rel = path.as_posix()
258 if path.suffix != ".py":
259 return {"path": rel, "status": "non_python", "issues": []}
260 if no_check:
261 return {"path": rel, "status": "unchecked", "issues": []}
262 compliant, issues = _quick_check_file(path, workspace.root)
263 return {
264 "path": rel,
265 "status": "ok" if compliant else "violations",
266 "issues": issues,
267 }
268
269 def _route(path: Path, kind: str) -> None:
270 sp = assign_to_subproject(path, workspace)
271 if sp is not None:
272 buckets[sp.name][kind].append(
273 _annotate_for_bucket(path, sp.path),
274 )
275 return
276 # Not in any subproject; consult workspace_files.
277 try:
278 rel = path.resolve().relative_to(
279 workspace_root_resolved,
280 ).as_posix()
281 except ValueError:
282 rel = path.as_posix()
283 if matches_workspace_files(rel, workspace.workspace_files):
284 buckets["workspace"][kind].append(_annotate_workspace(path))
285 # else: drop — legacy top-level keys still carry the file.
286
287 for p in staged:
288 _route(p, "staged")
289 for p in modified:
290 _route(p, "modified")
291 # Untracked files should also bucket via the same logic so the user
292 # sees per-subproject context for new files.
293 for p in untracked:
294 sp = assign_to_subproject(p, workspace)
295 if sp is not None:
296 try:
297 rel = p.resolve().relative_to(sp.path).as_posix()
298 except ValueError:
299 rel = p.as_posix()
300 buckets[sp.name]["untracked"].append({
301 "path": rel, "status": "untracked", "issues": [],
302 })
303 else:
304 try:
305 rel = p.resolve().relative_to(
306 workspace_root_resolved,
307 ).as_posix()
308 except ValueError:
309 rel = p.as_posix()
310 if matches_workspace_files(rel, workspace.workspace_files):
311 buckets["workspace"]["untracked"].append({
312 "path": rel, "status": "untracked", "issues": [],
313 })
314
315 return buckets
316
317
318def _get_untracked_files(root: Path) -> list[Path]:
319 """Return untracked files (not ignored) via ``git ls-files``."""
320 func = "_get_untracked_files"
321 _dbg("GIT", func, f"root={root}", 3)
322 from oct.core.git import git_run
323 proc = git_run(
324 ["ls-files", "--others", "--exclude-standard"],
325 cwd=root,
326 )
327 result = []
328 for line in proc.stdout.splitlines():
329 line = line.strip()
330 if line:
331 result.append((root / line).resolve())
332 return result
333
334
335def _get_modified_unstaged(root: Path) -> list[Path]:
336 """Return tracked files with unstaged changes."""
337 func = "_get_modified_unstaged"
338 _dbg("GIT", func, f"root={root}", 3)
339 from oct.core.git import git_run
340 proc = git_run(
341 ["diff", "--name-only", "--diff-filter=ACMR"],
342 cwd=root,
343 )
344 result = []
345 for line in proc.stdout.splitlines():
346 line = line.strip()
347 if line:
348 result.append((root / line).resolve())
349 return result
350
351
352def _get_ahead_behind(root: Path) -> tuple[int, int]:
353 """Return ``(ahead, behind)`` counts relative to upstream.
354
355 Returns ``(0, 0)`` if there is no upstream or if git fails.
356 """
357 from oct.core.git import git_run
358 proc = git_run(
359 ["rev-list", "--count", "--left-right", "@{upstream}...HEAD"],
360 cwd=root,
361 check=False,
362 )
363 if proc.returncode != 0:
364 return (0, 0)
365 parts = proc.stdout.strip().split()
366 if len(parts) == 2:
367 try:
368 return int(parts[1]), int(parts[0])
369 except ValueError:
370 pass
371 return (0, 0)
372
373
374@git.command(name="status")
375@click.option("--json", "json_mode", is_flag=True, help="Emit JSON output.")
376@click.option(
377 "--no-check", is_flag=True,
378 help="Skip compliance annotations (plain file list).",
379)
380@click.option(
381 "--scope", "scope",
382 type=click.Choice(["project", "repo", "auto"]),
383 default=None,
384 help=(
385 "Scope of files to show: project (default), repo (whole git "
386 "repo), auto (project unless project_root != repo_root)."
387 ),
388)
389@click.pass_context
390@audited("oct git status")
391def git_status_cmd(ctx, json_mode, no_check, scope):
392 """Show git status with Option C compliance annotations."""
393 func = "git_status_cmd"
394 root = get_project_root(ctx)
395 _dbg(
396 "GIT", func,
397 f"root={root} json={json_mode} no_check={no_check} scope={scope!r}",
398 2,
399 )
400
401 from oct.core.git import (
402 current_branch,
403 get_repo_root,
404 git_staged_files,
405 is_detached_head,
406 is_git_repo,
407 )
408
409 if not is_git_repo(root):
410 click.echo("Not a git repository.", err=True)
411 ctx.exit(1)
412 return
413
414 # Git returns paths relative to the repo root, which may differ from
415 # the OCT project root in monorepo layouts. Use the actual git repo
416 # root for file-listing calls so paths resolve correctly, and keep
417 # ``root`` (project root) for compliance checks and display.
418 repo_root = get_repo_root(root)
419 _dbg("GIT", func, f"repo_root={repo_root}", 3)
420
421 # F1.2: detect workspace mode. Mode activates only when the project
422 # root resolved to the workspace root itself — invocation from
423 # inside a subproject keeps the legacy single-project rendering.
424 workspace, in_workspace_mode = _maybe_load_workspace(root)
425 _dbg(
426 "GIT", func,
427 f"workspace_mode={in_workspace_mode} "
428 f"subprojects={(len(workspace.subprojects) if workspace else 0)}",
429 3,
430 )
431
432 # Resolve effective scope: CLI > octrc > "project".
433 if scope is None:
434 octrc = _load_octrc(root)
435 if isinstance(octrc, dict):
436 git_cfg = octrc.get("git")
437 if isinstance(git_cfg, dict):
438 raw = git_cfg.get("status_default_scope")
439 if isinstance(raw, str) and raw in ("project", "repo", "auto"):
440 scope = raw
441 if scope is None:
442 scope = "project"
443
444 root_resolved = root.resolve()
445 repo_root_resolved = repo_root.resolve()
446
447 # ``auto`` collapses to ``project`` when project root == repo root.
448 effective_scope = scope
449 if scope == "auto":
450 effective_scope = (
451 "project" if root_resolved == repo_root_resolved else "repo"
452 )
453 _dbg("GIT", func, f"effective_scope={effective_scope}", 3)
454
455 branch = current_branch(repo_root)
456 detached = is_detached_head(repo_root)
457 ahead, behind = _get_ahead_behind(repo_root)
458 staged = git_staged_files(repo_root)
459 modified = _get_modified_unstaged(repo_root)
460 untracked = _get_untracked_files(repo_root)
461
462 # Partition into in-project and out-of-project so the renderer can
463 # show them separately when scope=repo or scope=auto-monorepo.
464 def _partition(files: list[Path]) -> tuple[list[Path], list[Path]]:
465 in_proj: list[Path] = []
466 out_proj: list[Path] = []
467 for p in files:
468 if _is_within_project(p, root_resolved):
469 in_proj.append(p)
470 else:
471 out_proj.append(p)
472 return in_proj, out_proj
473
474 staged_in, staged_out = _partition(staged)
475 modified_in, modified_out = _partition(modified)
476 untracked_in, untracked_out = _partition(untracked)
477
478 # Deduplicate: same-file appearing in both staged and modified is
479 # an artefact of path-resolution noise; mirror real git semantics
480 # by keeping it in staged only (real "modified" entries have
481 # additional unstaged hunks which the user will see on diff).
482 staged_in_set = {p.resolve() for p in staged_in}
483 modified_in = [p for p in modified_in if p.resolve() not in staged_in_set]
484 staged_out_set = {p.resolve() for p in staged_out}
485 modified_out = [p for p in modified_out if p.resolve() not in staged_out_set]
486
487 # Apply scope to determine what to render.
488 show_out_of_project = effective_scope == "repo"
489
490 # -- Build annotated file lists --
491 def annotate(files: list[Path]) -> list[dict]:
492 annotated = []
493 for path in files:
494 rel = to_project_relative(path, root)
495 if path.suffix != ".py":
496 annotated.append({
497 "path": rel, "status": "non_python", "issues": [],
498 })
499 elif no_check:
500 annotated.append({
501 "path": rel, "status": "unchecked", "issues": [],
502 })
503 else:
504 compliant, issues = _quick_check_file(path, root)
505 annotated.append({
506 "path": rel,
507 "status": "ok" if compliant else "violations",
508 "issues": issues,
509 })
510 return annotated
511
512 def annotate_out(files: list[Path]) -> list[dict]:
513 """Out-of-project files: render with repo-relative path, unchecked."""
514 out: list[dict] = []
515 for path in files:
516 try:
517 rel = path.resolve().relative_to(repo_root_resolved).as_posix()
518 except ValueError:
519 rel = path.as_posix()
520 out.append({"path": rel, "status": "unchecked", "issues": []})
521 return out
522
523 staged_ann = annotate(staged_in)
524 modified_ann = annotate(modified_in)
525 untracked_ann = [
526 {"path": to_project_relative(p, root),
527 "status": "untracked", "issues": []}
528 for p in untracked_in
529 ]
530
531 out_of_project_ann: list[dict] = []
532 if show_out_of_project:
533 for entry in annotate_out(staged_out):
534 entry["section"] = "staged"
535 out_of_project_ann.append(entry)
536 for entry in annotate_out(modified_out):
537 entry["section"] = "modified"
538 out_of_project_ann.append(entry)
539 for path in untracked_out:
540 try:
541 rel = path.resolve().relative_to(repo_root_resolved).as_posix()
542 except ValueError:
543 rel = path.as_posix()
544 out_of_project_ann.append({
545 "path": rel, "status": "untracked",
546 "issues": [], "section": "untracked",
547 })
548
549 # -- F1.2 workspace_view: per-subproject + workspace buckets --
550 workspace_view: dict | None = None
551 if in_workspace_mode and workspace is not None and workspace.subprojects:
552 workspace_view = _build_workspace_view(
553 workspace=workspace,
554 staged=staged_in,
555 modified=modified_in,
556 untracked=untracked_in,
557 no_check=no_check,
558 )
559
560 # -- Populate audit record --
561 record = ctx.obj.get("_audit_record", AuditRecord())
562 record.timestamp = record.timestamp or _utc_now_iso()
563 record.command = "oct git status"
564 record.branch = branch if not detached else None
565 record.staged_files = len(staged_in)
566 record.checks_passed = True
567 record.exit_code = 0
568 ctx.obj["_audit_record"] = record
569
570 # -- Render output --
571 if json_mode:
572 output = {
573 "branch": branch if not detached else None,
574 "detached": detached,
575 "ahead": ahead,
576 "behind": behind,
577 "scope": effective_scope,
578 "staged": staged_ann,
579 "modified": modified_ann,
580 "untracked": untracked_ann,
581 "out_of_project": out_of_project_ann,
582 "workspace_view": workspace_view,
583 }
584 click.echo(json.dumps(output, indent=2, ensure_ascii=False))
585 return
586
587 # Terminal output (blueprint §5.2 format).
588 if detached:
589 click.echo("HEAD detached")
590 else:
591 click.echo(f"On branch {branch}")
592 if ahead > 0 or behind > 0:
593 parts = []
594 if ahead > 0:
595 parts.append(f"ahead by {ahead}")
596 if behind > 0:
597 parts.append(f"behind by {behind}")
598 click.echo(f" {', '.join(parts)}")
599 click.echo()
600
601 def _render_section(label: str, items: list[dict]):
602 if not items:
603 return
604 click.echo(f"{label} ({len(items)} file{'s' if len(items) != 1 else ''}):")
605 for item in items:
606 status = item["status"]
607 path = item["path"]
608 if status == "ok":
609 badge = " [OK] "
610 elif status == "violations":
611 issues_str = " (" + ", ".join(item["issues"]) + ")" if item["issues"] else ""
612 click.echo(f" [!!] {path}{issues_str}")
613 continue
614 elif status == "non_python":
615 badge = " --- "
616 elif status == "unchecked":
617 badge = " "
618 else:
619 badge = " "
620 click.echo(f"{badge}{path}")
621 click.echo()
622
623 if workspace_view is not None:
624 # F1.2: workspace mode — render one block per subproject + a
625 # workspace-level block for files matching workspace_files[].
626 for bucket_name in (
627 *(sp.name for sp in workspace.subprojects),
628 "workspace",
629 ):
630 bucket = workspace_view.get(bucket_name, {})
631 staged_b = bucket.get("staged", [])
632 modified_b = bucket.get("modified", [])
633 untracked_b = bucket.get("untracked", [])
634 if not (staged_b or modified_b or untracked_b):
635 continue
636 click.echo(f"=== {bucket_name} ===")
637 _render_section("Staged", staged_b)
638 _render_section("Modified", modified_b)
639 _render_section("Untracked", untracked_b)
640 else:
641 _render_section("Staged", staged_ann)
642 _render_section("Modified", modified_ann)
643 _render_section("Untracked", untracked_ann)
644 if show_out_of_project and out_of_project_ann:
645 click.echo(
646 f"Other repo files ({len(out_of_project_ann)} "
647 f"file{'s' if len(out_of_project_ann) != 1 else ''}, "
648 f"outside OCT project):"
649 )
650 for item in out_of_project_ann:
651 section = item.get("section", "?")
652 click.echo(f" [{section[:3]}] {item['path']}")
653 click.echo()
654
655
656# =====================================================================
657# oct git check
658# =====================================================================
659
660
661@git.command(name="check")
662@click.option(
663 "--staged-only", is_flag=True,
664 help="Check only staged (index) files.",
665)
666@click.option(
667 "--all", "check_all", is_flag=True,
668 help="Check every Python file in the project.",
669)
670@click.option(
671 "--include-tests", is_flag=True,
672 help="Also run pytest (exit code 4 on failure).",
673)
674@click.option(
675 "--fix", is_flag=True,
676 help="Attempt auto-fix of lint/format violations, then re-check.",
677)
678@click.option(
679 "--profile",
680 type=click.Choice(["proto", "compact", "strict"], case_sensitive=False),
681 default=None,
682 help="Override the lint profile.",
683)
684@click.option("--json", "json_mode", is_flag=True, help="Emit JSON output.")
685@click.option(
686 "--sandbox", is_flag=True, default=False,
687 help="OI-517: run pytest (when --include-tests) under the MCP sandbox "
688 "environment so PYTHONPATH and secret-named env vars are stripped.",
689)
690@click.pass_context
691@audited("oct git check")
692def git_check_cmd(ctx, staged_only, check_all, include_tests, fix, profile, json_mode, sandbox):
693 """Run the unified quality gate: lint + format + secrets."""
694 func = "git_check_cmd"
695 root = get_project_root(ctx)
696 _dbg("GIT", func, f"root={root}", 2)
697
698 from oct.core.git import current_branch, is_git_repo
699 from oct.git.policy import apply_profile_policy, resolve_effective_profile
700 from oct.git.quality_gate import run_quality_gate
701
702 if not is_git_repo(root):
703 click.echo("Not a git repository.", err=True)
704 ctx.exit(1)
705 return
706
707 # -- Resolve scope --
708 if staged_only:
709 scope = "staged"
710 elif check_all:
711 scope = "all"
712 else:
713 scope = "changed"
714
715 # -- Resolve profile --
716 branch = current_branch(root)
717 octrc = _load_octrc(root)
718 effective_profile = resolve_effective_profile(branch, octrc, profile)
719 _dbg(
720 "GIT", func,
721 f"scope={scope} profile={effective_profile} fix={fix}",
722 3,
723 )
724
725 # -- Run quality gate --
726 result = run_quality_gate(
727 project_root=root,
728 scope=scope,
729 profile=effective_profile,
730 include_tests=include_tests,
731 fix=fix,
732 sandbox=sandbox,
733 )
734
735 # -- Derive exit code via policy --
736 exit_code = apply_profile_policy(effective_profile, result)
737 result.exit_code = exit_code
738
739 # -- Populate audit record --
740 record = ctx.obj.get("_audit_record", AuditRecord())
741 record.timestamp = record.timestamp or _utc_now_iso()
742 record.command = "oct git check"
743 record.branch = branch
744 record.staged_files = result.files_checked
745 record.checks_passed = (exit_code == 0)
746 record.secrets_detected = bool(result.secrets_findings)
747 record.lint_violations = result.lint_violations
748 record.format_violations = result.format_violations
749 record.exit_code = exit_code
750 ctx.obj["_audit_record"] = record
751
752 # -- Render output --
753 if json_mode:
754 output = {
755 "scope": scope,
756 "profile": effective_profile,
757 "files_checked": result.files_checked,
758 "lint_violations": result.lint_violations,
759 "format_violations": result.format_violations,
760 "secrets_findings": [
761 {"path": s[0], "line": s[1], "reason": s[2]}
762 for s in result.secrets_findings
763 ],
764 "test_failures": result.test_failures,
765 "exit_code": exit_code,
766 "duration_ms": result.duration_ms,
767 }
768 click.echo(json.dumps(output, indent=2, ensure_ascii=False))
769 ctx.exit(exit_code)
770 return
771
772 # Terminal summary.
773 click.echo(f"Quality gate: scope={scope}, profile={effective_profile}")
774 click.echo(f" Files checked: {result.files_checked}")
775 click.echo(f" Lint violations: {result.lint_violations}")
776 click.echo(f" Format issues: {result.format_violations}")
777 click.echo(f" Secrets findings: {len(result.secrets_findings)}")
778 if include_tests:
779 click.echo(f" Test failures: {result.test_failures}")
780 click.echo(f" Duration: {result.duration_ms} ms")
781 click.echo()
782
783 if result.secrets_findings:
784 click.echo("SECRETS DETECTED (always blocking):", err=True)
785 for path_str, line, reason in result.secrets_findings:
786 loc = f"{path_str}:{line}" if line else path_str
787 click.echo(f" {loc} — {reason}", err=True)
788 click.echo()
789
790 if exit_code == 0:
791 click.echo("All checks passed.")
792 else:
793 labels = {
794 1: "Lint violations",
795 2: "Format violations",
796 3: "Lint + format violations",
797 4: "Test failures",
798 5: "Secrets detected",
799 }
800 click.echo(
801 f"FAILED (exit {exit_code}): {labels.get(exit_code, 'unknown')}",
802 err=True,
803 )
804
805 ctx.exit(exit_code)
806
807
808# =====================================================================
809# oct git init
810# =====================================================================
811
812
813_GITATTRIBUTES_DEFAULTS: tuple[str, ...] = (
814 "*.py text eol=lf",
815 "*.pem binary",
816 "*.key binary",
817)
818
819
820_GITHUB_WORKFLOW_TEMPLATE = """\
821name: Option C CI
822on: [push, pull_request]
823jobs:
824 check:
825 runs-on: ubuntu-latest
826 steps:
827 - uses: actions/checkout@v4
828 - uses: actions/setup-python@v5
829 with:
830 python-version: "3.12"
831 - run: pip install -e ./oct
832 - run: oct lint --json
833 - run: oct format --dry-run
834 - run: oct test
835"""
836
837# Marker comments used to delimit the Option C block in .gitignore / .gitattributes.
838# Shared with ``oct/git/policy.py`` so ``oct git init`` (which writes the initial
839# block with section comments) and ``oct git ignore`` (which adds bare patterns)
840# agree on the literal fence strings.
841from oct.git.policy import (
842 OC_GITIGNORE_MARKER_END as _OC_MARKER_END,
843 OC_GITIGNORE_MARKER_START as _OC_MARKER_START,
844)
845
846
848 path: Path, entries: tuple[str, ...], dry_run: bool,
849) -> list[str]:
850 """Merge *entries* into an ignore/attributes file, appending only missing lines.
851
852 Returns the list of entries that were (or would be) added.
853 """
854 existing_lines: set[str] = set()
855 if path.is_file():
856 for line in path.read_text(encoding="utf-8").splitlines():
857 existing_lines.add(line.strip())
858
859 # Filter to entries not already present (skip comments/blanks for dedup).
860 to_add: list[str] = []
861 for entry in entries:
862 stripped = entry.strip()
863 if stripped == "" or stripped.startswith("#"):
864 # Always include structural lines in the block, but they don't
865 # count as "added entries" for reporting.
866 continue
867 if stripped not in existing_lines:
868 to_add.append(stripped)
869
870 if not to_add:
871 return []
872
873 # Build the block to append, including all entries (with comments/blanks).
874 block_lines: list[str] = [_OC_MARKER_START]
875 for entry in entries:
876 stripped = entry.strip()
877 if stripped == "" or stripped.startswith("#"):
878 block_lines.append(entry)
879 elif stripped in {a for a in to_add}:
880 block_lines.append(entry)
881 block_lines.append(_OC_MARKER_END)
882
883 if not dry_run:
884 # Ensure existing content ends with a newline before appending.
885 existing = ""
886 if path.is_file():
887 existing = path.read_text(encoding="utf-8")
888 if existing and not existing.endswith("\n"):
889 existing += "\n"
890 with path.open("w", encoding="utf-8") as fh:
891 fh.write(existing + "\n".join(block_lines) + "\n")
892
893 return to_add
894
895
896def _create_gitattributes(root: Path, dry_run: bool) -> list[str]:
897 """Create or merge ``.gitattributes`` with Option C defaults."""
898 path = root / ".gitattributes"
899 return _merge_ignore_file(path, _GITATTRIBUTES_DEFAULTS, dry_run)
900
901
902def _generate_github_workflow(root: Path, dry_run: bool) -> bool:
903 """Write ``.github/workflows/option-c.yml``. Returns True if created."""
904 workflow_dir = root / ".github" / "workflows"
905 workflow_path = workflow_dir / "option-c.yml"
906 if workflow_path.is_file():
907 return False
908 if not dry_run:
909 workflow_dir.mkdir(parents=True, exist_ok=True)
910 workflow_path.write_text(_GITHUB_WORKFLOW_TEMPLATE, encoding="utf-8")
911 return True
912
913
914@git.command(name="init")
915@click.option("--dry-run", is_flag=True, help="Show what would be created/modified.")
916@click.option("--github", is_flag=True, help="Also generate GitHub Actions workflow.")
917@click.option("--no-hooks", is_flag=True, help="Skip hook installation.")
918@click.pass_context
919@audited("oct git init")
920def git_init_cmd(ctx, dry_run, github, no_hooks):
921 """Initialise or enhance a git repo with Option C conventions."""
922 func = "git_init_cmd"
923 # Accept either a --root-dir context or cwd.
924 try:
925 root = get_project_root(ctx)
926 except click.ClickException:
927 root = Path.cwd()
928 _dbg("GIT", func, f"root={root} dry_run={dry_run}", 2)
929
930 from oct.core.git import is_git_repo, git_run
931 from oct.core.exclusions import GIT_IGNORE_DEFAULTS
932 from oct.hooks.oct_hooks import install_hooks
933
934 actions: list[str] = []
935
936 # 1. git init if needed
937 if not is_git_repo(root):
938 if dry_run:
939 actions.append("Would run: git init")
940 else:
941 git_run(["init"], cwd=root)
942 actions.append("Initialised git repository")
943
944 # F1.4: when running at a workspace root, augment the standard
945 # ignore-defaults with the manifest's ignore_at_workspace_scope[]
946 # patterns. Subproject roots get the standard defaults only.
947 init_ws, init_in_ws = _maybe_load_workspace(root)
948 if (
949 init_in_ws and init_ws is not None
950 and init_ws.ignore_at_workspace_scope
951 ):
952 ignore_entries: tuple[str, ...] = (
953 *GIT_IGNORE_DEFAULTS,
954 "",
955 "# --- workspace-scope additions ---",
956 *init_ws.ignore_at_workspace_scope,
957 )
958 _dbg(
959 "GIT", func,
960 f"workspace-mode init: appending "
961 f"{len(init_ws.ignore_at_workspace_scope)} workspace patterns",
962 3,
963 )
964 else:
965 ignore_entries = GIT_IGNORE_DEFAULTS
966
967 # 2. Merge .gitignore
968 added_ignores = _merge_ignore_file(
969 root / ".gitignore", ignore_entries, dry_run,
970 )
971 if added_ignores:
972 verb = "Would add" if dry_run else "Added"
973 actions.append(f"{verb} {len(added_ignores)} entries to .gitignore")
974 else:
975 actions.append(".gitignore already up-to-date")
976
977 # 3. Create/merge .gitattributes
978 added_attrs = _create_gitattributes(root, dry_run)
979 if added_attrs:
980 verb = "Would add" if dry_run else "Added"
981 actions.append(f"{verb} {len(added_attrs)} entries to .gitattributes")
982 else:
983 actions.append(".gitattributes already up-to-date")
984
985 # 4. Install hooks
986 if no_hooks:
987 actions.append("Skipped hook installation (--no-hooks)")
988 else:
989 hook_path = root / ".pre-commit-config.yaml"
990 if hook_path.is_file():
991 actions.append(".pre-commit-config.yaml already exists")
992 elif dry_run:
993 actions.append("Would create .pre-commit-config.yaml")
994 else:
995 install_hooks(root, force=False)
996 actions.append("Created .pre-commit-config.yaml")
997
998 # 5. GitHub Actions workflow
999 if github:
1000 created = _generate_github_workflow(root, dry_run)
1001 if created:
1002 verb = "Would create" if dry_run else "Created"
1003 actions.append(f"{verb} .github/workflows/option-c.yml")
1004 else:
1005 actions.append(".github/workflows/option-c.yml already exists")
1006
1007 # -- Populate audit record --
1008 record = ctx.obj.get("_audit_record", AuditRecord())
1009 record.timestamp = record.timestamp or _utc_now_iso()
1010 record.command = "oct git init"
1011 record.checks_passed = True
1012 record.exit_code = 0
1013 ctx.obj["_audit_record"] = record
1014
1015 # -- Render output --
1016 prefix = "[DRY RUN] " if dry_run else ""
1017 for action in actions:
1018 click.echo(f"{prefix}{action}")
1019 if not dry_run:
1020 click.echo("Option C git initialisation complete.")
1021
1022
1023# =====================================================================
1024# oct git commit (Phase 4D — G-D3, G-D4)
1025# =====================================================================
1026
1027
1028@git.command(
1029 name="commit",
1030 context_settings={"ignore_unknown_options": True},
1031)
1032@click.option(
1033 "-m", "--message", "messages", required=True, multiple=True,
1034 help=(
1035 "Commit message (Conventional Commits format). May be passed "
1036 "multiple times — each value becomes a paragraph separated by "
1037 "a blank line, matching ``git commit -m`` semantics."
1038 ),
1039)
1040@click.option("--force", "force_commit", is_flag=True,
1041 help="Skip lint/format checks (secrets still checked).")
1042@click.option("--no-verify", is_flag=True,
1043 help="Forward --no-verify to git (hooks skipped).")
1044@click.option(
1045 "--profile",
1046 type=click.Choice(["proto", "compact", "strict"], case_sensitive=False),
1047 default=None,
1048 help="Override lint profile.",
1049)
1050@click.option("--json", "json_mode", is_flag=True, help="Emit JSON output.")
1051@click.option(
1052 "--ignore-unstaged-mutation", "ignore_unstaged_mutation", is_flag=True,
1053 help=(
1054 "Downgrade B1.0 unstaged-tracked-file mutation detection from "
1055 "block to warn for this invocation."
1056 ),
1057)
1058@click.argument("extra_args", nargs=-1, type=click.UNPROCESSED)
1059@click.pass_context
1060@audited("oct git commit")
1062 ctx, messages, force_commit, no_verify, profile, json_mode,
1063 ignore_unstaged_mutation, extra_args,
1064):
1065 """Commit staged changes with quality gate enforcement."""
1066 func = "git_commit_cmd"
1067 root = get_project_root(ctx)
1068 # L4: join multiple -m values with a blank line between paragraphs
1069 # to mirror plain ``git commit -m subject -m body`` semantics.
1070 message = "\n\n".join(m for m in messages if m is not None)
1071 _dbg("GIT", func, f"root={root} force={force_commit} -m count={len(messages)}", 2)
1072
1073 from oct.core.git import current_branch, git_run, is_git_repo
1074 from oct.git.conventional import (
1075 analyse_diagnostics_impact,
1076 format_validation_errors,
1077 )
1078 from oct.git.policy import apply_profile_policy
1079 from oct.git.quality_gate import pre_commit_checks, run_quality_gate
1080
1081 if not is_git_repo(root):
1082 click.echo("Not a git repository.", err=True)
1083 ctx.exit(1)
1084 return
1085
1086 branch = current_branch(root)
1087 octrc = _load_octrc(root)
1088
1089 # -- 1-3. Pre-gate checks (OI-527): commit message, staged files, protected branch --
1090 pre = pre_commit_checks(
1091 project_root=root,
1092 branch=branch,
1093 message=message,
1094 force_commit=force_commit,
1095 profile=profile,
1096 octrc=octrc,
1097 )
1098 effective_profile = pre.effective_profile
1099 if pre.pb_advisory:
1100 click.echo(pre.pb_advisory, err=True)
1101
1102 if not pre.ok:
1103 # -- Auto-branch on protected-branch block (Phase 4F) --
1104 if pre.exit_code == 1 and _should_auto_branch(branch, octrc):
1105 auto_branch_name = _auto_branch_for_commit(
1106 root, branch, message, ctx, json_mode,
1107 )
1108 if auto_branch_name is None:
1109 # _auto_branch_for_commit already emitted errors and
1110 # populated the audit record.
1111 return
1112 branch = auto_branch_name
1113 # Re-run pre-checks on the new (non-protected) branch.
1114 pre = pre_commit_checks(
1115 project_root=root, branch=branch, message=message,
1116 force_commit=force_commit, profile=profile, octrc=octrc,
1117 )
1118 effective_profile = pre.effective_profile
1119
1120 if not pre.ok:
1121 if json_mode:
1123 ctx, exit_code=pre.exit_code, message=message,
1124 branch=branch, profile=effective_profile,
1125 errors=pre.errors,
1126 )
1127 elif pre.exit_code == 3:
1128 click.echo(format_validation_errors(pre.errors), err=True)
1129 elif pre.exit_code == 4:
1130 click.echo("Nothing to commit (no staged files).", err=True)
1131 # exit_code == 1 already surfaced via pb_advisory echo above.
1133 ctx, branch=branch, exit_code=pre.exit_code,
1134 checks_passed=False, staged_count=len(pre.staged_files),
1135 )
1136 ctx.exit(pre.exit_code)
1137 return
1138
1139 staged = pre.staged_files
1140
1141 # -- B1.0: snapshot tracked-but-unstaged files for mutation detection --
1142 observe_mode = _resolve_observe_mode(octrc, ignore_unstaged_mutation)
1143 pre_snapshot: dict[str, str] = {}
1144 if observe_mode != "off":
1145 try:
1146 from oct.core.git import get_repo_root
1147 from oct.git.commit_observer import snapshot_unstaged_tracked
1148 repo_root_for_obs = get_repo_root(root)
1149 staged_rel = {
1150 str(p.resolve().relative_to(repo_root_for_obs.resolve()))
1151 .replace("\\", "/")
1152 for p in staged
1153 if _safe_relative(p, repo_root_for_obs) is not None
1154 }
1155 pre_snapshot = snapshot_unstaged_tracked(
1156 repo_root_for_obs, staged_set=staged_rel,
1157 )
1158 except Exception as exc:
1159 _dbg(
1160 "GIT", func,
1161 f"observer snapshot failed: {exc!r}; continuing without",
1162 1, override=True,
1163 )
1164 pre_snapshot = {}
1165 observe_mode = "off"
1166
1167 # -- 4. Run quality gate on staged files --
1168 # F1.3: in workspace mode with subprojects, fan the staged set out
1169 # by subproject so each subproject's slice runs under its own
1170 # profile. The aggregated result preserves the secrets-always-block
1171 # invariant (G-D4) — a finding in any subproject still trips
1172 # secret enforcement below.
1173 commit_workspace, commit_in_workspace_mode = _maybe_load_workspace(root)
1174 per_sp_results: dict[str, "QualityGateResult"] = {}
1175 per_sp_meta: dict[str, dict] = {}
1176 use_fanout = (
1177 commit_in_workspace_mode
1178 and commit_workspace is not None
1179 and bool(commit_workspace.subprojects)
1180 )
1181
1182 if use_fanout:
1183 from oct.git.quality_gate import run_quality_gate_on_files
1184 groups = _group_files_by_subproject(staged, commit_workspace)
1185 for sp in commit_workspace.subprojects:
1186 sp_files = groups.get(sp.name, [])
1187 if not sp_files:
1188 continue
1189 sp_octrc = _load_octrc(sp.path)
1190 sp_profile = _resolve_subproject_profile(
1191 sp, sp_octrc, profile, branch,
1192 )
1193 per_sp_results[sp.name] = run_quality_gate_on_files(
1194 project_root=sp.path, files=sp_files,
1195 profile=sp_profile, include_tests=False, fix=False,
1196 )
1197 per_sp_meta[sp.name] = {
1198 "profile": sp_profile, "files": len(sp_files),
1199 }
1200 ws_files = groups.get("workspace", [])
1201 if ws_files:
1202 ws_profile = (
1203 profile or commit_workspace.workspace_profile
1204 or effective_profile
1205 )
1206 per_sp_results["workspace"] = run_quality_gate_on_files(
1207 project_root=commit_workspace.root, files=ws_files,
1208 profile=ws_profile, include_tests=False, fix=False,
1209 )
1210 per_sp_meta["workspace"] = {
1211 "profile": ws_profile, "files": len(ws_files),
1212 }
1213
1214 result = _merge_quality_gate_results(per_sp_results)
1215 # Apply policy per-bucket; overall exit is the worst.
1216 if not force_commit:
1217 gate_exit = max(
1218 (
1219 apply_profile_policy(per_sp_meta[name]["profile"], r)
1220 for name, r in per_sp_results.items()
1221 ),
1222 default=0,
1223 )
1224 else:
1225 gate_exit = 0
1226 elif not force_commit:
1227 result = run_quality_gate(
1228 project_root=root,
1229 scope="staged",
1230 profile=effective_profile,
1231 include_tests=False,
1232 fix=False,
1233 )
1234 gate_exit = apply_profile_policy(effective_profile, result)
1235 else:
1236 # --force: still run secrets check only.
1237 result = run_quality_gate(
1238 project_root=root,
1239 scope="staged",
1240 profile=effective_profile,
1241 include_tests=False,
1242 fix=False,
1243 )
1244 gate_exit = 0 # Force ignores lint/format.
1245
1246 # G-D4 invariant: secrets are NEVER bypassable.
1247 if result.secrets_findings:
1248 if json_mode:
1250 ctx, exit_code=2, message=message, branch=branch,
1251 profile=effective_profile,
1252 errors=[f"Secrets detected in {len(result.secrets_findings)} location(s)."],
1253 secrets=result.secrets_findings,
1254 )
1255 else:
1256 click.echo("SECRETS DETECTED (non-bypassable):", err=True)
1257 for path_str, line, reason in result.secrets_findings:
1258 loc = f"{path_str}:{line}" if line else path_str
1259 click.echo(f" {loc} — {reason}", err=True)
1261 ctx, branch=branch, exit_code=2,
1262 checks_passed=False, staged_count=len(staged),
1263 secrets=True, lint_v=result.lint_violations,
1264 format_v=result.format_violations,
1265 )
1266 ctx.exit(2)
1267 return
1268
1269 # Non-secret quality gate failures (only when not --force).
1270 if gate_exit != 0 and not force_commit:
1271 if json_mode:
1273 ctx, exit_code=1, message=message, branch=branch,
1274 profile=effective_profile,
1275 errors=[f"Quality gate failed (exit {gate_exit})."],
1276 )
1277 else:
1278 click.echo(
1279 f"Quality gate failed (exit {gate_exit}). "
1280 f"Use --force to skip lint/format checks.",
1281 err=True,
1282 )
1284 ctx, branch=branch, exit_code=1,
1285 checks_passed=False, staged_count=len(staged),
1286 lint_v=result.lint_violations,
1287 format_v=result.format_violations,
1288 )
1289 ctx.exit(1)
1290 return
1291
1292 if force_commit and gate_exit != 0:
1293 click.echo(
1294 f"Warning: quality gate would have failed (exit {gate_exit}), "
1295 f"bypassed with --force.",
1296 err=True,
1297 )
1298
1299 # -- 5. Large-file advisory (G-D6) --
1300 oversized = _check_large_staged_files(root, staged, octrc)
1301 for rel, size in oversized:
1302 mb = size / (1024 * 1024)
1303 click.echo(
1304 f"Warning: {rel} is {mb:.1f} MB. Consider .gitignore or Git LFS.",
1305 err=True,
1306 )
1307
1308 # -- 6. Advisory checks --
1309 _advisory_checks(root, staged, message, err=True)
1310
1311 # -- 7. --no-verify warning --
1312 if no_verify:
1313 click.echo(
1314 "Skipping git hooks (--no-verify). "
1315 "Note: oct git commit still runs its own quality checks.",
1316 err=True,
1317 )
1318
1319 # -- 7.5 B1.0: pre-commit-call mutation check (catches gate writers) --
1320 if observe_mode != "off" and pre_snapshot:
1321 gate_mutations = _check_unstaged_mutation(
1322 root, pre_snapshot, staged,
1323 stage_label="quality gate",
1324 )
1325 if gate_mutations and observe_mode == "block":
1327 ctx, mutations=gate_mutations,
1328 message=message, branch=branch,
1329 profile=effective_profile, json_mode=json_mode,
1330 staged_count=len(staged),
1331 )
1332 return
1333
1334 # -- 8. Execute git commit --
1335 git_args = ["commit", "-m", message]
1336 if no_verify:
1337 git_args.append("--no-verify")
1338 git_args.extend(extra_args)
1339
1340 try:
1341 git_run(git_args, cwd=root)
1342 except Exception as exc:
1343 err_msg = str(exc)
1344 if json_mode:
1346 ctx, exit_code=4, message=message, branch=branch,
1347 profile=effective_profile,
1348 errors=[f"git commit failed: {err_msg}"],
1349 )
1350 else:
1351 click.echo(f"git commit failed: {err_msg}", err=True)
1353 ctx, branch=branch, exit_code=4,
1354 checks_passed=False, staged_count=len(staged),
1355 )
1356 ctx.exit(4)
1357 return
1358
1359 # -- 8.5 B1.0: post-commit mutation check (catches git-hook writers) --
1360 if observe_mode != "off" and pre_snapshot:
1361 hook_mutations = _check_unstaged_mutation(
1362 root, pre_snapshot, staged,
1363 stage_label="git-hook",
1364 )
1365 if hook_mutations and observe_mode == "block":
1367 ctx, mutations=hook_mutations,
1368 message=message, branch=branch,
1369 profile=effective_profile, json_mode=json_mode,
1370 staged_count=len(staged),
1371 )
1372 return
1373
1374 # -- 9. Diagnostics impact (G-D5) --
1375 try:
1376 from oct.core.git import git_run as _gr
1377 diff_proc = _gr(
1378 ["diff", "HEAD~1..HEAD"], cwd=root, check=False,
1379 )
1380 impact = analyse_diagnostics_impact(diff_proc.stdout)
1381 if impact:
1382 click.echo("Diagnostics Impact:", err=True)
1383 for line in impact:
1384 click.echo(f" {line}", err=True)
1385 except Exception:
1386 pass # Best-effort; never block.
1387
1388 # -- 10. Success output --
1389 if json_mode:
1391 ctx, exit_code=0, message=message, branch=branch,
1392 profile=effective_profile, errors=[],
1393 )
1394 else:
1395 click.echo("Commit successful.")
1396
1397 psr_audit = (
1398 _build_per_subproject_audit(per_sp_results, per_sp_meta)
1399 if use_fanout else None
1400 )
1402 ctx, branch=branch, exit_code=0,
1403 checks_passed=True, staged_count=len(staged),
1404 per_subproject_results=psr_audit,
1405 )
1406
1407
1409 ctx, *, exit_code: int, message: str, branch: str | None,
1410 profile: str, errors: list[str] | None = None,
1411 secrets: list | None = None,
1412) -> None:
1413 """Emit JSON output for the commit command."""
1414 output: dict = {
1415 "exit_code": exit_code,
1416 "message": message,
1417 "branch": branch,
1418 "profile": profile,
1419 "errors": errors or [],
1420 }
1421 if secrets:
1422 output["secrets_findings"] = [
1423 {"path": s[0], "line": s[1], "reason": s[2]} for s in secrets
1424 ]
1425 click.echo(json.dumps(output, indent=2, ensure_ascii=False))
1426
1427
1429 ctx, *, branch: str | None, exit_code: int,
1430 checks_passed: bool, staged_count: int,
1431 secrets: bool = False, lint_v: int = 0, format_v: int = 0,
1432 tracked_mutations: list[str] | None = None,
1433 per_subproject_results: list[dict] | None = None,
1434) -> None:
1435 """Populate the audit record for oct git commit."""
1436 record = ctx.obj.get("_audit_record", AuditRecord())
1437 record.timestamp = record.timestamp or _utc_now_iso()
1438 record.command = "oct git commit"
1439 record.branch = branch
1440 record.staged_files = staged_count
1441 record.checks_passed = checks_passed
1442 record.secrets_detected = secrets
1443 record.lint_violations = lint_v
1444 record.format_violations = format_v
1445 record.exit_code = exit_code
1446 if tracked_mutations:
1447 # AuditRecord schema has no field for this yet; stash on the
1448 # ctx.obj for downstream serialisation. Test code reads it from
1449 # the JSONL line directly.
1450 ctx.obj["_tracked_mutations"] = list(tracked_mutations)
1451 if per_subproject_results is not None:
1452 # F1.3: per-subproject fan-out results. Stashed on ctx.obj for
1453 # downstream JSONL serialisation; AuditRecord doesn't yet
1454 # carry the field but the audit writer picks it up from
1455 # ctx.obj if present.
1456 ctx.obj["_per_subproject_results"] = list(per_subproject_results)
1457 ctx.obj["_audit_record"] = record
1458
1459
1460# =====================================================================
1461# F1.3 fan-out helpers
1462# =====================================================================
1463
1464
1466 staged: list[Path], workspace,
1467) -> dict[str, list[Path]]:
1468 """Group *staged* paths into per-subproject buckets + workspace bucket.
1469
1470 Files matched by a subproject's path go to that subproject's
1471 bucket; files not under any subproject AND matching
1472 ``workspace.workspace_files`` go to the ``"workspace"`` bucket;
1473 files matching neither are dropped (they exist outside any
1474 declared scope).
1475 """
1476 from oct.core.workspace import (
1477 assign_to_subproject,
1478 matches_workspace_files,
1479 )
1480 workspace_root_resolved = workspace.root.resolve()
1481 buckets: dict[str, list[Path]] = {
1482 sp.name: [] for sp in workspace.subprojects
1483 }
1484 buckets["workspace"] = []
1485 for path in staged:
1486 sp = assign_to_subproject(path, workspace)
1487 if sp is not None:
1488 buckets[sp.name].append(path)
1489 continue
1490 try:
1491 rel = path.resolve().relative_to(
1492 workspace_root_resolved,
1493 ).as_posix()
1494 except ValueError:
1495 continue
1496 if matches_workspace_files(rel, workspace.workspace_files):
1497 buckets["workspace"].append(path)
1498 return buckets
1499
1500
1502 subproject, sp_octrc: dict | None,
1503 cli_override: str | None, branch: str | None,
1504) -> str:
1505 """Resolve the lint profile for *subproject* under workspace fan-out.
1506
1507 Precedence (highest first):
1508
1509 1. **Protected-branch override** — when a per-subproject octrc
1510 lists the current branch as protected, force ``"strict"``.
1511 2. **CLI ``--profile``** — explicit user override.
1512 3. **Manifest ``subproject.profile``** — workspace-declared
1513 per-subproject default.
1514 4. **``sp_octrc.linter.profile``** — the subproject's own
1515 ``.octrc.json`` setting.
1516 5. **``"compact"``** — final fallback.
1517 """
1518 func = "_resolve_subproject_profile"
1519 # Protected branch wins everything.
1520 if branch and isinstance(sp_octrc, dict):
1521 git_cfg = sp_octrc.get("git")
1522 if isinstance(git_cfg, dict):
1523 protected = git_cfg.get("protected_branches")
1524 if isinstance(protected, list) and branch in protected:
1525 _dbg(
1526 "GIT", func,
1527 f"branch {branch!r} protected for subproject "
1528 f"{subproject.name!r} -> strict",
1529 3,
1530 )
1531 return "strict"
1532 if cli_override in ("proto", "compact", "strict"):
1533 return cli_override
1534 if subproject.profile in ("proto", "compact", "strict"):
1535 return subproject.profile
1536 if isinstance(sp_octrc, dict):
1537 linter_cfg = sp_octrc.get("linter")
1538 if isinstance(linter_cfg, dict):
1539 p = linter_cfg.get("profile")
1540 if p in ("proto", "compact", "strict"):
1541 return p
1542 return "compact"
1543
1544
1546 per_sp: dict[str, "QualityGateResult"],
1547):
1548 """Aggregate per-subproject results into a single QualityGateResult.
1549
1550 Counters (lint, format, files_checked, test_failures) are summed.
1551 Secrets findings are concatenated (preserves the
1552 ``(path, line, reason)`` tuples the G-D4 invariant uses to surface
1553 every leak). per_file is concatenated. duration_ms is the max
1554 (forward-compatible with parallel execution; v1 runs serially so
1555 it's effectively the longest single bucket).
1556 """
1557 from oct.git.quality_gate import QualityGateResult
1558 if not per_sp:
1559 return QualityGateResult()
1560 files_checked = 0
1561 lint_violations = 0
1562 format_violations = 0
1563 test_failures = 0
1564 duration_ms = 0
1565 secrets_findings: list = []
1566 per_file: list = []
1567 for r in per_sp.values():
1568 files_checked += r.files_checked
1569 lint_violations += r.lint_violations
1570 format_violations += r.format_violations
1571 test_failures += r.test_failures
1572 duration_ms = max(duration_ms, r.duration_ms)
1573 secrets_findings.extend(r.secrets_findings)
1574 per_file.extend(r.per_file)
1575 return QualityGateResult(
1576 files_checked=files_checked,
1577 lint_violations=lint_violations,
1578 format_violations=format_violations,
1579 secrets_findings=secrets_findings,
1580 test_failures=test_failures,
1581 duration_ms=duration_ms,
1582 per_file=per_file,
1583 )
1584
1585
1587 per_sp_results: dict, per_sp_meta: dict,
1588) -> list[dict]:
1589 """Shape the per-subproject results dict for the audit record."""
1590 out: list[dict] = []
1591 for name, r in per_sp_results.items():
1592 meta = per_sp_meta.get(name, {})
1593 out.append({
1594 "name": name,
1595 "profile": meta.get("profile", "compact"),
1596 "files": meta.get("files", r.files_checked),
1597 "lint_violations": r.lint_violations,
1598 "format_violations": r.format_violations,
1599 "secrets": len(r.secrets_findings),
1600 })
1601 return out
1602
1603
1605 octrc: dict | None, ignore_unstaged_mutation: bool,
1606) -> str:
1607 """Return ``"off"`` / ``"warn"`` / ``"block"`` for the B1.0 observer.
1608
1609 Precedence (highest first): ``--ignore-unstaged-mutation`` flag
1610 forces ``"warn"``; ``.octrc.json -> git.observe_unstaged_mutation``
1611 sets the persistent default; final fallback is ``"warn"``.
1612 """
1613 if ignore_unstaged_mutation:
1614 return "warn"
1615 if isinstance(octrc, dict):
1616 git_cfg = octrc.get("git")
1617 if isinstance(git_cfg, dict):
1618 raw = git_cfg.get("observe_unstaged_mutation")
1619 if raw is False or raw == "off":
1620 return "off"
1621 if raw in ("warn", "block"):
1622 return raw
1623 return "warn"
1624
1625
1626def _safe_relative(path: Path, root: Path) -> str | None:
1627 """Return *path* relative to *root* as a forward-slash string, or
1628 ``None`` when *path* is outside *root*.
1629 """
1630 try:
1631 rel = path.resolve().relative_to(root.resolve())
1632 except ValueError:
1633 return None
1634 return str(rel).replace("\\", "/")
1635
1636
1638 root: Path, pre_snapshot: dict[str, str],
1639 staged: list[Path], stage_label: str,
1640) -> list[str]:
1641 """Re-snapshot and diff against *pre_snapshot*. Emit a stderr
1642 warning for each mutated path. Returns the list of changed paths.
1643 """
1644 func = "_check_unstaged_mutation"
1645 try:
1646 from oct.core.git import get_repo_root
1647 from oct.git.commit_observer import (
1648 diff_snapshots, snapshot_unstaged_tracked,
1649 )
1650 repo_root = get_repo_root(root)
1651 staged_rel: set[str] = set()
1652 for p in staged:
1653 r = _safe_relative(p, repo_root)
1654 if r is not None:
1655 staged_rel.add(r)
1656 post = snapshot_unstaged_tracked(repo_root, staged_set=staged_rel)
1657 except Exception as exc:
1658 _dbg(
1659 "GIT", func,
1660 f"observer post-snapshot failed: {exc!r}; skipping",
1661 1, override=True,
1662 )
1663 return []
1664
1665 mutations = diff_snapshots(pre_snapshot, post)
1666 if mutations:
1667 click.echo(
1668 f"WARNING: {len(mutations)} unstaged tracked file(s) "
1669 f"mutated during {stage_label}:",
1670 err=True,
1671 )
1672 for rel in mutations:
1673 click.echo(f" {rel}", err=True)
1674 return mutations
1675
1676
1678 ctx, *, mutations: list[str], message: str, branch: str | None,
1679 profile: str, json_mode: bool, staged_count: int,
1680) -> None:
1681 """Abort the commit with exit code 6 (B1.0 block mode)."""
1682 click.echo(
1683 "ABORTED: tracked-file mutation detected during commit pipeline. "
1684 "Inspect the listed files and re-run with "
1685 "--ignore-unstaged-mutation to override.",
1686 err=True,
1687 )
1688 if json_mode:
1690 ctx, exit_code=6, message=message, branch=branch,
1691 profile=profile,
1692 errors=[
1693 "Unstaged tracked file mutation detected: "
1694 + ", ".join(mutations),
1695 ],
1696 )
1698 ctx, branch=branch, exit_code=6,
1699 checks_passed=False, staged_count=staged_count,
1700 tracked_mutations=mutations,
1701 )
1702 ctx.exit(6)
1703
1704
1706 root: Path, staged: list[Path], message: str, err: bool = True,
1707) -> None:
1708 """Print advisory warnings for missing docs files."""
1709 staged_names = {p.name for p in staged}
1710 staged_rel = {to_project_relative(p, root) for p in staged}
1711
1712 # Warn if new/deleted .py files without ARCHITECTURE.md staged.
1713 py_staged = [p for p in staged if p.suffix == ".py"]
1714 if len(py_staged) > 0 and "docs/ARCHITECTURE.md" not in staged_rel:
1715 has_arch = (root / "docs" / "ARCHITECTURE.md").is_file()
1716 if has_arch:
1717 click.echo(
1718 "Advisory: ARCHITECTURE.md is not staged. "
1719 "Consider updating it if you changed module structure.",
1720 err=err,
1721 )
1722
1723 # Warn if multi-file commit without CHANGELOG.md staged.
1724 if len(staged) > 3 and "CHANGELOG.md" not in staged_names:
1725 if (root / "CHANGELOG.md").is_file():
1726 click.echo(
1727 "Advisory: CHANGELOG.md is not staged for this multi-file commit.",
1728 err=err,
1729 )
1730
1731
1732# =====================================================================
1733# oct git branch (Phase 4F — G-F1)
1734# =====================================================================
1735
1736
1737@git.command(name="branch")
1738@click.option("--create", "create_name", default=None,
1739 help="Create a new branch from HEAD.")
1740@click.option("--switch", "switch_name", default=None,
1741 help="Switch to an existing branch.")
1742@click.option("--json", "json_mode", is_flag=True, help="Emit JSON output.")
1743@click.pass_context
1744@audited("oct git branch")
1745def git_branch_cmd(ctx, create_name, switch_name, json_mode):
1746 """Manage branches with Option C naming conventions."""
1747 func = "git_branch_cmd"
1748 root = get_project_root(ctx)
1749 _dbg("GIT", func, f"root={root} create={create_name!r} switch={switch_name!r}", 2)
1750
1751 from oct.core.git import (
1752 branch_exists,
1753 create_branch,
1754 has_uncommitted_changes,
1755 is_git_repo,
1756 switch_branch,
1757 )
1758 from oct.git.policy import validate_branch_name
1759
1760 if not is_git_repo(root):
1761 click.echo("Not a git repository.", err=True)
1762 _branch_audit(ctx, exit_code=1)
1763 ctx.exit(1)
1764 return
1765
1766 octrc = _load_octrc(root)
1767
1768 # -- Mutual exclusivity ------------------------------------------------
1769 if create_name and switch_name:
1770 msg = "Error: --create and --switch are mutually exclusive."
1771 click.echo(msg, err=True)
1772 if json_mode:
1773 _emit_branch_json(exit_code=1, errors=[msg])
1774 _branch_audit(ctx, exit_code=1)
1775 ctx.exit(1)
1776 return
1777
1778 if not create_name and not switch_name:
1779 msg = "Use --create NAME or --switch NAME. Branch listing coming soon."
1780 click.echo(msg, err=True)
1781 if json_mode:
1782 _emit_branch_json(exit_code=1, errors=[msg])
1783 _branch_audit(ctx, exit_code=1)
1784 ctx.exit(1)
1785 return
1786
1787 # -- --create -----------------------------------------------------------
1788 if create_name:
1789 valid, val_msg = validate_branch_name(create_name, octrc)
1790 if not valid:
1791 click.echo(f"Error: {val_msg}", err=True)
1792 if json_mode:
1794 operation="create", branch=create_name,
1795 exit_code=1, errors=[val_msg],
1796 )
1797 _branch_audit(ctx, exit_code=1, branch=create_name)
1798 ctx.exit(1)
1799 return
1800
1801 if branch_exists(root, create_name):
1802 msg = f"Error: branch '{create_name}' already exists."
1803 click.echo(msg, err=True)
1804 if json_mode:
1806 operation="create", branch=create_name,
1807 exit_code=1, errors=[msg],
1808 )
1809 _branch_audit(ctx, exit_code=1, branch=create_name)
1810 ctx.exit(1)
1811 return
1812
1813 try:
1814 create_branch(root, create_name)
1815 except Exception as exc:
1816 msg = f"Git error: {exc}"
1817 click.echo(msg, err=True)
1818 if json_mode:
1820 operation="create", branch=create_name,
1821 exit_code=2, errors=[msg],
1822 )
1823 _branch_audit(ctx, exit_code=2, branch=create_name)
1824 ctx.exit(2)
1825 return
1826
1827 if json_mode:
1829 operation="create", branch=create_name, exit_code=0,
1830 )
1831 else:
1832 click.echo(f"Created branch '{create_name}' (not switched to it).")
1833 _branch_audit(ctx, exit_code=0, branch=create_name)
1834 return
1835
1836 # -- --switch -----------------------------------------------------------
1837 if switch_name:
1838 if not branch_exists(root, switch_name):
1839 msg = f"Error: branch '{switch_name}' does not exist."
1840 click.echo(msg, err=True)
1841 if json_mode:
1843 operation="switch", branch=switch_name,
1844 exit_code=1, errors=[msg],
1845 )
1846 _branch_audit(ctx, exit_code=1, branch=switch_name)
1847 ctx.exit(1)
1848 return
1849
1850 if has_uncommitted_changes(root):
1851 msg = (
1852 "Error: uncommitted changes would be lost. "
1853 "Commit or stash them first."
1854 )
1855 click.echo(msg, err=True)
1856 if json_mode:
1858 operation="switch", branch=switch_name,
1859 exit_code=1, errors=[msg],
1860 )
1861 _branch_audit(ctx, exit_code=1, branch=switch_name)
1862 ctx.exit(1)
1863 return
1864
1865 try:
1866 switch_branch(root, switch_name)
1867 except Exception as exc:
1868 msg = f"Git error: {exc}"
1869 click.echo(msg, err=True)
1870 if json_mode:
1872 operation="switch", branch=switch_name,
1873 exit_code=2, errors=[msg],
1874 )
1875 _branch_audit(ctx, exit_code=2, branch=switch_name)
1876 ctx.exit(2)
1877 return
1878
1879 if json_mode:
1881 operation="switch", branch=switch_name, exit_code=0,
1882 )
1883 else:
1884 click.echo(f"Switched to branch '{switch_name}'.")
1885 _branch_audit(ctx, exit_code=0, branch=switch_name)
1886 return
1887
1888
1890 *,
1891 operation: str = "",
1892 branch: str = "",
1893 exit_code: int = 0,
1894 errors: list[str] | None = None,
1895) -> None:
1896 """Emit JSON for ``oct git branch`` to stdout."""
1897 click.echo(json.dumps({
1898 "operation": operation,
1899 "branch": branch,
1900 "exit_code": exit_code,
1901 "errors": errors or [],
1902 }, indent=2))
1903
1904
1906 ctx: click.Context,
1907 *,
1908 exit_code: int,
1909 branch: str = "",
1910) -> None:
1911 """Populate the audit record for ``oct git branch``."""
1912 record = ctx.obj.get("_audit_record", AuditRecord())
1913 record.timestamp = record.timestamp or _utc_now_iso()
1914 record.command = "oct git branch"
1915 record.branch = branch or None
1916 record.checks_passed = exit_code == 0
1917 record.exit_code = exit_code
1918 ctx.obj["_audit_record"] = record
1919
1920
1921# =====================================================================
1922# oct git add (Phase 4G — staging command)
1923# =====================================================================
1924
1925
1927 *,
1928 operation: str,
1929 paths_staged: list[str],
1930 paths_skipped: list[dict],
1931 post_check: dict | None,
1932 exit_code: int,
1933 errors: list[str] | None = None,
1934) -> None:
1935 """Emit JSON for ``oct git add`` to stdout."""
1936 click.echo(json.dumps({
1937 "operation": operation,
1938 "paths_staged": paths_staged,
1939 "paths_skipped": paths_skipped,
1940 "post_check": post_check,
1941 "exit_code": exit_code,
1942 "errors": errors or [],
1943 }, indent=2))
1944
1945
1947 ctx: click.Context,
1948 *,
1949 exit_code: int,
1950 staged_count: int = 0,
1951) -> None:
1952 """Populate the audit record for ``oct git add``."""
1953 record = ctx.obj.get("_audit_record", AuditRecord())
1954 record.timestamp = record.timestamp or _utc_now_iso()
1955 record.command = "oct git add"
1956 record.staged_files = staged_count
1957 record.checks_passed = exit_code == 0
1958 record.exit_code = exit_code
1959 ctx.obj["_audit_record"] = record
1960
1961
1963 root: Path,
1964 octrc: dict | None,
1965) -> dict | None:
1966 """Run the staged-only quality gate and return a summary dict.
1967
1968 Honours ``git.add_post_check`` (default ``True``). Returns ``None``
1969 when disabled.
1970 """
1971 func = "_post_check_summary"
1972 if isinstance(octrc, dict):
1973 git_cfg = octrc.get("git")
1974 if isinstance(git_cfg, dict):
1975 raw = git_cfg.get("add_post_check")
1976 if isinstance(raw, bool) and not raw:
1977 _dbg("GIT", func, "post_check disabled by octrc", 3)
1978 return None
1979 try:
1980 from oct.core.git import current_branch
1981 from oct.git.policy import resolve_effective_profile
1982 from oct.git.quality_gate import run_quality_gate
1983 except ImportError:
1984 _dbg("GIT", func, "quality_gate import failed; skipping", 1, override=True)
1985 return None
1986 try:
1987 branch = current_branch(root)
1988 except Exception:
1989 branch = None
1990 profile = resolve_effective_profile(branch, octrc, None)
1991 try:
1992 result = run_quality_gate(
1993 project_root=root, scope="staged",
1994 profile=profile, fix=False, include_tests=False,
1995 )
1996 except Exception as exc:
1997 _dbg("GIT", func, f"quality_gate raised {exc!r}; skipping", 1, override=True)
1998 return None
1999 return {
2000 "profile": profile,
2001 "lint_violations": result.lint_violations,
2002 "format_violations": result.format_violations,
2003 "secrets_findings": len(result.secrets_findings),
2004 "exit_code": result.exit_code,
2005 }
2006
2007
2008@git.command(name="add")
2009@click.argument("paths", nargs=-1, type=click.Path())
2010@click.option(
2011 "--patch", "-p", "patch_mode", is_flag=True,
2012 help="Interactive hunk-level staging (passthrough to git add -p).",
2013)
2014@click.option(
2015 "--update", "-u", "update_mode", is_flag=True,
2016 help="Stage all already-tracked modified files (-u).",
2017)
2018@click.option(
2019 "--all", "-A", "all_mode", is_flag=True,
2020 help="Stage all changes within project scope (-A).",
2021)
2022@click.option(
2023 "--dry-run", is_flag=True,
2024 help="Show what would be staged without staging.",
2025)
2026@click.option(
2027 "--json", "json_mode", is_flag=True, help="Emit JSON output.",
2028)
2029@click.pass_context
2030@audited("oct git add")
2032 ctx, paths, patch_mode, update_mode, all_mode, dry_run, json_mode,
2033):
2034 """Stage files for commit (whole-file or interactive hunk)."""
2035 func = "git_add_cmd"
2036 root = get_project_root(ctx)
2037 _dbg(
2038 "GIT", func,
2039 f"root={root} paths={paths} patch={patch_mode} "
2040 f"update={update_mode} all={all_mode} dry_run={dry_run}",
2041 2,
2042 )
2043
2044 from oct.core.git import (
2045 get_repo_root,
2046 is_git_repo,
2047 stage_paths,
2048 stage_paths_interactive,
2049 )
2050
2051 if not is_git_repo(root):
2052 click.echo("Not a git repository.", err=True)
2053 _add_audit(ctx, exit_code=1)
2054 ctx.exit(1)
2055 return
2056
2057 repo_root = get_repo_root(root)
2058 octrc = _load_octrc(root)
2059
2060 # -- Mutual exclusivity --------------------------------------------------
2061 if patch_mode and (update_mode or all_mode):
2062 msg = "Error: --patch cannot be combined with --update or --all."
2063 click.echo(msg, err=True)
2064 if json_mode:
2066 operation="patch", paths_staged=[], paths_skipped=[],
2067 post_check=None, exit_code=1, errors=[msg],
2068 )
2069 _add_audit(ctx, exit_code=1)
2070 ctx.exit(1)
2071 return
2072
2073 if update_mode and all_mode:
2074 msg = "Error: --update and --all are mutually exclusive."
2075 click.echo(msg, err=True)
2076 if json_mode:
2078 operation="add", paths_staged=[], paths_skipped=[],
2079 post_check=None, exit_code=1, errors=[msg],
2080 )
2081 _add_audit(ctx, exit_code=1)
2082 ctx.exit(1)
2083 return
2084
2085 # -- Resolve and validate paths ------------------------------------------
2086 root_resolved = root.resolve()
2087 skipped: list[dict] = []
2088 resolved_paths: list[Path] = []
2089 for raw in paths:
2090 candidate = (Path.cwd() / raw).resolve() \
2091 if not Path(raw).is_absolute() else Path(raw).resolve()
2092 if not _is_within_project(candidate, root_resolved):
2093 skipped.append({
2094 "path": str(raw), "reason": "outside project scope",
2095 })
2096 continue
2097 resolved_paths.append(candidate)
2098
2099 if skipped and not json_mode:
2100 for entry in skipped:
2101 click.echo(
2102 f"Skipping {entry['path']} ({entry['reason']}).",
2103 err=True,
2104 )
2105
2106 # -- Empty selection guard -----------------------------------------------
2107 if not resolved_paths and not (update_mode or all_mode or patch_mode):
2108 msg = (
2109 "Error: no paths to stage. Provide PATHS, "
2110 "or use --update / --all / --patch."
2111 )
2112 click.echo(msg, err=True)
2113 if json_mode:
2115 operation="add", paths_staged=[], paths_skipped=skipped,
2116 post_check=None, exit_code=1, errors=[msg],
2117 )
2118 _add_audit(ctx, exit_code=1)
2119 ctx.exit(1)
2120 return
2121
2122 # -- Dry-run -------------------------------------------------------------
2123 if dry_run:
2124 operation = "dry-run"
2125 rel_strings = [
2126 to_project_relative(p, root) for p in resolved_paths
2127 ]
2128 if update_mode or all_mode:
2129 # Compute what git would stage for -u / -A by inspecting status.
2130 from oct.core.git import git_run as _git_run
2131 argv = ["status", "--porcelain"]
2132 proc = _git_run(argv, cwd=repo_root)
2133 for line in proc.stdout.splitlines():
2134 # Porcelain format: "XY path"; XY are status flags.
2135 if len(line) < 4:
2136 continue
2137 xy = line[:2]
2138 rel = line[3:].strip()
2139 if update_mode and xy[1] in (" ", ""):
2140 # Tracked + unmodified — git add -u skips it.
2141 continue
2142 if update_mode and xy[1] == "?":
2143 continue
2144 full = (repo_root / rel).resolve()
2145 if not _is_within_project(full, root_resolved):
2146 continue
2147 rel_strings.append(to_project_relative(full, root))
2148 if json_mode:
2150 operation=operation, paths_staged=rel_strings,
2151 paths_skipped=skipped, post_check=None, exit_code=0,
2152 )
2153 else:
2154 click.echo("Would stage:")
2155 for s in rel_strings:
2156 click.echo(f" {s}")
2157 _add_audit(ctx, exit_code=0, staged_count=len(rel_strings))
2158 return
2159
2160 # -- Patch mode ----------------------------------------------------------
2161 if patch_mode:
2162 if json_mode:
2163 click.echo(
2164 "Note: --patch is interactive; --json output is limited.",
2165 err=True,
2166 )
2167 try:
2168 rc = stage_paths_interactive(repo_root, resolved_paths)
2169 except Exception as exc:
2170 msg = f"Git error: {exc}"
2171 click.echo(msg, err=True)
2172 if json_mode:
2174 operation="patch", paths_staged=[],
2175 paths_skipped=skipped, post_check=None,
2176 exit_code=2, errors=[msg],
2177 )
2178 _add_audit(ctx, exit_code=2)
2179 ctx.exit(2)
2180 return
2181 if rc != 0:
2182 _add_audit(ctx, exit_code=rc)
2183 ctx.exit(rc)
2184 return
2185 post = _post_check_summary(root, octrc)
2186 if json_mode:
2188 operation="patch", paths_staged=[],
2189 paths_skipped=skipped, post_check=post, exit_code=0,
2190 )
2191 else:
2193 _add_audit(ctx, exit_code=0)
2194 return
2195
2196 # -- Whole-file staging --------------------------------------------------
2197 # L2: capture pre-stage index so we can decide, on a non-zero git
2198 # exit, whether anything actually got staged. Git emits exit 1
2199 # alongside the stderr "ignored path" warning when staging tracked
2200 # files inside an ignored directory; the operation succeeds but the
2201 # exit code lies. We mirror plain-git semantics by treating
2202 # "warning + something staged" as success.
2203 from oct.core.git import git_staged_files
2204 pre_index = {p.resolve() for p in git_staged_files(repo_root)}
2205
2206 git_warning: str | None = None
2207 try:
2208 stage_paths(
2209 repo_root, resolved_paths,
2210 update=update_mode, all_=all_mode,
2211 )
2212 except Exception as exc:
2213 # Re-query the index. If it grew, this was a "warning"-class
2214 # git failure (exit 1 + something staged). Treat as success
2215 # with the stderr message preserved.
2216 post_index = {p.resolve() for p in git_staged_files(repo_root)}
2217 newly_staged = post_index - pre_index
2218 if newly_staged:
2219 git_warning = str(exc)
2220 click.echo(
2221 f"Note: git emitted a warning during staging but "
2222 f"{len(newly_staged)} file(s) were staged successfully:\n"
2223 f" {git_warning}",
2224 err=True,
2225 )
2226 else:
2227 msg = f"Git error: {exc}"
2228 click.echo(msg, err=True)
2229 if json_mode:
2231 operation="add", paths_staged=[],
2232 paths_skipped=skipped,
2233 post_check=None, exit_code=2, errors=[msg],
2234 )
2235 _add_audit(ctx, exit_code=2)
2236 ctx.exit(2)
2237 return
2238
2239 # Compute what was actually staged for reporting.
2240 staged_now = git_staged_files(repo_root)
2241 staged_within = [
2242 p for p in staged_now if _is_within_project(p, root_resolved)
2243 ]
2244 staged_strings = [to_project_relative(p, root) for p in staged_within]
2245
2246 operation = "all" if all_mode else "update" if update_mode else "add"
2247 post = _post_check_summary(root, octrc)
2248
2249 if json_mode:
2251 operation=operation, paths_staged=staged_strings,
2252 paths_skipped=skipped, post_check=post, exit_code=0,
2253 )
2254 else:
2255 if resolved_paths:
2256 for p in resolved_paths:
2257 click.echo(f"Staged {to_project_relative(p, root)}")
2258 elif update_mode:
2259 click.echo(
2260 f"Staged {len(staged_strings)} tracked modified file(s).",
2261 )
2262 elif all_mode:
2263 click.echo(
2264 f"Staged {len(staged_strings)} file(s) (including untracked).",
2265 )
2267
2268 _add_audit(ctx, exit_code=0, staged_count=len(staged_strings))
2269
2270
2271def _print_post_check_advisory(post: dict | None) -> None:
2272 """Print a single-line summary of the post-stage quality gate result."""
2273 if post is None:
2274 return
2275 if post["exit_code"] == 0:
2276 click.echo("Quality gate: PASS", err=True)
2277 else:
2278 click.echo(
2279 f"Quality gate: FAIL "
2280 f"(lint={post['lint_violations']} "
2281 f"format={post['format_violations']} "
2282 f"secrets={post['secrets_findings']}). "
2283 f"Run `oct git check --staged-only` for details.",
2284 err=True,
2285 )
2286
2287
2288# =====================================================================
2289# oct git ignore (Phase 4G — .gitignore manager)
2290# =====================================================================
2291
2292
2294 *,
2295 operation: str,
2296 patterns_added: list[str] | None = None,
2297 patterns_removed: list[str] | None = None,
2298 oct_managed: list[str] | None = None,
2299 other: list[str] | None = None,
2300 exit_code: int = 0,
2301 errors: list[str] | None = None,
2302) -> None:
2303 """Emit JSON for ``oct git ignore`` to stdout."""
2304 payload: dict = {
2305 "operation": operation,
2306 "exit_code": exit_code,
2307 "errors": errors or [],
2308 }
2309 if patterns_added is not None:
2310 payload["patterns_added"] = patterns_added
2311 if patterns_removed is not None:
2312 payload["patterns_removed"] = patterns_removed
2313 if oct_managed is not None:
2314 payload["oct_managed"] = oct_managed
2315 if other is not None:
2316 payload["other"] = other
2317 click.echo(json.dumps(payload, indent=2))
2318
2319
2321 ctx: click.Context,
2322 *,
2323 exit_code: int,
2324) -> None:
2325 """Populate the audit record for ``oct git ignore``."""
2326 record = ctx.obj.get("_audit_record", AuditRecord())
2327 record.timestamp = record.timestamp or _utc_now_iso()
2328 record.command = "oct git ignore"
2329 record.checks_passed = exit_code == 0
2330 record.exit_code = exit_code
2331 ctx.obj["_audit_record"] = record
2332
2333
2334# =====================================================================
2335# F1.4 ignore --scope helpers
2336# =====================================================================
2337
2338
2340 cli_scope: str | None,
2341 octrc: dict | None,
2342) -> str:
2343 """Apply scope precedence: CLI > octrc > 'nearest'.
2344
2345 Mirrors the precedence used by ``oct git status --scope``.
2346 """
2347 if cli_scope in ("workspace", "project", "nearest"):
2348 return cli_scope
2349 if isinstance(octrc, dict):
2350 git_cfg = octrc.get("git")
2351 if isinstance(git_cfg, dict):
2352 raw = git_cfg.get("ignore_default_scope")
2353 if raw in ("workspace", "project", "nearest"):
2354 return raw
2355 return "nearest"
2356
2357
2359 *,
2360 project_root: Path,
2361 repo_root: Path,
2362 scope: str,
2363) -> tuple[Path | None, str | None]:
2364 """Resolve the directory whose ``.gitignore`` should be edited.
2365
2366 Returns ``(target_dir, error_message)`` — exactly one is ``None``.
2367
2368 Scope semantics:
2369
2370 - ``workspace``: walk up from *project_root* to find a workspace
2371 manifest; the workspace root is the target. Errors when no
2372 manifest exists above CWD.
2373 - ``project``: the *project_root* itself.
2374 - ``nearest``: workspace root if *project_root* IS a workspace
2375 root (i.e. discovery resolved through the manifest fallback);
2376 otherwise *project_root* (a subproject or a single project).
2377 """
2378 from oct.core.workspace import (
2379 WORKSPACE_MANIFEST,
2380 find_workspace_root,
2381 )
2382 if scope == "workspace":
2383 ws_path = find_workspace_root(project_root)
2384 if ws_path is None:
2385 return None, (
2386 "scope=workspace requires a workspace manifest "
2387 f"({WORKSPACE_MANIFEST}) above CWD; none found"
2388 )
2389 return ws_path, None
2390 if scope == "project":
2391 return project_root, None
2392 # scope == "nearest"
2393 project_root_resolved = project_root.resolve()
2394 is_at_workspace_root = (
2395 project_root_resolved
2396 / WORKSPACE_MANIFEST
2397 ).is_file()
2398 if is_at_workspace_root:
2399 return project_root_resolved, None
2400 return project_root, None
2401
2402
2403# =====================================================================
2404# oct git restore (Phase 4G-followup L3)
2405# =====================================================================
2406
2407
2408def _restore_audit(ctx: click.Context, *, exit_code: int) -> None:
2409 """Populate the audit record for ``oct git restore``."""
2410 record = ctx.obj.get("_audit_record", AuditRecord())
2411 record.timestamp = record.timestamp or _utc_now_iso()
2412 record.command = "oct git restore"
2413 record.checks_passed = exit_code == 0
2414 record.exit_code = exit_code
2415 ctx.obj["_audit_record"] = record
2416
2417
2418@git.command(name="restore")
2419@click.argument("paths", nargs=-1, type=click.Path())
2420@click.option(
2421 "--staged", "staged_mode", is_flag=True,
2422 help="Unstage paths from the index (leaves working tree intact).",
2423)
2424@click.option(
2425 "--all", "-A", "all_mode", is_flag=True,
2426 help="Restore all in-scope paths (requires explicit confirmation when used with --staged).",
2427)
2428@click.option(
2429 "--yes", "skip_prompt", is_flag=True,
2430 help="Skip the confirmation prompt for --staged --all.",
2431)
2432@click.option(
2433 "--json", "json_mode", is_flag=True, help="Emit JSON output.",
2434)
2435@click.pass_context
2436@audited("oct git restore")
2438 ctx, paths, staged_mode, all_mode, skip_prompt, json_mode,
2439):
2440 """Restore working-tree edits or unstage paths.
2441
2442 Without ``--staged``: discards unstaged working-tree edits in
2443 *paths*, reverting them to the index (i.e. ``git restore <paths>``).
2444
2445 With ``--staged``: removes *paths* from the index without touching
2446 the working tree (i.e. ``git restore --staged <paths>``).
2447
2448 With ``--all``: scope is every modified file inside the OCT
2449 project root. ``--staged --all`` requires confirmation (irreversible
2450 destruction of all staged content); pass ``--yes`` to skip.
2451 """
2452 func = "git_restore_cmd"
2453 root = get_project_root(ctx)
2454 _dbg(
2455 "GIT", func,
2456 f"root={root} paths={paths} staged={staged_mode} "
2457 f"all={all_mode} json={json_mode}",
2458 2,
2459 )
2460
2461 from oct.core.git import (
2462 get_repo_root, is_git_repo, restore_paths,
2463 )
2464
2465 if not is_git_repo(root):
2466 click.echo("Not a git repository.", err=True)
2467 _restore_audit(ctx, exit_code=1)
2468 ctx.exit(1)
2469 return
2470
2471 repo_root = get_repo_root(root)
2472 root_resolved = root.resolve()
2473
2474 if not paths and not all_mode:
2475 msg = "Error: provide PATHS or use --all."
2476 click.echo(msg, err=True)
2477 if json_mode:
2478 click.echo(json.dumps({
2479 "operation": "restore",
2480 "paths_restored": [], "exit_code": 1, "errors": [msg],
2481 }, indent=2))
2482 _restore_audit(ctx, exit_code=1)
2483 ctx.exit(1)
2484 return
2485
2486 # Resolve and scope-filter explicit paths.
2487 skipped: list[dict] = []
2488 resolved_paths: list[Path] = []
2489 for raw in paths:
2490 candidate = Path(raw)
2491 if not candidate.is_absolute():
2492 candidate = (Path.cwd() / candidate).resolve()
2493 else:
2494 candidate = candidate.resolve()
2495 if not _is_within_project(candidate, root_resolved):
2496 skipped.append(
2497 {"path": str(raw), "reason": "outside project scope"},
2498 )
2499 continue
2500 resolved_paths.append(candidate)
2501
2502 if skipped and not json_mode:
2503 for entry in skipped:
2504 click.echo(
2505 f"Skipping {entry['path']} ({entry['reason']}).",
2506 err=True,
2507 )
2508
2509 # --all mode: enumerate the in-scope candidates from git.
2510 if all_mode:
2511 from oct.core.git import git_run as _gr
2512 if staged_mode:
2513 argv = ["diff", "--cached", "--name-only"]
2514 else:
2515 argv = ["diff", "--name-only"]
2516 proc = _gr(argv, cwd=repo_root)
2517 for line in proc.stdout.splitlines():
2518 rel = line.strip()
2519 if not rel:
2520 continue
2521 full = (repo_root / rel).resolve()
2522 if _is_within_project(full, root_resolved):
2523 resolved_paths.append(full)
2524
2525 if not resolved_paths:
2526 msg = (
2527 "Nothing to restore (no in-scope paths matched)."
2528 if all_mode else
2529 "Nothing to restore."
2530 )
2531 if json_mode:
2532 click.echo(json.dumps({
2533 "operation": "restore",
2534 "paths_restored": [], "exit_code": 0,
2535 "skipped": skipped, "errors": [],
2536 }, indent=2))
2537 else:
2538 click.echo(msg)
2539 _restore_audit(ctx, exit_code=0)
2540 return
2541
2542 # Confirmation prompt for --staged --all (irreversibly drops the
2543 # staged changes for every file in scope).
2544 if staged_mode and all_mode and not skip_prompt and not json_mode:
2545 click.echo(
2546 f"About to UNSTAGE {len(resolved_paths)} file(s). This "
2547 f"discards every staged change in the project scope and "
2548 f"cannot be undone. Continue? [y/N] ",
2549 nl=False, err=True,
2550 )
2551 try:
2552 answer = click.get_text_stream("stdin").readline().strip().lower()
2553 except (KeyboardInterrupt, OSError):
2554 answer = ""
2555 if answer not in ("y", "yes"):
2556 click.echo("Aborted.", err=True)
2557 _restore_audit(ctx, exit_code=1)
2558 ctx.exit(1)
2559 return
2560
2561 try:
2562 restore_paths(repo_root, resolved_paths, staged=staged_mode)
2563 except Exception as exc:
2564 msg = f"Git error: {exc}"
2565 click.echo(msg, err=True)
2566 if json_mode:
2567 click.echo(json.dumps({
2568 "operation": "restore",
2569 "paths_restored": [], "exit_code": 2,
2570 "errors": [msg],
2571 }, indent=2))
2572 _restore_audit(ctx, exit_code=2)
2573 ctx.exit(2)
2574 return
2575
2576 rel_strings = [to_project_relative(p, root) for p in resolved_paths]
2577
2578 if json_mode:
2579 click.echo(json.dumps({
2580 "operation": "restore",
2581 "staged": staged_mode,
2582 "paths_restored": rel_strings,
2583 "skipped": skipped,
2584 "exit_code": 0,
2585 "errors": [],
2586 }, indent=2))
2587 else:
2588 verb = "Unstaged" if staged_mode else "Restored"
2589 click.echo(f"{verb} {len(rel_strings)} file(s):")
2590 for rel in rel_strings:
2591 click.echo(f" {rel}")
2592 _restore_audit(ctx, exit_code=0)
2593
2594
2595@git.command(name="ignore")
2596@click.argument("patterns", nargs=-1)
2597@click.option(
2598 "--remove", "remove_pattern", default=None,
2599 help="Remove a pattern from the OCT-managed block.",
2600)
2601@click.option(
2602 "--list", "list_mode", is_flag=True,
2603 help="List currently active ignore patterns.",
2604)
2605@click.option(
2606 "--scope", "scope",
2607 type=click.Choice(["workspace", "project", "nearest"]),
2608 default=None,
2609 help=(
2610 "Which .gitignore to target: 'workspace' (workspace-root file), "
2611 "'project' (subproject-root file), 'nearest' (workspace if at "
2612 "workspace root, subproject otherwise; default)."
2613 ),
2614)
2615@click.option(
2616 "--json", "json_mode", is_flag=True, help="Emit JSON output.",
2617)
2618@click.pass_context
2619@audited("oct git ignore")
2621 ctx, patterns, remove_pattern, list_mode, scope, json_mode,
2622):
2623 """Manage .gitignore patterns within the OCT-managed block."""
2624 func = "git_ignore_cmd"
2625 root = get_project_root(ctx)
2626 _dbg(
2627 "GIT", func,
2628 f"root={root} patterns={patterns} "
2629 f"remove={remove_pattern!r} list={list_mode} scope={scope!r}",
2630 2,
2631 )
2632
2633 from oct.core.git import get_repo_root, is_git_repo
2634 from oct.git.policy import (
2635 add_ignore_patterns,
2636 list_ignore_patterns,
2637 remove_ignore_pattern,
2638 validate_ignore_pattern,
2639 )
2640
2641 if not is_git_repo(root):
2642 click.echo("Not a git repository.", err=True)
2643 _ignore_audit(ctx, exit_code=1)
2644 ctx.exit(1)
2645 return
2646
2647 repo_root = get_repo_root(root)
2648
2649 # F1.4: resolve which .gitignore to target.
2650 octrc = _load_octrc(root)
2651 effective_scope = _resolve_ignore_scope(scope, octrc)
2652 target_dir, scope_err = _resolve_ignore_target_dir(
2653 project_root=root,
2654 repo_root=repo_root,
2655 scope=effective_scope,
2656 )
2657 if scope_err is not None:
2658 click.echo(f"Error: {scope_err}", err=True)
2659 if json_mode:
2661 operation="ignore", exit_code=1, errors=[scope_err],
2662 )
2663 _ignore_audit(ctx, exit_code=1)
2664 ctx.exit(1)
2665 return
2666 _dbg(
2667 "GIT", func,
2668 f"effective_scope={effective_scope} target_dir={target_dir}",
2669 3,
2670 )
2671
2672 # -- Mutual exclusivity --------------------------------------------------
2673 op_count = sum([bool(patterns), bool(remove_pattern), bool(list_mode)])
2674 if op_count > 1:
2675 msg = "Error: PATTERNS, --remove, and --list are mutually exclusive."
2676 click.echo(msg, err=True)
2677 if json_mode:
2679 operation="ignore", exit_code=1, errors=[msg],
2680 )
2681 _ignore_audit(ctx, exit_code=1)
2682 ctx.exit(1)
2683 return
2684 if op_count == 0:
2685 msg = (
2686 "Use PATTERNS to add, --remove PATTERN to remove, "
2687 "or --list to show current patterns."
2688 )
2689 click.echo(msg, err=True)
2690 if json_mode:
2692 operation="ignore", exit_code=1, errors=[msg],
2693 )
2694 _ignore_audit(ctx, exit_code=1)
2695 ctx.exit(1)
2696 return
2697
2698 # -- --list --------------------------------------------------------------
2699 if list_mode:
2700 oct_managed, other = list_ignore_patterns(target_dir)
2701 if json_mode:
2703 operation="list", oct_managed=oct_managed,
2704 other=other, exit_code=0,
2705 )
2706 else:
2707 click.echo(
2708 f"OCT-managed patterns ({len(oct_managed)}):"
2709 )
2710 for p in oct_managed:
2711 click.echo(f" {p}")
2712 click.echo()
2713 click.echo(
2714 f"Other patterns ({len(other)}, read-only via oct):"
2715 )
2716 for p in other:
2717 click.echo(f" {p}")
2718 _ignore_audit(ctx, exit_code=0)
2719 return
2720
2721 # -- --remove ------------------------------------------------------------
2722 if remove_pattern:
2723 valid, val_msg = validate_ignore_pattern(remove_pattern)
2724 if not valid:
2725 click.echo(f"Error: {val_msg}", err=True)
2726 if json_mode:
2728 operation="remove", exit_code=1, errors=[val_msg],
2729 )
2730 _ignore_audit(ctx, exit_code=1)
2731 ctx.exit(1)
2732 return
2733 try:
2734 removed, msg = remove_ignore_pattern(target_dir, remove_pattern)
2735 except OSError as exc:
2736 err = f"IO error: {exc}"
2737 click.echo(err, err=True)
2738 if json_mode:
2740 operation="remove", exit_code=2, errors=[err],
2741 )
2742 _ignore_audit(ctx, exit_code=2)
2743 ctx.exit(2)
2744 return
2745 if not removed:
2746 click.echo(f"Error: {msg}", err=True)
2747 if json_mode:
2749 operation="remove", patterns_removed=[],
2750 exit_code=1, errors=[msg],
2751 )
2752 _ignore_audit(ctx, exit_code=1)
2753 ctx.exit(1)
2754 return
2755 if json_mode:
2757 operation="remove",
2758 patterns_removed=[remove_pattern], exit_code=0,
2759 )
2760 else:
2761 click.echo(f"Removed pattern '{remove_pattern}' from .gitignore.")
2762 _ignore_audit(ctx, exit_code=0)
2763 return
2764
2765 # -- Add patterns --------------------------------------------------------
2766 validated: list[str] = []
2767 errors: list[str] = []
2768 for pat in patterns:
2769 valid, val_msg = validate_ignore_pattern(pat)
2770 if not valid:
2771 errors.append(f"{pat!r}: {val_msg}")
2772 else:
2773 validated.append(pat)
2774
2775 if errors:
2776 for e in errors:
2777 click.echo(f"Error: {e}", err=True)
2778 if json_mode:
2780 operation="add", patterns_added=[],
2781 exit_code=1, errors=errors,
2782 )
2783 _ignore_audit(ctx, exit_code=1)
2784 ctx.exit(1)
2785 return
2786
2787 try:
2788 added = add_ignore_patterns(target_dir, validated)
2789 except OSError as exc:
2790 err = f"IO error: {exc}"
2791 click.echo(err, err=True)
2792 if json_mode:
2794 operation="add", exit_code=2, errors=[err],
2795 )
2796 _ignore_audit(ctx, exit_code=2)
2797 ctx.exit(2)
2798 return
2799
2800 if json_mode:
2802 operation="add", patterns_added=added, exit_code=0,
2803 )
2804 else:
2805 if added:
2806 click.echo(
2807 f"Added {len(added)} pattern(s) to .gitignore "
2808 f"(OCT-managed block):"
2809 )
2810 for p in added:
2811 click.echo(f" {p}")
2812 else:
2813 click.echo("All patterns already present in .gitignore.")
2814 _ignore_audit(ctx, exit_code=0)
2815
2816
2817# =====================================================================
2818# oct git commit-msg (OI-503 — entry for pre-commit-framework commit-msg stage)
2819# =====================================================================
2820
2821
2822@git.command(name="commit-msg")
2823@click.argument(
2824 "message_file",
2825 type=click.Path(exists=True, dir_okay=False, path_type=Path),
2826)
2827@click.option("--json", "json_mode", is_flag=True, help="Emit JSON result.")
2828@click.pass_context
2829def git_commit_msg_cmd(ctx: click.Context, message_file: Path, json_mode: bool) -> None:
2830 """Validate a Conventional Commit message file.
2831
2832 Intended as the entry for the ``commit-msg`` pre-commit-framework stage:
2833 ``pre-commit`` invokes ``oct git commit-msg <file>`` with the path to
2834 ``.git/COMMIT_EDITMSG``. Exits 0 on valid, 1 on invalid, 2 on I/O errors.
2835 """
2836 func = "git_commit_msg_cmd"
2837 _dbg("GIT", func, f"validating {message_file}", 2)
2838
2839 from oct.git.conventional import (
2840 format_validation_errors,
2841 validate_commit_message,
2842 )
2843
2844 try:
2845 text = message_file.read_text(encoding="utf-8")
2846 except OSError as exc:
2847 if json_mode:
2848 click.echo(
2849 json.dumps(
2850 {"valid": False, "errors": [f"cannot read {message_file}: {exc}"]},
2851 indent=2,
2852 )
2853 )
2854 else:
2855 click.echo(
2856 f"[oct git commit-msg] cannot read {message_file}: {exc}",
2857 err=True,
2858 )
2859 ctx.exit(2)
2860 return
2861
2862 errors = validate_commit_message(text)
2863 valid = not errors
2864
2865 if json_mode:
2866 click.echo(
2867 json.dumps({"valid": valid, "errors": errors}, indent=2)
2868 )
2869 else:
2870 if valid:
2871 click.echo(f"[oct git commit-msg] OK: {message_file.name}")
2872 else:
2873 click.echo(format_validation_errors(errors), err=True)
2874
2875 ctx.exit(0 if valid else 1)
2876
2877
2878# =====================================================================
2879# oct git hooks (Phase 4D — G-D8, G-D9, G-D10)
2880# =====================================================================
2881
2882
2883_ENHANCED_HOOKS_TEMPLATE = """\
2884# Option C pre-commit configuration
2885# Generated by: oct git hooks install
2886# See https://pre-commit.com for more information
2887
2888repos:
2889 - repo: local
2890 hooks:
2891 - id: oct-quality-gate
2892 name: Option C Quality Gate
2893 entry: oct git check --staged-only
2894 language: system
2895 types: [python]
2896 pass_filenames: false
2897
2898 - id: oct-secrets-check
2899 name: Option C Secrets Check
2900 entry: oct git check --staged-only --profile strict
2901 language: system
2902 always_run: true
2903 pass_filenames: false
2904
2905 - id: oct-commit-msg
2906 name: Option C Commit Message
2907 entry: oct git commit-msg
2908 language: system
2909 stages: [commit-msg]
2910
2911 - repo: local
2912 hooks:
2913 - id: oct-pre-push
2914 name: Option C Pre-Push Gate
2915 entry: oct git check --include-tests
2916 language: system
2917 stages: [pre-push]
2918 pass_filenames: false
2919"""
2920
2921# Hook IDs used to identify OCT hooks in a pre-commit config.
2922_OCT_HOOK_IDS = (
2923 "oct-quality-gate",
2924 "oct-secrets-check",
2925 "oct-commit-msg",
2926 "oct-pre-push",
2927 # Legacy hook IDs from oct install-hooks (Phase 4C and earlier).
2928 "oct-lint",
2929 "oct-format-check",
2930)
2931
2932
2933@git.group(name="hooks")
2934@click.pass_context
2936 """Manage Option C git hooks."""
2937 pass
2938
2939
2940@hooks_group.command(name="install")
2941@click.option("--force", is_flag=True, help="Overwrite existing configuration.")
2942@click.pass_context
2943@audited("oct git hooks install")
2944def hooks_install_cmd(ctx, force):
2945 """Generate .pre-commit-config.yaml with all Option C hooks."""
2946 func = "hooks_install_cmd"
2947 root = get_project_root(ctx)
2948 _dbg("GIT", func, f"root={root} force={force}", 2)
2949
2950 config_path = root / ".pre-commit-config.yaml"
2951 if config_path.exists() and not force:
2952 click.echo(
2953 f"Error: {config_path} already exists. Use --force to overwrite.",
2954 err=True,
2955 )
2956 record = ctx.obj.get("_audit_record", AuditRecord())
2957 record.timestamp = record.timestamp or _utc_now_iso()
2958 record.command = "oct git hooks install"
2959 record.checks_passed = False
2960 record.exit_code = 1
2961 ctx.obj["_audit_record"] = record
2962 ctx.exit(1)
2963 return
2964
2965 config_path.write_text(_ENHANCED_HOOKS_TEMPLATE, encoding="utf-8")
2966 click.echo(f"Created {config_path}")
2967 click.echo("Run 'pre-commit install' to activate the hooks.")
2968
2969 record = ctx.obj.get("_audit_record", AuditRecord())
2970 record.timestamp = record.timestamp or _utc_now_iso()
2971 record.command = "oct git hooks install"
2972 record.checks_passed = True
2973 record.exit_code = 0
2974 ctx.obj["_audit_record"] = record
2975
2976
2977@hooks_group.command(name="status")
2978@click.pass_context
2979@audited("oct git hooks status")
2981 """Show installed hook state."""
2982 func = "hooks_status_cmd"
2983 root = get_project_root(ctx)
2984 _dbg("GIT", func, f"root={root}", 2)
2985
2986 config_path = root / ".pre-commit-config.yaml"
2987 expected = ("oct-quality-gate", "oct-secrets-check", "oct-commit-msg", "oct-pre-push")
2988
2989 found: set[str] = set()
2990 if config_path.is_file():
2991 content = config_path.read_text(encoding="utf-8")
2992 for hook_id in expected:
2993 if f"id: {hook_id}" in content:
2994 found.add(hook_id)
2995
2996 for hook_id in expected:
2997 status = "[INSTALLED]" if hook_id in found else "[MISSING]"
2998 click.echo(f" {status} {hook_id}")
2999
3000 record = ctx.obj.get("_audit_record", AuditRecord())
3001 record.timestamp = record.timestamp or _utc_now_iso()
3002 record.command = "oct git hooks status"
3003 record.checks_passed = True
3004 record.exit_code = 0
3005 ctx.obj["_audit_record"] = record
3006
3007
3008@hooks_group.command(name="remove")
3009@click.pass_context
3010@audited("oct git hooks remove")
3012 """Remove Option C hooks from .pre-commit-config.yaml."""
3013 func = "hooks_remove_cmd"
3014 root = get_project_root(ctx)
3015 _dbg("GIT", func, f"root={root}", 2)
3016
3017 config_path = root / ".pre-commit-config.yaml"
3018 if not config_path.is_file():
3019 click.echo("No .pre-commit-config.yaml found.", err=True)
3020 record = ctx.obj.get("_audit_record", AuditRecord())
3021 record.timestamp = record.timestamp or _utc_now_iso()
3022 record.command = "oct git hooks remove"
3023 record.checks_passed = True
3024 record.exit_code = 0
3025 ctx.obj["_audit_record"] = record
3026 return
3027
3028 content = config_path.read_text(encoding="utf-8")
3029
3030 # Check if any non-OCT hooks exist.
3031 has_non_oct = False
3032 in_oct_hook = False
3033 filtered_lines: list[str] = []
3034 for line in content.splitlines():
3035 stripped = line.strip()
3036 # Detect OCT hook blocks by their id.
3037 if stripped.startswith("- id:"):
3038 hook_id = stripped.split(":", 1)[1].strip()
3039 in_oct_hook = hook_id in _OCT_HOOK_IDS
3040 if not in_oct_hook:
3041 has_non_oct = True
3042
3043 if in_oct_hook:
3044 # Skip lines belonging to OCT hooks.
3045 # The hook block ends at the next "- id:" or "- repo:" or end of file.
3046 if stripped.startswith("- id:") or stripped.startswith("- repo:"):
3047 # Re-evaluate: is this the start of a new hook or repo?
3048 if stripped.startswith("- id:"):
3049 hook_id = stripped.split(":", 1)[1].strip()
3050 in_oct_hook = hook_id in _OCT_HOOK_IDS
3051 if in_oct_hook:
3052 continue
3053 else:
3054 has_non_oct = True
3055 filtered_lines.append(line)
3056 in_oct_hook = False
3057 continue
3058 elif stripped.startswith("- repo:"):
3059 in_oct_hook = False
3060 filtered_lines.append(line)
3061 continue
3062 else:
3063 continue
3064 else:
3065 filtered_lines.append(line)
3066
3067 if not has_non_oct:
3068 # All hooks were OCT hooks — delete the file.
3069 config_path.unlink()
3070 click.echo("Removed .pre-commit-config.yaml (all hooks were Option C hooks).")
3071 else:
3072 # Rewrite with non-OCT hooks preserved.
3073 config_path.write_text("\n".join(filtered_lines) + "\n", encoding="utf-8")
3074 click.echo("Removed Option C hooks (non-OCT hooks preserved).")
3075
3076 record = ctx.obj.get("_audit_record", AuditRecord())
3077 record.timestamp = record.timestamp or _utc_now_iso()
3078 record.command = "oct git hooks remove"
3079 record.checks_passed = True
3080 record.exit_code = 0
3081 ctx.obj["_audit_record"] = record
3082
3083
3084# =====================================================================
3085# oct git changelog (Phase 4E — G-E2)
3086# =====================================================================
3087
3088
3089@git.command(name="changelog")
3090@click.option("--release", "version", default=None,
3091 help="Release version tag (e.g. v0.13.0).")
3092@click.option("--since", default=None,
3093 help="Git ref to start from (default: last tag).")
3094@click.option("--dry-run", is_flag=True,
3095 help="Print to stdout without writing CHANGELOG.md.")
3096@click.option("--json", "json_mode", is_flag=True,
3097 help="Emit structured JSON.")
3098@click.pass_context
3099@audited("oct git changelog")
3100def git_changelog_cmd(ctx, version, since, dry_run, json_mode):
3101 """Generate changelog from Conventional Commits history."""
3102 func = "git_changelog_cmd"
3103 root = get_project_root(ctx)
3104 _dbg("GIT", func, f"root={root} version={version!r} since={since!r}", 2)
3105
3106 from oct.git.changelog import (
3107 collect_changelog_entries,
3108 format_changelog,
3109 changelog_to_json,
3110 )
3111
3112 root = _require_git_repo(root)
3113
3114 entries = collect_changelog_entries(root, since_ref=since)
3115
3116 if json_mode:
3117 data = changelog_to_json(entries, version=version)
3118 click.echo(json.dumps(data, indent=2))
3119 record = ctx.obj.get("_audit_record", AuditRecord())
3120 record.timestamp = record.timestamp or _utc_now_iso()
3121 record.command = "oct git changelog"
3122 record.checks_passed = True
3123 record.exit_code = 0
3124 ctx.obj["_audit_record"] = record
3125 return
3126
3127 text = format_changelog(entries, version=version)
3128
3129 if dry_run or version is None:
3130 click.echo(text)
3131 else:
3132 # Prepend to CHANGELOG.md.
3133 changelog_path = root / "CHANGELOG.md"
3134 existing = ""
3135 if changelog_path.is_file():
3136 existing = changelog_path.read_text(encoding="utf-8")
3137 # Insert after the top-level heading if present, else prepend.
3138 if existing.startswith("# "):
3139 # Keep the top heading, insert after first blank line.
3140 first_newline = existing.find("\n\n")
3141 if first_newline >= 0:
3142 new_content = (
3143 existing[:first_newline + 2]
3144 + text + "\n"
3145 + existing[first_newline + 2:]
3146 )
3147 else:
3148 new_content = existing + "\n\n" + text + "\n"
3149 elif existing:
3150 new_content = text + "\n" + existing
3151 else:
3152 new_content = f"# Changelog\n\n{text}\n"
3153 changelog_path.write_text(new_content, encoding="utf-8")
3154 click.echo(f"Updated {to_project_relative(changelog_path, root)}")
3155
3156 record = ctx.obj.get("_audit_record", AuditRecord())
3157 record.timestamp = record.timestamp or _utc_now_iso()
3158 record.command = "oct git changelog"
3159 record.checks_passed = True
3160 record.exit_code = 0
3161 ctx.obj["_audit_record"] = record
3162
3163
3164# =====================================================================
3165# Helpers
3166# =====================================================================
3167
3168
3169def _require_git_repo(root: Path) -> Path:
3170 """Return the git repo root, or exit with a clear UX message.
3171
3172 - Git not installed: *"Git is not installed. Git-related features
3173 are disabled."*
3174 - Not a repo: *"Not a git repository. Run 'oct git init' to
3175 initialise."*
3176 """
3177 from oct.core.git import (
3178 is_git_repo, get_repo_root as _get_repo_root,
3179 GitNotFoundError, GitNotARepoError,
3180 )
3181 import shutil
3182 if shutil.which("git") is None:
3183 raise click.ClickException(
3184 "Git is not installed. Git-related features are disabled."
3185 )
3186 if not is_git_repo(root):
3187 raise click.ClickException(
3188 "Not a git repository. Run 'oct git init' to initialise."
3189 )
3190 try:
3191 return _get_repo_root(root)
3192 except (GitNotFoundError, GitNotARepoError) as exc:
3193 raise click.ClickException(str(exc))
3194
3195
3197 root: Path, staged: list[Path], octrc: dict | None,
3198) -> list[tuple[str, int]]:
3199 """Check staged files for size exceeding the threshold.
3200
3201 Returns a list of ``(rel_path, size_bytes)`` for oversized files.
3202 Threshold is configurable via ``octrc["git"]["large_file_threshold_mb"]``
3203 (default: 10 MB).
3204 """
3205 threshold = _LARGE_FILE_THRESHOLD_BYTES
3206 if isinstance(octrc, dict):
3207 git_cfg = octrc.get("git")
3208 if isinstance(git_cfg, dict):
3209 raw = git_cfg.get("large_file_threshold_mb")
3210 if isinstance(raw, (int, float)) and raw > 0:
3211 threshold = int(raw * 1024 * 1024)
3212
3213 oversized: list[tuple[str, int]] = []
3214 for path in staged:
3215 try:
3216 size = path.stat().st_size
3217 if size > threshold:
3218 rel = to_project_relative(path, root)
3219 oversized.append((rel, size))
3220 except OSError:
3221 continue
3222 return oversized
3223
3224
3225def _load_octrc(root: Path) -> dict | None:
3226 """Load ``.octrc.json`` if present, else return None.
3227
3228 Never raises — a malformed file is logged and treated as absent.
3229 """
3230 path = root / ".octrc.json"
3231 if not path.is_file():
3232 return None
3233 try:
3234 import json as _json
3235 return _json.loads(path.read_text(encoding="utf-8"))
3236 except (OSError, ValueError) as exc:
3237 _dbg("GIT", "_load_octrc", f"failed: {exc}", 1)
3238 return None
3239
3240
3241# ---------------------------------------------------------------------
3242# Auto-branch helpers (Phase 4F — G-F2)
3243# ---------------------------------------------------------------------
3244
3245
3246def _should_auto_branch(branch: str | None, octrc: dict | None) -> bool:
3247 """Return True when auto-branching is enabled for *branch*.
3248
3249 Auto-branching activates when the branch is protected and the
3250 ``git.auto_branch_on_protected`` setting is not explicitly ``False``
3251 (defaults to ``True``).
3252 """
3253 from oct.git.policy import is_protected_branch
3254
3255 if not is_protected_branch(branch, octrc):
3256 return False
3257 if isinstance(octrc, dict):
3258 git_cfg = octrc.get("git")
3259 if isinstance(git_cfg, dict):
3260 raw = git_cfg.get("auto_branch_on_protected")
3261 if isinstance(raw, bool):
3262 return raw
3263 return True # default: enabled
3264
3265
3266def _deduplicate_branch_name(root: Path, name: str) -> str:
3267 """Return *name* if it does not exist, otherwise append ``-2``, ``-3``, etc."""
3268 from oct.core.git import branch_exists
3269
3270 if not branch_exists(root, name):
3271 return name
3272 counter = 2
3273 while counter <= 100:
3274 candidate = f"{name}-{counter}"
3275 if not branch_exists(root, candidate):
3276 return candidate
3277 counter += 1
3278 # Extremely unlikely; surface as-is and let git error naturally.
3279 return f"{name}-{counter}"
3280
3281
3283 root: Path,
3284 original_branch: str,
3285 message: str,
3286 ctx: click.Context,
3287 json_mode: bool,
3288) -> str | None:
3289 """Create a feature branch from a commit message and switch to it.
3290
3291 Returns the new branch name on success, or ``None`` on failure
3292 (after emitting errors and populating the audit record).
3293 """
3294 func = "_auto_branch_for_commit"
3295 from oct.core.git import create_and_switch_branch
3296 from oct.git.policy import generate_branch_name_from_message
3297
3298 auto_name = generate_branch_name_from_message(message)
3299 if auto_name is None:
3300 msg = "Cannot auto-branch: failed to derive branch name from commit message."
3301 click.echo(msg, err=True)
3302 if json_mode:
3304 ctx, exit_code=1, message=message, branch=original_branch,
3305 profile="strict", errors=[msg],
3306 )
3307 _populate_commit_audit(ctx, branch=original_branch, exit_code=1,
3308 checks_passed=False, staged_count=0)
3309 ctx.exit(1)
3310 return None
3311
3312 final_name = _deduplicate_branch_name(root, auto_name)
3313
3314 try:
3315 create_and_switch_branch(root, final_name)
3316 except Exception as exc:
3317 msg = f"Auto-branch failed: {exc}"
3318 click.echo(msg, err=True)
3319 _dbg("GIT", func, msg, 1)
3320 if json_mode:
3322 ctx, exit_code=1, message=message, branch=original_branch,
3323 profile="strict", errors=[msg],
3324 )
3325 _populate_commit_audit(ctx, branch=original_branch, exit_code=1,
3326 checks_passed=False, staged_count=0)
3327 ctx.exit(1)
3328 return None
3329
3330 click.echo(
3331 f"Auto-created branch '{final_name}' from '{original_branch}'.",
3332 err=True,
3333 )
3334 _dbg("GIT", func, f"auto-branched to {final_name!r}", 2)
3335 return final_name
Definition git.py:1
str _resolve_observe_mode(dict|None octrc, bool ignore_unstaged_mutation)
Definition oct_git.py:1606
hooks_install_cmd(ctx, force)
Definition oct_git.py:2944
git_restore_cmd(ctx, paths, staged_mode, all_mode, skip_prompt, json_mode)
Definition oct_git.py:2439
_maybe_load_workspace(Path root)
Definition oct_git.py:148
None _populate_commit_audit(ctx, *, str|None branch, int exit_code, bool checks_passed, int staged_count, bool secrets=False, int lint_v=0, int format_v=0, list[str]|None tracked_mutations=None, list[dict]|None per_subproject_results=None)
Definition oct_git.py:1434
git_changelog_cmd(ctx, version, since, dry_run, json_mode)
Definition oct_git.py:3100
list[str] _create_gitattributes(Path root, bool dry_run)
Definition oct_git.py:896
None _restore_audit(click.Context ctx, *, int exit_code)
Definition oct_git.py:2408
None _emit_ignore_json(*, str operation, list[str]|None patterns_added=None, list[str]|None patterns_removed=None, list[str]|None oct_managed=None, list[str]|None other=None, int exit_code=0, list[str]|None errors=None)
Definition oct_git.py:2302
bool _should_auto_branch(str|None branch, dict|None octrc)
Definition oct_git.py:3246
Path _require_git_repo(Path root)
Definition oct_git.py:3169
git_status_cmd(ctx, json_mode, no_check, scope)
Definition oct_git.py:391
None _emit_commit_json(ctx, *, int exit_code, str message, str|None branch, str profile, list[str]|None errors=None, list|None secrets=None)
Definition oct_git.py:1412
list[tuple[str, int]] _check_large_staged_files(Path root, list[Path] staged, dict|None octrc)
Definition oct_git.py:3198
None _emit_observer_block(ctx, *, list[str] mutations, str message, str|None branch, str profile, bool json_mode, int staged_count)
Definition oct_git.py:1680
list[Path] _get_modified_unstaged(Path root)
Definition oct_git.py:335
str|None _safe_relative(Path path, Path root)
Definition oct_git.py:1626
None _add_audit(click.Context ctx, *, int exit_code, int staged_count=0)
Definition oct_git.py:1951
list[Path] _get_untracked_files(Path root)
Definition oct_git.py:318
dict _build_workspace_view(*, workspace, list[Path] staged, list[Path] modified, list[Path] untracked, bool no_check)
Definition oct_git.py:200
list[str] _merge_ignore_file(Path path, tuple[str,...] entries, bool dry_run)
Definition oct_git.py:849
None _advisory_checks(Path root, list[Path] staged, str message, bool err=True)
Definition oct_git.py:1707
dict|None _post_check_summary(Path root, dict|None octrc)
Definition oct_git.py:1965
str _resolve_ignore_scope(str|None cli_scope, dict|None octrc)
Definition oct_git.py:2342
git_ignore_cmd(ctx, patterns, remove_pattern, list_mode, scope, json_mode)
Definition oct_git.py:2622
None _ignore_audit(click.Context ctx, *, int exit_code)
Definition oct_git.py:2324
None _emit_branch_json(*, str operation="", str branch="", int exit_code=0, list[str]|None errors=None)
Definition oct_git.py:1895
list[str] _check_unstaged_mutation(Path root, dict[str, str] pre_snapshot, list[Path] staged, str stage_label)
Definition oct_git.py:1640
git_add_cmd(ctx, paths, patch_mode, update_mode, all_mode, dry_run, json_mode)
Definition oct_git.py:2033
None _branch_audit(click.Context ctx, *, int exit_code, str branch="")
Definition oct_git.py:1910
hooks_remove_cmd(ctx)
Definition oct_git.py:3011
git_branch_cmd(ctx, create_name, switch_name, json_mode)
Definition oct_git.py:1745
git_commit_cmd(ctx, messages, force_commit, no_verify, profile, json_mode, ignore_unstaged_mutation, extra_args)
Definition oct_git.py:1064
hooks_status_cmd(ctx)
Definition oct_git.py:2980
git_init_cmd(ctx, dry_run, github, no_hooks)
Definition oct_git.py:920
dict[str, list[Path]] _group_files_by_subproject(list[Path] staged, workspace)
Definition oct_git.py:1467
git_check_cmd(ctx, staged_only, check_all, include_tests, fix, profile, json_mode, sandbox)
Definition oct_git.py:692
str _badge(bool|None compliant)
Definition oct_git.py:127
tuple[bool, list[str]] _quick_check_file(Path path, Path project_root)
Definition oct_git.py:85
tuple[Path|None, str|None] _resolve_ignore_target_dir(*, Path project_root, Path repo_root, str scope)
Definition oct_git.py:2363
None git_commit_msg_cmd(click.Context ctx, Path message_file, bool json_mode)
Definition oct_git.py:2829
str _deduplicate_branch_name(Path root, str name)
Definition oct_git.py:3266
list[dict] _build_per_subproject_audit(dict per_sp_results, dict per_sp_meta)
Definition oct_git.py:1588
dict|None _load_octrc(Path root)
Definition oct_git.py:3225
str _resolve_subproject_profile(subproject, dict|None sp_octrc, str|None cli_override, str|None branch)
Definition oct_git.py:1504
tuple[int, int] _get_ahead_behind(Path root)
Definition oct_git.py:352
None _print_post_check_advisory(dict|None post)
Definition oct_git.py:2271
bool _generate_github_workflow(Path root, bool dry_run)
Definition oct_git.py:902
bool _is_within_project(Path path, Path project_root_resolved)
Definition oct_git.py:134
_merge_quality_gate_results(dict[str, "QualityGateResult"] per_sp)
Definition oct_git.py:1547
None _emit_add_json(*, str operation, list[str] paths_staged, list[dict] paths_skipped, dict|None post_check, int exit_code, list[str]|None errors=None)
Definition oct_git.py:1934
str|None _auto_branch_for_commit(Path root, str original_branch, str message, click.Context ctx, bool json_mode)
Definition oct_git.py:3288