oct
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- Plan 199: every octrc read in this module goes through
38 :func:`oct.core.octrc.load_octrc_safe`, the same loader the lint,
39 secrets and formatter passes use. It resolves ``.option_c/octrc.json``
40 first and falls back to the legacy ``<root>/.octrc.json`` (FS-539), so
41 a migrated project is never invisible to the git layer. Loading a
42 second way here is what made a subproject's config unreadable on the
43 commit path; do not reintroduce a local reader.
44- The loader returns ``{}`` (never ``None``) when no config exists. Every
45 consumer in this module guards with ``isinstance(octrc, dict)`` or
46 ``(octrc or {})``, both of which treat ``{}`` exactly as they treated
47 the old ``None``.
48
49Dependencies
50------------
51- oct.core.diagnostics (_dbg structured logger)
52- oct.core.octrc (the ONE octrc loader - see the contract above)
53- oct.core.paths (project-relative path helper)
54- oct.core.project_root (project root detection)
55- oct.git.audit (AuditRecord, audited decorator)
56- click (CLI framework)
57- oct.git.policy (the .gitignore marker fences, the profile exit-code policy and the branch and ignore-pattern rules)
58- oct.core.exclusions (lazy; GIT_IGNORE_DEFAULTS - the ignore patterns oct git init writes)
59- oct.core.git (lazy; git_run, current_branch, get_repo_root and the other git primitives the commands use)
60- oct.core.workspace (lazy; manifest discovery and subproject bucketing for the workspace fan-out)
61- oct.git.changelog (lazy; collect_changelog_entries and format_changelog for oct git changelog)
62- oct.git.commit_observer (lazy; unstaged-mutation snapshots taken around the git commit call)
63- oct.git.conventional (lazy; commit message validation and diagnostics-impact analysis)
64- oct.git.quality_gate (lazy; run_quality_gate and the secrets scan - the lint, format and secrets gate)
65- oct.hooks.oct_hooks (lazy; install_hooks for oct git init)
66- oct.linter.oct_lint (lazy; _lint_single_file - the quick header and docstring check for status)
67"""
68
69from __future__ import annotations
70
71import json
72import sys
73from dataclasses import dataclass
74from pathlib import Path
75
76import click
77
78from oct.core.diagnostics import _dbg
79from oct.core.octrc import load_octrc_safe
80from oct.core.paths import to_project_relative
81from oct.core.project_root import get_project_root, find_project_root
82from oct.git.audit import AuditRecord, audited, _utc_now_iso
83
84
85# =====================================================================
86# Constants
87# =====================================================================
88
89_LARGE_FILE_THRESHOLD_BYTES: int = 10 * 1024 * 1024 # 10 MB
90
91
92# =====================================================================
93# Git command group
94# =====================================================================
95
96
97@click.group()
98@click.pass_context
99def git(ctx: click.Context) -> None:
100 """Git integration with Option C compliance checks."""
101 pass
102
103
104# =====================================================================
105# oct git status
106# =====================================================================
107
108
109def _quick_check_file(path: Path, project_root: Path) -> tuple[bool, list[str]]:
110 """Run a minimal lint check (header + docstring only) on a Python file.
111
112 Returns ``(compliant, issues_list)``. This is the "quick check" from
113 blueprint §5.2 - not the full gate, just enough for status badges.
114 """
115 from oct.linter.oct_lint import LinterContext, _lint_single_file
116
117 ctx = LinterContext(
118 project_root=project_root,
119 project_name=project_root.name,
120 diagnostics_dir=project_root / "oc_diagnostics",
121 tests_dir=project_root / "tests",
122 docs_dir=project_root / "docs",
123 )
124 # Quick-check rules: only header and docstring.
125 quick_rules = {
126 "header": True,
127 "docstring": True,
128 "diagnostics_profile": False,
129 "dbg_usage": False,
130 "dbg_import": True,
131 "func_pattern": False,
132 "tests_exist": False,
133 "type_hints": False,
134 "no_hardcoded_secrets": False,
135 "dbg_assert_safety": False,
136 # Advisory and never read here; omitted rules default to on.
137 "dependencies_match": False,
138 }
139 try:
140 result = _lint_single_file(path, ctx, quick_rules, False)
141 issues = []
142 detail = result.get("json_detail", {}).get("checks", {})
143 for check_name in ("header", "docstring", "dbg_import"):
144 info = detail.get(check_name, {})
145 if not info.get("pass", True):
146 msg = info.get("message", check_name)
147 issues.append(msg)
148 return result.get("compliant", True), issues
149 except Exception:
150 return True, [] # If we can't check, don't penalise
151
152
153def _badge(compliant: bool | None) -> str:
154 """Return ``[OK]``, ``[!!]``, or ``---`` for status rendering."""
155 if compliant is None:
156 return "---"
157 return "[OK]" if compliant else "[!!]"
158
159
160def _is_within_project(path: Path, project_root_resolved: Path) -> bool:
161 """Return ``True`` if *path* is inside the OCT project root.
162
163 Used by ``git_status_cmd`` to filter git results down to the
164 project scope in monorepo layouts where the git repo root is an
165 ancestor of the OCT project root.
166 """
167 try:
168 path.resolve().relative_to(project_root_resolved)
169 return True
170 except ValueError:
171 return False
172
173
174def _maybe_load_workspace(root: Path):
175 """Probe for workspace mode and load the manifest if applicable.
176
177 F1.2 helper. Returns ``(workspace, in_workspace_mode)``:
178
179 - ``in_workspace_mode = True`` when *root* is itself the workspace
180 root (the directory containing ``.option_c_workspace.json``).
181 This activates per-subproject fan-out.
182 - ``in_workspace_mode = False`` when invocation came from inside a
183 subproject (project-root discovery resolved to that subproject
184 first), even if a workspace manifest exists higher up. This
185 preserves backwards-compat byte-for-byte for single-project
186 callers.
187
188 Returns ``(None, False)`` on any load failure; the caller falls
189 back to the legacy single-project rendering path.
190 """
191 func = "_maybe_load_workspace"
192 try:
193 from oct.core.workspace import (
194 find_workspace_root,
195 load_workspace,
196 )
197 except ImportError:
198 return None, False
199
200 ws_path = find_workspace_root(root)
201 if ws_path is None:
202 return None, False
203 in_workspace_mode = ws_path.resolve() == root.resolve()
204 if not in_workspace_mode:
205 return None, False
206 try:
207 ws = load_workspace(ws_path)
208 except Exception as exc:
209 _dbg(
210 "GIT", func,
211 f"workspace manifest at {ws_path} failed to load: {exc!r}; "
212 f"falling back to single-project mode",
213 1, override=True,
214 )
215 return None, False
216 return ws, True
217
218
220 *,
221 workspace,
222 staged: list[Path],
223 modified: list[Path],
224 untracked: list[Path],
225 no_check: bool,
226) -> dict:
227 """Group files into per-subproject + workspace buckets for F1.2.
228
229 Returns a dict keyed by subproject name, plus ``"workspace"`` for
230 files matching ``workspace.workspace_files`` and
231 :data:`UNSCOPED_BUCKET` for everything else; each value is
232 ``{"staged": [...], "modified": [...], "untracked": [...]}`` of
233 annotated entries.
234
235 Plan 213 / D-E: this view used to DROP files matching no subproject
236 and no ``workspace_files`` glob. That was the pre-208 silent-drop
237 shape surviving in the advisory view - incoherent with the commit
238 gate's partition invariant, and misleading in exactly the case that
239 matters, since a file the user cannot see in any section is the one
240 they are most likely to be surprised by at commit time. Unmatched
241 files now render in an ``__unscoped__`` section, under the same
242 workspace-root lens the gate uses for that bucket.
243 """
244 from oct.core.workspace import (
245 assign_to_subproject,
246 matches_workspace_files,
247 )
248
249 workspace_root_resolved = workspace.root.resolve()
250
251 buckets: dict[str, dict[str, list[dict]]] = {
252 sp.name: {"staged": [], "modified": [], "untracked": []}
253 for sp in workspace.subprojects
254 }
255 buckets["workspace"] = {
256 "staged": [], "modified": [], "untracked": [],
257 }
258 buckets[UNSCOPED_BUCKET] = {
259 "staged": [], "modified": [], "untracked": [],
260 }
261
262 def _annotate_for_bucket(
263 path: Path, bucket_root: Path,
264 ) -> dict:
265 """Annotate *path* using *bucket_root* for relative-path display."""
266 try:
267 rel = path.resolve().relative_to(bucket_root.resolve()).as_posix()
268 except ValueError:
269 rel = path.as_posix()
270 if path.suffix != ".py":
271 return {"path": rel, "status": "non_python", "issues": []}
272 if no_check:
273 return {"path": rel, "status": "unchecked", "issues": []}
274 compliant, issues = _quick_check_file(path, bucket_root)
275 return {
276 "path": rel,
277 "status": "ok" if compliant else "violations",
278 "issues": issues,
279 }
280
281 def _annotate_workspace(path: Path) -> dict:
282 """Workspace-bucket annotation: path rendered repo-relative,
283 compliance not quick-checked (workspace files are usually
284 non-Python - README, .gitignore, etc.)."""
285 try:
286 rel = path.resolve().relative_to(
287 workspace_root_resolved,
288 ).as_posix()
289 except ValueError:
290 rel = path.as_posix()
291 if path.suffix != ".py":
292 return {"path": rel, "status": "non_python", "issues": []}
293 if no_check:
294 return {"path": rel, "status": "unchecked", "issues": []}
295 compliant, issues = _quick_check_file(path, workspace.root)
296 return {
297 "path": rel,
298 "status": "ok" if compliant else "violations",
299 "issues": issues,
300 }
301
302 def _route(path: Path, kind: str) -> None:
303 sp = assign_to_subproject(path, workspace)
304 if sp is not None:
305 buckets[sp.name][kind].append(
306 _annotate_for_bucket(path, sp.path),
307 )
308 return
309 # Not in any subproject; consult workspace_files.
310 try:
311 rel = path.resolve().relative_to(
312 workspace_root_resolved,
313 ).as_posix()
314 except ValueError:
315 rel = path.as_posix()
316 if matches_workspace_files(rel, workspace.workspace_files):
317 buckets["workspace"][kind].append(_annotate_workspace(path))
318 else:
319 # Plan 213 / D-E: no longer dropped. Same workspace-root lens
320 # the commit gate applies to its own __unscoped__ bucket.
321 buckets[UNSCOPED_BUCKET][kind].append(_annotate_workspace(path))
322
323 for p in staged:
324 _route(p, "staged")
325 for p in modified:
326 _route(p, "modified")
327 # Untracked files should also bucket via the same logic so the user
328 # sees per-subproject context for new files.
329 for p in untracked:
330 sp = assign_to_subproject(p, workspace)
331 if sp is not None:
332 try:
333 rel = p.resolve().relative_to(sp.path).as_posix()
334 except ValueError:
335 rel = p.as_posix()
336 buckets[sp.name]["untracked"].append({
337 "path": rel, "status": "untracked", "issues": [],
338 })
339 else:
340 try:
341 rel = p.resolve().relative_to(
342 workspace_root_resolved,
343 ).as_posix()
344 except ValueError:
345 rel = p.as_posix()
346 target = (
347 "workspace"
348 if matches_workspace_files(rel, workspace.workspace_files)
349 else UNSCOPED_BUCKET
350 )
351 buckets[target]["untracked"].append({
352 "path": rel, "status": "untracked", "issues": [],
353 })
354
355 # Plan 213 / D-E: the unscoped bucket is emitted ONLY when it holds
356 # something. That keeps the change strictly additive - a workspace
357 # where every file is owned or declared produces byte-identical JSON
358 # to before - while the files that used to vanish now have a home.
359 if not any(buckets[UNSCOPED_BUCKET].values()):
360 del buckets[UNSCOPED_BUCKET]
361
362 return buckets
363
364
365def _get_untracked_files(root: Path) -> list[Path]:
366 """Return untracked files (not ignored) via ``git ls-files``."""
367 func = "_get_untracked_files"
368 _dbg("GIT", func, f"root={root}", 3)
369 from oct.core.git import git_run
370 proc = git_run(
371 ["ls-files", "--others", "--exclude-standard"],
372 cwd=root,
373 )
374 result = []
375 for line in proc.stdout.splitlines():
376 line = line.strip()
377 if line:
378 result.append((root / line).resolve())
379 return result
380
381
382def _get_modified_unstaged(root: Path) -> list[Path]:
383 """Return tracked files with unstaged changes."""
384 func = "_get_modified_unstaged"
385 _dbg("GIT", func, f"root={root}", 3)
386 from oct.core.git import git_run
387 proc = git_run(
388 ["diff", "--name-only", "--diff-filter=ACMR"],
389 cwd=root,
390 )
391 result = []
392 for line in proc.stdout.splitlines():
393 line = line.strip()
394 if line:
395 result.append((root / line).resolve())
396 return result
397
398
399def _get_ahead_behind(root: Path) -> tuple[int, int]:
400 """Return ``(ahead, behind)`` counts relative to upstream.
401
402 Returns ``(0, 0)`` if there is no upstream or if git fails.
403 """
404 from oct.core.git import git_run
405 proc = git_run(
406 ["rev-list", "--count", "--left-right", "@{upstream}...HEAD"],
407 cwd=root,
408 check=False,
409 )
410 if proc.returncode != 0:
411 return (0, 0)
412 parts = proc.stdout.strip().split()
413 if len(parts) == 2:
414 try:
415 return int(parts[1]), int(parts[0])
416 except ValueError:
417 pass
418 return (0, 0)
419
420
421@git.command(name="status")
422@click.option("--json", "json_mode", is_flag=True, help="Emit JSON output.")
423@click.option(
424 "--no-check", is_flag=True,
425 help="Skip compliance annotations (plain file list).",
426)
427@click.option(
428 "--scope", "scope",
429 type=click.Choice(["project", "repo", "auto"]),
430 default=None,
431 help=(
432 "Scope of files to show: project (default), repo (whole git "
433 "repo), auto (project unless project_root != repo_root)."
434 ),
435)
436@click.pass_context
437@audited("oct git status")
439 ctx: click.Context,
440 json_mode: bool,
441 no_check: bool,
442 scope: str | None,
443) -> None:
444 """Show git status with Option C compliance annotations."""
445 func = "git_status_cmd"
446 root = get_project_root(ctx)
447 _dbg(
448 "GIT", func,
449 f"root={root} json={json_mode} no_check={no_check} scope={scope!r}",
450 2,
451 )
452
453 from oct.core.git import (
454 current_branch,
455 get_repo_root,
456 git_staged_files,
457 is_detached_head,
458 is_git_repo,
459 )
460
461 if not is_git_repo(root):
462 click.echo("Not a git repository.", err=True)
463 ctx.exit(1)
464 return
465
466 # Git returns paths relative to the repo root, which may differ from
467 # the OCT project root in monorepo layouts. Use the actual git repo
468 # root for file-listing calls so paths resolve correctly, and keep
469 # ``root`` (project root) for compliance checks and display.
470 repo_root = get_repo_root(root)
471 _dbg("GIT", func, f"repo_root={repo_root}", 3)
472
473 # F1.2: detect workspace mode. Mode activates only when the project
474 # root resolved to the workspace root itself - invocation from
475 # inside a subproject keeps the legacy single-project rendering.
476 workspace, in_workspace_mode = _maybe_load_workspace(root)
477 _dbg(
478 "GIT", func,
479 f"workspace_mode={in_workspace_mode} "
480 f"subprojects={(len(workspace.subprojects) if workspace else 0)}",
481 3,
482 )
483
484 # Resolve effective scope: CLI > octrc > "project".
485 if scope is None:
486 octrc = load_octrc_safe(root)
487 if isinstance(octrc, dict):
488 git_cfg = octrc.get("git")
489 if isinstance(git_cfg, dict):
490 raw = git_cfg.get("status_default_scope")
491 if isinstance(raw, str) and raw in ("project", "repo", "auto"):
492 scope = raw
493 if scope is None:
494 scope = "project"
495
496 root_resolved = root.resolve()
497 repo_root_resolved = repo_root.resolve()
498
499 # ``auto`` collapses to ``project`` when project root == repo root.
500 effective_scope = scope
501 if scope == "auto":
502 effective_scope = (
503 "project" if root_resolved == repo_root_resolved else "repo"
504 )
505 _dbg("GIT", func, f"effective_scope={effective_scope}", 3)
506
507 branch = current_branch(repo_root)
508 detached = is_detached_head(repo_root)
509 ahead, behind = _get_ahead_behind(repo_root)
510 staged = git_staged_files(repo_root)
511 modified = _get_modified_unstaged(repo_root)
512 untracked = _get_untracked_files(repo_root)
513
514 # Partition into in-project and out-of-project so the renderer can
515 # show them separately when scope=repo or scope=auto-monorepo.
516 def _partition(files: list[Path]) -> tuple[list[Path], list[Path]]:
517 in_proj: list[Path] = []
518 out_proj: list[Path] = []
519 for p in files:
520 if _is_within_project(p, root_resolved):
521 in_proj.append(p)
522 else:
523 out_proj.append(p)
524 return in_proj, out_proj
525
526 staged_in, staged_out = _partition(staged)
527 modified_in, modified_out = _partition(modified)
528 untracked_in, untracked_out = _partition(untracked)
529
530 # Deduplicate: same-file appearing in both staged and modified is
531 # an artefact of path-resolution noise; mirror real git semantics
532 # by keeping it in staged only (real "modified" entries have
533 # additional unstaged hunks which the user will see on diff).
534 staged_in_set = {p.resolve() for p in staged_in}
535 modified_in = [p for p in modified_in if p.resolve() not in staged_in_set]
536 staged_out_set = {p.resolve() for p in staged_out}
537 modified_out = [p for p in modified_out if p.resolve() not in staged_out_set]
538
539 # Apply scope to determine what to render.
540 show_out_of_project = effective_scope == "repo"
541
542 # -- Build annotated file lists --
543 def annotate(files: list[Path]) -> list[dict]:
544 annotated = []
545 for path in files:
546 rel = to_project_relative(path, root)
547 if path.suffix != ".py":
548 annotated.append({
549 "path": rel, "status": "non_python", "issues": [],
550 })
551 elif no_check:
552 annotated.append({
553 "path": rel, "status": "unchecked", "issues": [],
554 })
555 else:
556 compliant, issues = _quick_check_file(path, root)
557 annotated.append({
558 "path": rel,
559 "status": "ok" if compliant else "violations",
560 "issues": issues,
561 })
562 return annotated
563
564 def annotate_out(files: list[Path]) -> list[dict]:
565 """Out-of-project files: render with repo-relative path, unchecked."""
566 out: list[dict] = []
567 for path in files:
568 try:
569 rel = path.resolve().relative_to(repo_root_resolved).as_posix()
570 except ValueError:
571 rel = path.as_posix()
572 out.append({"path": rel, "status": "unchecked", "issues": []})
573 return out
574
575 staged_ann = annotate(staged_in)
576 modified_ann = annotate(modified_in)
577 untracked_ann = [
578 {"path": to_project_relative(p, root),
579 "status": "untracked", "issues": []}
580 for p in untracked_in
581 ]
582
583 out_of_project_ann: list[dict] = []
584 if show_out_of_project:
585 for entry in annotate_out(staged_out):
586 entry["section"] = "staged"
587 out_of_project_ann.append(entry)
588 for entry in annotate_out(modified_out):
589 entry["section"] = "modified"
590 out_of_project_ann.append(entry)
591 for path in untracked_out:
592 try:
593 rel = path.resolve().relative_to(repo_root_resolved).as_posix()
594 except ValueError:
595 rel = path.as_posix()
596 out_of_project_ann.append({
597 "path": rel, "status": "untracked",
598 "issues": [], "section": "untracked",
599 })
600
601 # -- F1.2 workspace_view: per-subproject + workspace buckets --
602 workspace_view: dict | None = None
603 if in_workspace_mode and workspace is not None and workspace.subprojects:
604 workspace_view = _build_workspace_view(
605 workspace=workspace,
606 staged=staged_in,
607 modified=modified_in,
608 untracked=untracked_in,
609 no_check=no_check,
610 )
611
612 # -- Populate audit record --
613 record = ctx.obj.get("_audit_record", AuditRecord())
614 record.timestamp = record.timestamp or _utc_now_iso()
615 record.command = "oct git status"
616 record.branch = branch if not detached else None
617 record.staged_files = len(staged_in)
618 record.checks_passed = True
619 record.exit_code = 0
620 ctx.obj["_audit_record"] = record
621
622 # -- Render output --
623 if json_mode:
624 output = {
625 "branch": branch if not detached else None,
626 "detached": detached,
627 "ahead": ahead,
628 "behind": behind,
629 "scope": effective_scope,
630 "staged": staged_ann,
631 "modified": modified_ann,
632 "untracked": untracked_ann,
633 "out_of_project": out_of_project_ann,
634 "workspace_view": workspace_view,
635 }
636 click.echo(json.dumps(output, indent=2, ensure_ascii=False))
637 return
638
639 # Terminal output (blueprint §5.2 format).
640 if detached:
641 click.echo("HEAD detached")
642 else:
643 click.echo(f"On branch {branch}")
644 if ahead > 0 or behind > 0:
645 parts = []
646 if ahead > 0:
647 parts.append(f"ahead by {ahead}")
648 if behind > 0:
649 parts.append(f"behind by {behind}")
650 click.echo(f" {', '.join(parts)}")
651 click.echo()
652
653 def _render_section(label: str, items: list[dict]):
654 if not items:
655 return
656 click.echo(f"{label} ({len(items)} file{'s' if len(items) != 1 else ''}):")
657 for item in items:
658 status = item["status"]
659 path = item["path"]
660 if status == "ok":
661 badge = " [OK] "
662 elif status == "violations":
663 issues_str = " (" + ", ".join(item["issues"]) + ")" if item["issues"] else ""
664 click.echo(f" [!!] {path}{issues_str}")
665 continue
666 elif status == "non_python":
667 badge = " --- "
668 elif status == "unchecked":
669 badge = " "
670 else:
671 badge = " "
672 click.echo(f"{badge}{path}")
673 click.echo()
674
675 if workspace_view is not None:
676 # F1.2: workspace mode - render one block per subproject + a
677 # workspace-level block for files matching workspace_files[].
678 for bucket_name in (
679 *(sp.name for sp in workspace.subprojects),
680 "workspace",
681 UNSCOPED_BUCKET,
682 ):
683 bucket = workspace_view.get(bucket_name, {})
684 staged_b = bucket.get("staged", [])
685 modified_b = bucket.get("modified", [])
686 untracked_b = bucket.get("untracked", [])
687 if not (staged_b or modified_b or untracked_b):
688 continue
689 click.echo(f"=== {bucket_name} ===")
690 _render_section("Staged", staged_b)
691 _render_section("Modified", modified_b)
692 _render_section("Untracked", untracked_b)
693 else:
694 _render_section("Staged", staged_ann)
695 _render_section("Modified", modified_ann)
696 _render_section("Untracked", untracked_ann)
697 if show_out_of_project and out_of_project_ann:
698 click.echo(
699 f"Other repo files ({len(out_of_project_ann)} "
700 f"file{'s' if len(out_of_project_ann) != 1 else ''}, "
701 f"outside OCT project):"
702 )
703 for item in out_of_project_ann:
704 section = item.get("section", "?")
705 click.echo(f" [{section[:3]}] {item['path']}")
706 click.echo()
707
708
709# =====================================================================
710# oct git check
711# =====================================================================
712
713
714#: D-C refusal text for ``oct git check --fix`` at a workspace root.
715#: Fix-mode through ``run_quality_gate_on_files`` is unexercised in
716#: fan-out, and the formatter has a known multi-line-``def`` fixer
717#: landmine. A loud refusal is coherent; a silently different - or
718#: destructive - verdict is not.
719_FANOUT_FIX_REFUSAL: str = (
720 "Refusing to run --fix across the workspace.\n"
721 "Fix mode is supported per subproject only. Run it from the "
722 "subproject you want to fix, for example:\n"
723 " cd {example}\n"
724 " oct git check --fix\n"
725 "Without --fix, this command checks the whole workspace."
726)
727
728
730 ctx: click.Context,
731 *,
732 workspace,
733 scope: str,
734 cli_profile: str | None,
735 fallback_profile: str,
736 branch: str | None,
737 include_tests: bool,
738 fix: bool,
739 json_mode: bool,
740) -> int:
741 """Render ``oct git check`` in workspace fan-out mode (plan 213 / D-B).
742
743 Resolves the scope from ``workspace.root`` with the SAME helper the
744 commit gate uses (:func:`oct.git.quality_gate._resolve_scope`), gates
745 it through :func:`_run_fanout_gate`, renders one section per non-empty
746 bucket (mirroring ``oct git status``), and returns the worst-wins exit
747 code.
748
749 ``--fix`` refuses loudly here (D-C). JSON output keeps the legacy
750 top-level shape, computed from the merged result, and ADDS a
751 ``per_subproject`` array - the same precedent ``oct git status`` set
752 with ``workspace_view``.
753 """
754 func = "_git_check_fanout"
755 from oct.git.quality_gate import _resolve_scope
756
757 if fix:
758 example = (
759 workspace.subprojects[0].name
760 if workspace.subprojects else "<subproject>"
761 )
762 message = _FANOUT_FIX_REFUSAL.format(example=example)
763 _dbg("GIT", func, "refusing --fix in fan-out mode", 2)
764 if json_mode:
765 click.echo(json.dumps(
766 {
767 "scope": scope,
768 "profile": fallback_profile,
769 "workspace": workspace.name,
770 "exit_code": 2,
771 "errors": [message],
772 },
773 indent=2, ensure_ascii=False,
774 ))
775 else:
776 click.echo(message, err=True)
777 return 2
778
779 files = _resolve_scope(workspace.root, scope)
780 _dbg("GIT", func, f"scope={scope} files={len(files)}", 3)
781
782 outcome = _run_fanout_gate(
783 workspace, files,
784 cli_profile=cli_profile, branch=branch,
785 fallback_profile=fallback_profile,
786 include_tests=include_tests,
787 )
788
789 # Fail-closed: the buckets did not partition the scope, so no verdict
790 # this command could print would be trustworthy.
791 if outcome.unbucketed:
792 names = ", ".join(
793 to_project_relative(p, workspace.root)
794 for p in outcome.unbucketed
795 )
796 message = (
797 f"{len(outcome.unbucketed)} file(s) could not be assigned to "
798 f"any quality-gate scope and were NOT checked: {names}."
799 )
800 _dbg("GIT", func, f"partition invariant violated: {names}", 1,
801 override=True)
802 if json_mode:
803 click.echo(json.dumps(
804 {
805 "scope": scope,
806 "profile": fallback_profile,
807 "workspace": workspace.name,
808 "exit_code": 2,
809 "errors": [message],
810 },
811 indent=2, ensure_ascii=False,
812 ))
813 else:
814 click.echo(f"Quality gate aborted: {message}", err=True)
815 return 2
816
817 result = outcome.result
818 exit_code = outcome.gate_exit
819 result.exit_code = exit_code
820 per_bucket = _build_per_subproject_audit(
821 outcome.per_bucket_results, outcome.per_bucket_meta,
822 )
823
824 # -- Populate audit record --
825 record = ctx.obj.get("_audit_record", AuditRecord())
826 record.timestamp = record.timestamp or _utc_now_iso()
827 record.command = "oct git check"
828 record.branch = branch
829 record.staged_files = result.files_checked
830 record.checks_passed = (exit_code == 0)
831 record.secrets_detected = bool(result.secrets_findings)
832 record.lint_violations = result.lint_violations
833 record.format_violations = result.format_violations
834 record.exit_code = exit_code
835 ctx.obj["_audit_record"] = record
836 ctx.obj["_per_subproject_results"] = per_bucket
837
838 if json_mode:
839 click.echo(json.dumps(
840 {
841 "scope": scope,
842 "profile": fallback_profile,
843 "workspace": workspace.name,
844 "files_checked": result.files_checked,
845 "lint_violations": result.lint_violations,
846 "format_violations": result.format_violations,
847 "secrets_findings": [
848 {"path": s[0], "line": s[1], "reason": s[2]}
849 for s in result.secrets_findings
850 ],
851 "test_failures": result.test_failures,
852 "exit_code": exit_code,
853 "duration_ms": result.duration_ms,
854 "per_subproject": per_bucket,
855 },
856 indent=2, ensure_ascii=False,
857 ))
858 return exit_code
859
860 # Terminal summary - one section per non-empty bucket, then the total.
861 click.echo(
862 f"Quality gate: scope={scope}, workspace={workspace.name}, "
863 f"buckets={len(per_bucket)}"
864 )
865 click.echo()
866 for entry in per_bucket:
867 click.echo(f"=== {entry['name']} ===")
868 click.echo(f" Profile: {entry['profile']}")
869 click.echo(f" Files checked: {entry['files']}")
870 click.echo(f" Lint violations: {entry['lint_violations']}")
871 click.echo(f" Format issues: {entry['format_violations']}")
872 click.echo(f" Secrets findings: {entry['secrets']}")
873 click.echo()
874
875 click.echo("=== total ===")
876 click.echo(f" Files checked: {result.files_checked}")
877 click.echo(f" Lint violations: {result.lint_violations}")
878 click.echo(f" Format issues: {result.format_violations}")
879 click.echo(f" Secrets findings: {len(result.secrets_findings)}")
880 if include_tests:
881 click.echo(f" Test failures: {result.test_failures}")
882 click.echo(f" Duration: {result.duration_ms} ms")
883 click.echo()
884
885 if result.secrets_findings:
886 click.echo("SECRETS DETECTED (always blocking):", err=True)
887 for path_str, line, reason in result.secrets_findings:
888 loc = f"{path_str}:{line}" if line else path_str
889 click.echo(f" {loc} - {reason}", err=True)
890 click.echo()
891
892 if exit_code == 0:
893 click.echo("All checks passed.")
894 else:
895 labels = {
896 1: "Lint violations",
897 2: "Format violations",
898 3: "Lint + format violations",
899 4: "Test failures",
900 5: "Secrets detected",
901 }
902 click.echo(
903 f"FAILED (exit {exit_code}): {labels.get(exit_code, 'unknown')}",
904 err=True,
905 )
906 return exit_code
907
908
909@git.command(name="check")
910@click.option(
911 "--staged-only", is_flag=True,
912 help="Check only staged (index) files.",
913)
914@click.option(
915 "--all", "check_all", is_flag=True,
916 help="Check every Python file in the project.",
917)
918@click.option(
919 "--include-tests", is_flag=True,
920 help="Also run pytest (exit code 4 on failure).",
921)
922@click.option(
923 "--fix", is_flag=True,
924 help="Attempt auto-fix of lint/format violations, then re-check.",
925)
926@click.option(
927 "--profile",
928 type=click.Choice(["proto", "compact", "strict"], case_sensitive=False),
929 default=None,
930 help="Override the lint profile.",
931)
932@click.option("--json", "json_mode", is_flag=True, help="Emit JSON output.")
933@click.option(
934 "--sandbox", is_flag=True, default=False,
935 help="OI-517: run pytest (when --include-tests) under the MCP sandbox "
936 "environment so PYTHONPATH and secret-named env vars are stripped.",
937)
938@click.pass_context
939@audited("oct git check")
941 ctx: click.Context,
942 staged_only: bool,
943 check_all: bool,
944 include_tests: bool,
945 fix: bool,
946 profile: str | None,
947 json_mode: bool,
948 sandbox: bool,
949) -> None:
950 """Run the unified quality gate: lint + format + secrets."""
951 func = "git_check_cmd"
952 root = get_project_root(ctx)
953 _dbg("GIT", func, f"root={root}", 2)
954
955 from oct.core.git import current_branch, is_git_repo
956 from oct.git.policy import (
957 apply_profile_policy,
958 resolve_profile_for_project,
959 )
960 from oct.git.quality_gate import run_quality_gate
961
962 if not is_git_repo(root):
963 click.echo("Not a git repository.", err=True)
964 ctx.exit(1)
965 return
966
967 # -- Resolve scope --
968 if staged_only:
969 scope = "staged"
970 elif check_all:
971 scope = "all"
972 else:
973 scope = "changed"
974
975 # -- Resolve profile --
976 branch = current_branch(root)
977 octrc = load_octrc_safe(root)
978 # Plan 197: manifest-aware so a subproject is gated at the profile
979 # the workspace declares, not just whatever its octrc says.
980 effective_profile = resolve_profile_for_project(
981 root, branch, octrc, profile,
982 )
983 _dbg(
984 "GIT", func,
985 f"scope={scope} profile={effective_profile} fix={fix}",
986 3,
987 )
988
989 # -- Plan 213 / D-B: workspace fan-out --
990 # Invoked at a workspace root, check must partition the scope by owning
991 # subproject exactly as the commit gate does. Before 213 it ran ONE
992 # run_quality_gate pass rooted here - and the workspace root has no
993 # octrc and matches no subproject, so the profile collapsed to compact
994 # (or strict on a protected branch) and workspace_profile was never
995 # consulted. check --staged-only and the commit gate could therefore
996 # disagree on an identical staged set. Invocation from inside a
997 # subproject, or in a non-workspace repo, is byte-identical to before:
998 # _maybe_load_workspace returns (None, False) in both cases.
999 check_workspace, check_in_workspace_mode = _maybe_load_workspace(root)
1000 use_fanout = (
1001 check_in_workspace_mode
1002 and check_workspace is not None
1003 and bool(check_workspace.subprojects)
1004 )
1005
1006 if use_fanout:
1007 exit_code = _git_check_fanout(
1008 ctx,
1009 workspace=check_workspace,
1010 scope=scope,
1011 cli_profile=profile,
1012 fallback_profile=effective_profile,
1013 branch=branch,
1014 include_tests=include_tests,
1015 fix=fix,
1016 json_mode=json_mode,
1017 )
1018 ctx.exit(exit_code)
1019 return
1020
1021 # -- Run quality gate --
1022 result = run_quality_gate(
1023 project_root=root,
1024 scope=scope,
1025 profile=effective_profile,
1026 include_tests=include_tests,
1027 fix=fix,
1028 sandbox=sandbox,
1029 )
1030
1031 # -- Derive exit code via policy --
1032 exit_code = apply_profile_policy(effective_profile, result)
1033 result.exit_code = exit_code
1034
1035 # -- Populate audit record --
1036 record = ctx.obj.get("_audit_record", AuditRecord())
1037 record.timestamp = record.timestamp or _utc_now_iso()
1038 record.command = "oct git check"
1039 record.branch = branch
1040 record.staged_files = result.files_checked
1041 record.checks_passed = (exit_code == 0)
1042 record.secrets_detected = bool(result.secrets_findings)
1043 record.lint_violations = result.lint_violations
1044 record.format_violations = result.format_violations
1045 record.exit_code = exit_code
1046 ctx.obj["_audit_record"] = record
1047
1048 # -- Render output --
1049 if json_mode:
1050 output = {
1051 "scope": scope,
1052 "profile": effective_profile,
1053 "files_checked": result.files_checked,
1054 "lint_violations": result.lint_violations,
1055 "format_violations": result.format_violations,
1056 "secrets_findings": [
1057 {"path": s[0], "line": s[1], "reason": s[2]}
1058 for s in result.secrets_findings
1059 ],
1060 "test_failures": result.test_failures,
1061 "exit_code": exit_code,
1062 "duration_ms": result.duration_ms,
1063 }
1064 click.echo(json.dumps(output, indent=2, ensure_ascii=False))
1065 ctx.exit(exit_code)
1066 return
1067
1068 # Terminal summary.
1069 click.echo(f"Quality gate: scope={scope}, profile={effective_profile}")
1070 click.echo(f" Files checked: {result.files_checked}")
1071 click.echo(f" Lint violations: {result.lint_violations}")
1072 click.echo(f" Format issues: {result.format_violations}")
1073 click.echo(f" Secrets findings: {len(result.secrets_findings)}")
1074 if include_tests:
1075 click.echo(f" Test failures: {result.test_failures}")
1076 click.echo(f" Duration: {result.duration_ms} ms")
1077 click.echo()
1078
1079 if result.secrets_findings:
1080 click.echo("SECRETS DETECTED (always blocking):", err=True)
1081 for path_str, line, reason in result.secrets_findings:
1082 loc = f"{path_str}:{line}" if line else path_str
1083 click.echo(f" {loc} - {reason}", err=True)
1084 click.echo()
1085
1086 if exit_code == 0:
1087 click.echo("All checks passed.")
1088 else:
1089 labels = {
1090 1: "Lint violations",
1091 2: "Format violations",
1092 3: "Lint + format violations",
1093 4: "Test failures",
1094 5: "Secrets detected",
1095 }
1096 click.echo(
1097 f"FAILED (exit {exit_code}): {labels.get(exit_code, 'unknown')}",
1098 err=True,
1099 )
1100
1101 ctx.exit(exit_code)
1102
1103
1104# =====================================================================
1105# oct git init
1106# =====================================================================
1107
1108
1109_GITATTRIBUTES_DEFAULTS: tuple[str, ...] = (
1110 "*.py text eol=lf",
1111 "*.pem binary",
1112 "*.key binary",
1113)
1114
1115
1116_GITHUB_WORKFLOW_TEMPLATE = """\
1117name: Option C CI
1118on: [push, pull_request]
1119jobs:
1120 check:
1121 runs-on: ubuntu-latest
1122 steps:
1123 - uses: actions/checkout@v4
1124 - uses: actions/setup-python@v5
1125 with:
1126 python-version: "3.12"
1127 - run: pip install -e ./oct
1128 - run: oct lint --json
1129 - run: oct format --dry-run
1130 - run: oct test
1131"""
1132
1133# Marker comments used to delimit the Option C block in .gitignore / .gitattributes.
1134# Shared with ``oct/git/policy.py`` so ``oct git init`` (which writes the initial
1135# block with section comments) and ``oct git ignore`` (which adds bare patterns)
1136# agree on the literal fence strings.
1137from oct.git.policy import (
1138 OC_GITIGNORE_MARKER_END as _OC_MARKER_END,
1139 OC_GITIGNORE_MARKER_START as _OC_MARKER_START,
1140)
1141
1142
1144 path: Path, entries: tuple[str, ...], dry_run: bool,
1145) -> list[str]:
1146 """Merge *entries* into an ignore/attributes file, appending only missing lines.
1147
1148 Returns the list of entries that were (or would be) added.
1149 """
1150 existing_lines: set[str] = set()
1151 if path.is_file():
1152 for line in path.read_text(encoding="utf-8").splitlines():
1153 existing_lines.add(line.strip())
1154
1155 # Filter to entries not already present (skip comments/blanks for dedup).
1156 to_add: list[str] = []
1157 for entry in entries:
1158 stripped = entry.strip()
1159 if stripped == "" or stripped.startswith("#"):
1160 # Always include structural lines in the block, but they don't
1161 # count as "added entries" for reporting.
1162 continue
1163 if stripped not in existing_lines:
1164 to_add.append(stripped)
1165
1166 if not to_add:
1167 return []
1168
1169 # Build the block to append, including all entries (with comments/blanks).
1170 block_lines: list[str] = [_OC_MARKER_START]
1171 for entry in entries:
1172 stripped = entry.strip()
1173 if stripped == "" or stripped.startswith("#"):
1174 block_lines.append(entry)
1175 elif stripped in {a for a in to_add}:
1176 block_lines.append(entry)
1177 block_lines.append(_OC_MARKER_END)
1178
1179 if not dry_run:
1180 # Ensure existing content ends with a newline before appending.
1181 existing = ""
1182 if path.is_file():
1183 existing = path.read_text(encoding="utf-8")
1184 if existing and not existing.endswith("\n"):
1185 existing += "\n"
1186 with path.open("w", encoding="utf-8") as fh:
1187 fh.write(existing + "\n".join(block_lines) + "\n")
1188
1189 return to_add
1190
1191
1192def _create_gitattributes(root: Path, dry_run: bool) -> list[str]:
1193 """Create or merge ``.gitattributes`` with Option C defaults."""
1194 path = root / ".gitattributes"
1195 return _merge_ignore_file(path, _GITATTRIBUTES_DEFAULTS, dry_run)
1196
1197
1198def _generate_github_workflow(root: Path, dry_run: bool) -> bool:
1199 """Write ``.github/workflows/option-c.yml``. Returns True if created."""
1200 workflow_dir = root / ".github" / "workflows"
1201 workflow_path = workflow_dir / "option-c.yml"
1202 if workflow_path.is_file():
1203 return False
1204 if not dry_run:
1205 workflow_dir.mkdir(parents=True, exist_ok=True)
1206 workflow_path.write_text(_GITHUB_WORKFLOW_TEMPLATE, encoding="utf-8")
1207 return True
1208
1209
1210@git.command(name="init")
1211@click.option("--dry-run", is_flag=True, help="Show what would be created/modified.")
1212@click.option("--github", is_flag=True, help="Also generate GitHub Actions workflow.")
1213@click.option("--no-hooks", is_flag=True, help="Skip hook installation.")
1214@click.pass_context
1215@audited("oct git init")
1217 ctx: click.Context,
1218 dry_run: bool,
1219 github: bool,
1220 no_hooks: bool,
1221) -> None:
1222 """Initialise or enhance a git repo with Option C conventions."""
1223 func = "git_init_cmd"
1224 # Accept either a --root-dir context or cwd.
1225 try:
1226 root = get_project_root(ctx)
1227 except click.ClickException:
1228 root = Path.cwd()
1229 _dbg("GIT", func, f"root={root} dry_run={dry_run}", 2)
1230
1231 from oct.core.git import is_git_repo, git_run
1232 from oct.core.exclusions import GIT_IGNORE_DEFAULTS
1233 from oct.hooks.oct_hooks import install_hooks
1234
1235 actions: list[str] = []
1236
1237 # 1. git init if needed
1238 if not is_git_repo(root):
1239 if dry_run:
1240 actions.append("Would run: git init")
1241 else:
1242 git_run(["init"], cwd=root)
1243 actions.append("Initialised git repository")
1244
1245 # F1.4: when running at a workspace root, augment the standard
1246 # ignore-defaults with the manifest's ignore_at_workspace_scope[]
1247 # patterns. Subproject roots get the standard defaults only.
1248 init_ws, init_in_ws = _maybe_load_workspace(root)
1249 if (
1250 init_in_ws and init_ws is not None
1251 and init_ws.ignore_at_workspace_scope
1252 ):
1253 ignore_entries: tuple[str, ...] = (
1254 *GIT_IGNORE_DEFAULTS,
1255 "",
1256 "# --- workspace-scope additions ---",
1257 *init_ws.ignore_at_workspace_scope,
1258 )
1259 _dbg(
1260 "GIT", func,
1261 f"workspace-mode init: appending "
1262 f"{len(init_ws.ignore_at_workspace_scope)} workspace patterns",
1263 3,
1264 )
1265 else:
1266 ignore_entries = GIT_IGNORE_DEFAULTS
1267
1268 # 2. Merge .gitignore
1269 added_ignores = _merge_ignore_file(
1270 root / ".gitignore", ignore_entries, dry_run,
1271 )
1272 if added_ignores:
1273 verb = "Would add" if dry_run else "Added"
1274 actions.append(f"{verb} {len(added_ignores)} entries to .gitignore")
1275 else:
1276 actions.append(".gitignore already up-to-date")
1277
1278 # 3. Create/merge .gitattributes
1279 added_attrs = _create_gitattributes(root, dry_run)
1280 if added_attrs:
1281 verb = "Would add" if dry_run else "Added"
1282 actions.append(f"{verb} {len(added_attrs)} entries to .gitattributes")
1283 else:
1284 actions.append(".gitattributes already up-to-date")
1285
1286 # 4. Install hooks
1287 if no_hooks:
1288 actions.append("Skipped hook installation (--no-hooks)")
1289 else:
1290 hook_path = root / ".pre-commit-config.yaml"
1291 if hook_path.is_file():
1292 actions.append(".pre-commit-config.yaml already exists")
1293 elif dry_run:
1294 actions.append("Would create .pre-commit-config.yaml")
1295 else:
1296 install_hooks(root, force=False)
1297 actions.append("Created .pre-commit-config.yaml")
1298
1299 # 5. GitHub Actions workflow
1300 if github:
1301 created = _generate_github_workflow(root, dry_run)
1302 if created:
1303 verb = "Would create" if dry_run else "Created"
1304 actions.append(f"{verb} .github/workflows/option-c.yml")
1305 else:
1306 actions.append(".github/workflows/option-c.yml already exists")
1307
1308 # -- Populate audit record --
1309 record = ctx.obj.get("_audit_record", AuditRecord())
1310 record.timestamp = record.timestamp or _utc_now_iso()
1311 record.command = "oct git init"
1312 record.checks_passed = True
1313 record.exit_code = 0
1314 ctx.obj["_audit_record"] = record
1315
1316 # -- Render output --
1317 prefix = "[DRY RUN] " if dry_run else ""
1318 for action in actions:
1319 click.echo(f"{prefix}{action}")
1320 if not dry_run:
1321 click.echo("Option C git initialisation complete.")
1322
1323
1324# =====================================================================
1325# oct git commit (Phase 4D - G-D3, G-D4)
1326# =====================================================================
1327
1328
1329@git.command(
1330 name="commit",
1331 context_settings={"ignore_unknown_options": True},
1332)
1333@click.option(
1334 "-m", "--message", "messages", required=True, multiple=True,
1335 help=(
1336 "Commit message (Conventional Commits format). May be passed "
1337 "multiple times - each value becomes a paragraph separated by "
1338 "a blank line, matching ``git commit -m`` semantics."
1339 ),
1340)
1341@click.option("--force", "force_commit", is_flag=True,
1342 help="Skip lint/format checks (secrets still checked).")
1343@click.option("--no-verify", is_flag=True,
1344 help="Forward --no-verify to git (hooks skipped).")
1345@click.option(
1346 "--profile",
1347 type=click.Choice(["proto", "compact", "strict"], case_sensitive=False),
1348 default=None,
1349 help="Override lint profile.",
1350)
1351@click.option("--json", "json_mode", is_flag=True, help="Emit JSON output.")
1352@click.option(
1353 "--ignore-unstaged-mutation", "ignore_unstaged_mutation", is_flag=True,
1354 help=(
1355 "Downgrade B1.0 unstaged-tracked-file mutation detection from "
1356 "block to warn for this invocation."
1357 ),
1358)
1359@click.argument("extra_args", nargs=-1, type=click.UNPROCESSED)
1360@click.pass_context
1361@audited("oct git commit")
1363 ctx: click.Context,
1364 messages: tuple[str, ...],
1365 force_commit: bool,
1366 no_verify: bool,
1367 profile: str | None,
1368 json_mode: bool,
1369 ignore_unstaged_mutation: bool,
1370 extra_args: tuple[str, ...],
1371) -> None:
1372 """Commit staged changes with quality gate enforcement."""
1373 func = "git_commit_cmd"
1374 root = get_project_root(ctx)
1375 # L4: join multiple -m values with a blank line between paragraphs
1376 # to mirror plain ``git commit -m subject -m body`` semantics.
1377 message = "\n\n".join(m for m in messages if m is not None)
1378 _dbg("GIT", func, f"root={root} force={force_commit} -m count={len(messages)}", 2)
1379
1380 from oct.core.git import current_branch, git_run, is_git_repo
1381 from oct.git.conventional import (
1382 analyse_diagnostics_impact,
1383 format_validation_errors,
1384 )
1385 from oct.git.policy import apply_profile_policy
1386 from oct.git.quality_gate import pre_commit_checks, run_quality_gate
1387
1388 if not is_git_repo(root):
1389 click.echo("Not a git repository.", err=True)
1390 ctx.exit(1)
1391 return
1392
1393 branch = current_branch(root)
1394 octrc = load_octrc_safe(root)
1395
1396 # -- 1-3. Pre-gate checks (OI-527): commit message, staged files, protected branch --
1397 pre = pre_commit_checks(
1398 project_root=root,
1399 branch=branch,
1400 message=message,
1401 force_commit=force_commit,
1402 profile=profile,
1403 octrc=octrc,
1404 )
1405 effective_profile = pre.effective_profile
1406 if pre.pb_advisory:
1407 click.echo(pre.pb_advisory, err=True)
1408
1409 if not pre.ok:
1410 # -- Auto-branch on protected-branch block (Phase 4F) --
1411 if pre.exit_code == 1 and _should_auto_branch(branch, octrc):
1412 auto_branch_name = _auto_branch_for_commit(
1413 root, branch, message, ctx, json_mode,
1414 )
1415 if auto_branch_name is None:
1416 # _auto_branch_for_commit already emitted errors and
1417 # populated the audit record.
1418 return
1419 branch = auto_branch_name
1420 # Re-run pre-checks on the new (non-protected) branch.
1421 pre = pre_commit_checks(
1422 project_root=root, branch=branch, message=message,
1423 force_commit=force_commit, profile=profile, octrc=octrc,
1424 )
1425 effective_profile = pre.effective_profile
1426
1427 if not pre.ok:
1428 if json_mode:
1430 ctx, exit_code=pre.exit_code, message=message,
1431 branch=branch, profile=effective_profile,
1432 errors=pre.errors,
1433 )
1434 elif pre.exit_code == 3:
1435 click.echo(format_validation_errors(pre.errors), err=True)
1436 elif pre.exit_code == 4:
1437 click.echo("Nothing to commit (no staged files).", err=True)
1438 # exit_code == 1 already surfaced via pb_advisory echo above.
1440 ctx, branch=branch, exit_code=pre.exit_code,
1441 checks_passed=False, staged_count=len(pre.staged_files),
1442 )
1443 ctx.exit(pre.exit_code)
1444 return
1445
1446 staged = pre.staged_files
1447
1448 # -- B1.0: snapshot tracked-but-unstaged files for mutation detection --
1449 observe_mode = _resolve_observe_mode(octrc, ignore_unstaged_mutation)
1450 # Plan 242: the method is resolved fail-closed and unconditionally,
1451 # before any snapshot and regardless of mode, so a typo in the key
1452 # is loud rather than silently picking a detection method.
1453 try:
1454 observe_method = _resolve_observe_method(octrc)
1455 except ValueError as exc:
1456 click.echo(str(exc), err=True)
1457 if json_mode:
1459 ctx, exit_code=2, message=message, branch=branch,
1460 profile=effective_profile, errors=[str(exc)],
1461 )
1463 ctx, branch=branch, exit_code=2,
1464 checks_passed=False, staged_count=len(staged),
1465 )
1466 ctx.exit(2)
1467 return
1468 pre_snapshot: dict[str, str] = {}
1469 if observe_mode != "off":
1470 try:
1471 from oct.core.git import get_repo_root
1472 from oct.git.commit_observer import snapshot_unstaged_tracked
1473 repo_root_for_obs = get_repo_root(root)
1474 staged_rel = {
1475 str(p.resolve().relative_to(repo_root_for_obs.resolve()))
1476 .replace("\\", "/")
1477 for p in staged
1478 if _safe_relative(p, repo_root_for_obs) is not None
1479 }
1480 pre_snapshot = snapshot_unstaged_tracked(
1481 repo_root_for_obs, staged_set=staged_rel,
1482 method=observe_method,
1483 )
1484 except Exception as exc:
1485 _dbg(
1486 "GIT", func,
1487 f"observer snapshot failed: {exc!r}; continuing without",
1488 1, override=True,
1489 )
1490 pre_snapshot = {}
1491 observe_mode = "off"
1492
1493 # -- 4. Run quality gate on staged files --
1494 # F1.3: in workspace mode with subprojects, fan the staged set out
1495 # by subproject so each subproject's slice runs under its own
1496 # profile. The aggregated result preserves the secrets-always-block
1497 # invariant (G-D4) - a finding in any subproject still trips
1498 # secret enforcement below.
1499 commit_workspace, commit_in_workspace_mode = _maybe_load_workspace(root)
1500 per_sp_results: dict[str, "QualityGateResult"] = {}
1501 per_sp_meta: dict[str, dict] = {}
1502 use_fanout = (
1503 commit_in_workspace_mode
1504 and commit_workspace is not None
1505 and bool(commit_workspace.subprojects)
1506 )
1507
1508 if use_fanout:
1509 # Plan 213 / D-A: one shared fan-out implementation, also used by
1510 # ``oct git check``, so the two commands cannot disagree on the
1511 # same file set. Behaviour here is unchanged from the inline block
1512 # it replaces.
1513 outcome = _run_fanout_gate(
1514 commit_workspace, staged,
1515 cli_profile=profile, branch=branch,
1516 fallback_profile=effective_profile,
1517 apply_policy=not force_commit,
1518 )
1519 unbucketed = outcome.unbucketed
1520 if unbucketed:
1521 names = ", ".join(
1522 to_project_relative(p, root) for p in unbucketed
1523 )
1524 _dbg(
1525 "GIT", func,
1526 f"partition invariant violated; {len(unbucketed)} staged "
1527 f"file(s) reached no bucket: {names}",
1528 1, override=True,
1529 )
1530 if json_mode:
1532 ctx, exit_code=2, message=message, branch=branch,
1533 profile=effective_profile,
1534 errors=[
1535 "Refusing to commit: "
1536 f"{len(unbucketed)} staged file(s) could not be "
1537 "assigned to any quality-gate scope and would be "
1538 f"committed unscanned: {names}."
1539 ],
1540 )
1541 else:
1542 click.echo(
1543 "Refusing to commit: staged file(s) could not be "
1544 "assigned to any quality-gate scope and would be "
1545 f"committed unscanned:\n {names}",
1546 err=True,
1547 )
1548 ctx.exit(2)
1549 return
1550 per_sp_results = outcome.per_bucket_results
1551 per_sp_meta = outcome.per_bucket_meta
1552 result = outcome.result
1553 gate_exit = outcome.gate_exit
1554 elif not force_commit:
1555 result = run_quality_gate(
1556 project_root=root,
1557 scope="staged",
1558 profile=effective_profile,
1559 include_tests=False,
1560 fix=False,
1561 )
1562 gate_exit = apply_profile_policy(effective_profile, result)
1563 else:
1564 # --force: still run secrets check only.
1565 result = run_quality_gate(
1566 project_root=root,
1567 scope="staged",
1568 profile=effective_profile,
1569 include_tests=False,
1570 fix=False,
1571 )
1572 gate_exit = 0 # Force ignores lint/format.
1573
1574 # G-D4 D3 (plan 208): `git commit` writes the WHOLE repo index regardless of
1575 # the invocation cwd, but the profile gate above scanned only the
1576 # invocation-scoped staged set - `git_staged_files(root)` drops every staged
1577 # file resolving outside `root` (core/git.py `_resolve_and_filter`). So a
1578 # secret staged OUTSIDE the invoking subproject (a sibling subproject, or a
1579 # workspace-root file) was committed unscanned. The workspace fan-out path is
1580 # exempt: there root == workspace root and the buckets already partition the
1581 # full index. For every other path - single-project, subproject-cwd, and
1582 # --force - adopt the secrets verdict of the FULL committed index, so the set
1583 # scanned for secrets always equals the set `git commit` will write.
1584 committed_secrets = result.secrets_findings
1585 if not use_fanout:
1586 try:
1587 from oct.core.git import get_repo_root, git_staged_files
1588 from oct.git.quality_gate import run_secrets_scan_on_files
1589 commit_repo_root = get_repo_root(root)
1590 full_index = git_staged_files(commit_repo_root)
1591 # The secrets pass alone, on ALL file types, configured exactly as
1592 # the gate's own secrets pass (one shared function). Lint and format
1593 # are not re-run: they used to be, over the full index, and their
1594 # result was discarded (found by the chart C runtime trace,
1595 # 2026-09-27). Scanning at the repo root merges each subproject's
1596 # entropy_exclusions, so operator-waivered files stay waivered.
1597 committed_secrets = run_secrets_scan_on_files(commit_repo_root, full_index)
1598 except Exception as exc:
1599 # Fail closed: if the full committed index cannot be scanned, refuse
1600 # rather than fall back to the narrower, bypassable scan.
1601 _dbg(
1602 "GIT", func,
1603 f"full-index secrets scan failed ({exc!r}); refusing commit",
1604 1, override=True,
1605 )
1606 if json_mode:
1608 ctx, exit_code=2, message=message, branch=branch,
1609 profile=effective_profile,
1610 errors=[
1611 "Refusing to commit: could not scan the full committed "
1612 f"index for secrets ({type(exc).__name__})."
1613 ],
1614 )
1615 else:
1616 click.echo(
1617 "Refusing to commit: could not scan the full committed "
1618 f"index for secrets ({type(exc).__name__}).",
1619 err=True,
1620 )
1621 ctx.exit(2)
1622 return
1623
1624 # G-D4 invariant: secrets are NEVER bypassable.
1625 if committed_secrets:
1626 if json_mode:
1628 ctx, exit_code=2, message=message, branch=branch,
1629 profile=effective_profile,
1630 errors=[f"Secrets detected in {len(committed_secrets)} location(s)."],
1631 secrets=committed_secrets,
1632 )
1633 else:
1634 click.echo("SECRETS DETECTED (non-bypassable):", err=True)
1635 for path_str, line, reason in committed_secrets:
1636 loc = f"{path_str}:{line}" if line else path_str
1637 click.echo(f" {loc} - {reason}", err=True)
1639 ctx, branch=branch, exit_code=2,
1640 checks_passed=False, staged_count=len(staged),
1641 secrets=True, lint_v=result.lint_violations,
1642 format_v=result.format_violations,
1643 )
1644 ctx.exit(2)
1645 return
1646
1647 # Non-secret quality gate failures (only when not --force).
1648 if gate_exit != 0 and not force_commit:
1649 if json_mode:
1651 ctx, exit_code=1, message=message, branch=branch,
1652 profile=effective_profile,
1653 errors=[f"Quality gate failed (exit {gate_exit})."],
1654 )
1655 else:
1656 click.echo(
1657 f"Quality gate failed (exit {gate_exit}). "
1658 f"Use --force to skip lint/format checks.",
1659 err=True,
1660 )
1662 ctx, branch=branch, exit_code=1,
1663 checks_passed=False, staged_count=len(staged),
1664 lint_v=result.lint_violations,
1665 format_v=result.format_violations,
1666 )
1667 ctx.exit(1)
1668 return
1669
1670 if force_commit and gate_exit != 0:
1671 click.echo(
1672 f"Warning: quality gate would have failed (exit {gate_exit}), "
1673 f"bypassed with --force.",
1674 err=True,
1675 )
1676
1677 # -- 5. Large-file advisory (G-D6) --
1678 oversized = _check_large_staged_files(root, staged, octrc)
1679 for rel, size in oversized:
1680 mb = size / (1024 * 1024)
1681 click.echo(
1682 f"Warning: {rel} is {mb:.1f} MB. Consider .gitignore or Git LFS.",
1683 err=True,
1684 )
1685
1686 # -- 6. Advisory checks --
1687 _advisory_checks(root, staged, message, err=True)
1688
1689 # -- 7. --no-verify warning --
1690 if no_verify:
1691 click.echo(
1692 "Skipping git hooks (--no-verify). "
1693 "Note: oct git commit still runs its own quality checks.",
1694 err=True,
1695 )
1696
1697 # -- 7.5 B1.0: pre-commit-call mutation check (catches gate writers) --
1698 if observe_mode != "off" and pre_snapshot:
1699 gate_mutations = _check_unstaged_mutation(
1700 root, pre_snapshot, staged,
1701 stage_label="quality gate",
1702 method=observe_method,
1703 )
1704 if gate_mutations and observe_mode == "block":
1706 ctx, mutations=gate_mutations,
1707 message=message, branch=branch,
1708 profile=effective_profile, json_mode=json_mode,
1709 staged_count=len(staged),
1710 )
1711 return
1712
1713 # -- 8. Execute git commit --
1714 git_args = ["commit", "-m", message]
1715 if no_verify:
1716 git_args.append("--no-verify")
1717 git_args.extend(extra_args)
1718
1719 try:
1720 git_run(git_args, cwd=root)
1721 except Exception as exc:
1722 err_msg = str(exc)
1723 if json_mode:
1725 ctx, exit_code=4, message=message, branch=branch,
1726 profile=effective_profile,
1727 errors=[f"git commit failed: {err_msg}"],
1728 )
1729 else:
1730 click.echo(f"git commit failed: {err_msg}", err=True)
1732 ctx, branch=branch, exit_code=4,
1733 checks_passed=False, staged_count=len(staged),
1734 )
1735 ctx.exit(4)
1736 return
1737
1738 # -- 8.5 B1.0: post-commit mutation check (catches git-hook writers) --
1739 if observe_mode != "off" and pre_snapshot:
1740 hook_mutations = _check_unstaged_mutation(
1741 root, pre_snapshot, staged,
1742 stage_label="git-hook",
1743 method=observe_method,
1744 )
1745 if hook_mutations and observe_mode == "block":
1747 ctx, mutations=hook_mutations,
1748 message=message, branch=branch,
1749 profile=effective_profile, json_mode=json_mode,
1750 staged_count=len(staged),
1751 )
1752 return
1753
1754 # -- 9. Diagnostics impact (G-D5) --
1755 try:
1756 from oct.core.git import git_run as _gr
1757 diff_proc = _gr(
1758 ["diff", "HEAD~1..HEAD"], cwd=root, check=False,
1759 )
1760 impact = analyse_diagnostics_impact(diff_proc.stdout)
1761 if impact:
1762 click.echo("Diagnostics Impact:", err=True)
1763 for line in impact:
1764 click.echo(f" {line}", err=True)
1765 except Exception:
1766 pass # Best-effort; never block.
1767
1768 # -- 10. Success output --
1769 if json_mode:
1771 ctx, exit_code=0, message=message, branch=branch,
1772 profile=effective_profile, errors=[],
1773 )
1774 else:
1775 click.echo("Commit successful.")
1776
1777 psr_audit = (
1778 _build_per_subproject_audit(per_sp_results, per_sp_meta)
1779 if use_fanout else None
1780 )
1782 ctx, branch=branch, exit_code=0,
1783 checks_passed=True, staged_count=len(staged),
1784 per_subproject_results=psr_audit,
1785 )
1786
1787
1789 ctx, *, exit_code: int, message: str, branch: str | None,
1790 profile: str, errors: list[str] | None = None,
1791 secrets: list | None = None,
1792) -> None:
1793 """Emit JSON output for the commit command."""
1794 output: dict = {
1795 "exit_code": exit_code,
1796 "message": message,
1797 "branch": branch,
1798 "profile": profile,
1799 "errors": errors or [],
1800 }
1801 if secrets:
1802 output["secrets_findings"] = [
1803 {"path": s[0], "line": s[1], "reason": s[2]} for s in secrets
1804 ]
1805 click.echo(json.dumps(output, indent=2, ensure_ascii=False))
1806
1807
1809 ctx, *, branch: str | None, exit_code: int,
1810 checks_passed: bool, staged_count: int,
1811 secrets: bool = False, lint_v: int = 0, format_v: int = 0,
1812 tracked_mutations: list[str] | None = None,
1813 per_subproject_results: list[dict] | None = None,
1814) -> None:
1815 """Populate the audit record for oct git commit."""
1816 record = ctx.obj.get("_audit_record", AuditRecord())
1817 record.timestamp = record.timestamp or _utc_now_iso()
1818 record.command = "oct git commit"
1819 record.branch = branch
1820 record.staged_files = staged_count
1821 record.checks_passed = checks_passed
1822 record.secrets_detected = secrets
1823 record.lint_violations = lint_v
1824 record.format_violations = format_v
1825 record.exit_code = exit_code
1826 if tracked_mutations:
1827 # AuditRecord schema has no field for this yet; stash on the
1828 # ctx.obj for downstream serialisation. Test code reads it from
1829 # the JSONL line directly.
1830 ctx.obj["_tracked_mutations"] = list(tracked_mutations)
1831 if per_subproject_results is not None:
1832 # F1.3: per-subproject fan-out results. Stashed on ctx.obj for
1833 # downstream JSONL serialisation; AuditRecord doesn't yet
1834 # carry the field but the audit writer picks it up from
1835 # ctx.obj if present.
1836 ctx.obj["_per_subproject_results"] = list(per_subproject_results)
1837 ctx.obj["_audit_record"] = record
1838
1839
1840# =====================================================================
1841# F1.3 fan-out helpers
1842# =====================================================================
1843
1844
1845#: Bucket name for staged files that match no subproject and no
1846#: ``workspace_files`` glob. Plan 208: such files were previously DROPPED and
1847#: committed without any lint/format/secrets scan - a G-D4 bypass. They now
1848#: land here and are gated under ``workspace_profile`` like the workspace
1849#: bucket, so the three buckets PARTITION the staged set: nothing is unscanned.
1850UNSCOPED_BUCKET = "__unscoped__"
1851
1852
1854 staged: list[Path], workspace,
1855) -> dict[str, list[Path]]:
1856 """Group *staged* paths into per-subproject + workspace + unscoped buckets.
1857
1858 Files matched by a subproject's path go to that subproject's bucket; files
1859 not under any subproject AND matching ``workspace.workspace_files`` go to
1860 the ``"workspace"`` bucket; **every remaining file goes to the
1861 :data:`UNSCOPED_BUCKET`** (plan 208).
1862
1863 The returned buckets PARTITION *staged* - the union of all bucket lists
1864 equals the input set, with no duplicates. This is the invariant the commit
1865 gate relies on so that no staged file is ever committed unscanned; the
1866 caller asserts it and fails closed. Previously a file matching neither a
1867 subproject nor ``workspace_files`` - or one that failed ``relative_to`` -
1868 was silently discarded, and the commit fan-out then never scanned it.
1869 """
1870 from oct.core.workspace import (
1871 assign_to_subproject,
1872 matches_workspace_files,
1873 )
1874 workspace_root_resolved = workspace.root.resolve()
1875 buckets: dict[str, list[Path]] = {
1876 sp.name: [] for sp in workspace.subprojects
1877 }
1878 buckets["workspace"] = []
1879 buckets[UNSCOPED_BUCKET] = []
1880 for path in staged:
1881 sp = assign_to_subproject(path, workspace)
1882 if sp is not None:
1883 buckets[sp.name].append(path)
1884 continue
1885 try:
1886 rel = path.resolve().relative_to(
1887 workspace_root_resolved,
1888 ).as_posix()
1889 except ValueError:
1890 # Outside the workspace root entirely - cannot be a declared
1891 # workspace file, but it IS staged, so it must still be scanned.
1892 # Fail-closed: route to the unscoped bucket rather than drop.
1893 buckets[UNSCOPED_BUCKET].append(path)
1894 continue
1895 if matches_workspace_files(rel, workspace.workspace_files):
1896 buckets["workspace"].append(path)
1897 else:
1898 buckets[UNSCOPED_BUCKET].append(path)
1899 return buckets
1900
1901
1902@dataclass
1904 """Result of one workspace fan-out quality-gate pass (plan 213 / D-A).
1905
1906 ``result`` is the merged :class:`~oct.git.quality_gate.QualityGateResult`
1907 across every non-empty bucket; ``gate_exit`` is the worst per-bucket
1908 policy verdict. ``per_bucket_results`` / ``per_bucket_meta`` are keyed by
1909 bucket name - a subproject name, ``"workspace"``, or
1910 :data:`UNSCOPED_BUCKET`.
1911
1912 ``unbucketed`` is the fail-closed signal. When it is non-empty NO gate
1913 ran: the buckets did not partition the input, so at least one file would
1914 have been passed through unscanned. Callers MUST refuse the operation
1915 and surface the listed paths. Rendering is left to the caller because
1916 the two callers differ: ``oct git commit`` refuses the commit, while
1917 ``oct git check`` reports a non-zero verdict.
1918 """
1919
1920 result: object
1921 gate_exit: int
1922 per_bucket_results: dict
1923 per_bucket_meta: dict
1924 unbucketed: list
1925
1926
1928 workspace,
1929 files: list[Path],
1930 *,
1931 cli_profile: str | None,
1932 branch: str | None,
1933 fallback_profile: str,
1934 include_tests: bool = False,
1935 apply_policy: bool = True,
1936) -> FanoutGateOutcome:
1937 """Gate *files* per owning subproject under a workspace manifest.
1938
1939 Plan 213 / D-A: the ONE fan-out implementation. ``oct git commit``
1940 (F1.3) and ``oct git check`` (plan 213 / D-B) both route through it, so
1941 the two commands cannot report different verdicts for the same file set
1942 - which is exactly what they did before, because ``check`` ran a single
1943 ``run_quality_gate`` pass rooted at the cwd-discovered project root. At
1944 the workspace root that root has no octrc and matches no subproject, so
1945 the profile collapsed to ``compact`` (or ``strict`` on a protected
1946 branch) and ``workspace.workspace_profile`` was never consulted.
1947
1948 Each subproject's slice runs under its own root and its own resolved
1949 profile (:func:`_resolve_subproject_profile`, the plan-197/209 shared
1950 resolver). The ``workspace`` bucket (declared ``workspace_files``) and
1951 the :data:`UNSCOPED_BUCKET` (plan 208 - everything else) both run at
1952 *cli_profile* → ``workspace.workspace_profile`` → *fallback_profile*.
1953
1954 The G-D4 secrets-always-block invariant is preserved by construction:
1955 the buckets partition *files*, every bucket is scanned, and the merged
1956 result concatenates every bucket's findings. The partition is verified
1957 rather than trusted - see :attr:`FanoutGateOutcome.unbucketed`.
1958
1959 Parameters
1960 ----------
1961 workspace
1962 Loaded :class:`~oct.core.workspace.Workspace` manifest.
1963 files
1964 The full file set to gate (staged, changed, or all).
1965 cli_profile
1966 Explicit ``--profile`` override, or ``None``.
1967 branch
1968 Current branch, for the protected-branch override.
1969 fallback_profile
1970 Profile used for the workspace/unscoped buckets when neither
1971 *cli_profile* nor the manifest supplies one. Callers pass their
1972 own single-project resolution so behaviour is unchanged when a
1973 manifest omits ``workspace_profile``.
1974 include_tests
1975 Forwarded per bucket. ``oct git commit`` never sets it.
1976 apply_policy
1977 When False every bucket's policy verdict is skipped and
1978 ``gate_exit`` is 0 (``oct git commit --force``). Secrets findings
1979 still flow through ``result`` - force never suppresses them.
1980 """
1981 func = "_run_fanout_gate"
1982 from oct.git.policy import apply_profile_policy
1983 from oct.git.quality_gate import run_quality_gate_on_files
1984
1985 groups = _group_files_by_subproject(files, workspace)
1986 # G-D4 fail-closed backstop (plan 208): the buckets MUST partition the
1987 # input set, or a file would slip past the secrets scan. Verify it here
1988 # rather than trust it - a future bucketing bug must abort the caller,
1989 # not silently drop a file.
1990 bucketed = {p for bucket in groups.values() for p in bucket}
1991 unbucketed = [p for p in files if p not in bucketed]
1992 if unbucketed:
1993 _dbg(
1994 "GIT", func,
1995 f"partition invariant violated; {len(unbucketed)} file(s) "
1996 f"reached no bucket",
1997 1, override=True,
1998 )
1999 return FanoutGateOutcome(
2000 result=_merge_quality_gate_results({}),
2001 gate_exit=2,
2002 per_bucket_results={},
2003 per_bucket_meta={},
2004 unbucketed=unbucketed,
2005 )
2006
2007 per_bucket_results: dict[str, "QualityGateResult"] = {}
2008 per_bucket_meta: dict[str, dict] = {}
2009
2010 for sp in workspace.subprojects:
2011 sp_files = groups.get(sp.name, [])
2012 if not sp_files:
2013 continue
2014 sp_octrc = load_octrc_safe(sp.path)
2015 sp_profile = _resolve_subproject_profile(
2016 sp, sp_octrc, cli_profile, branch,
2017 )
2018 per_bucket_results[sp.name] = run_quality_gate_on_files(
2019 project_root=sp.path, files=sp_files,
2020 profile=sp_profile, include_tests=include_tests, fix=False,
2021 )
2022 per_bucket_meta[sp.name] = {
2023 "profile": sp_profile, "files": len(sp_files),
2024 }
2025
2026 ws_profile = (
2027 cli_profile or workspace.workspace_profile or fallback_profile
2028 )
2029 for bucket_name in ("workspace", UNSCOPED_BUCKET):
2030 bucket_files = groups.get(bucket_name, [])
2031 if not bucket_files:
2032 continue
2033 per_bucket_results[bucket_name] = run_quality_gate_on_files(
2034 project_root=workspace.root, files=bucket_files,
2035 profile=ws_profile, include_tests=include_tests, fix=False,
2036 )
2037 per_bucket_meta[bucket_name] = {
2038 "profile": ws_profile, "files": len(bucket_files),
2039 }
2040
2041 result = _merge_quality_gate_results(per_bucket_results)
2042 if apply_policy:
2043 gate_exit = max(
2044 (
2045 apply_profile_policy(per_bucket_meta[name]["profile"], r)
2046 for name, r in per_bucket_results.items()
2047 ),
2048 default=0,
2049 )
2050 else:
2051 gate_exit = 0
2052
2053 _dbg(
2054 "GIT", func,
2055 f"buckets={list(per_bucket_results)} gate_exit={gate_exit}",
2056 3,
2057 )
2058 return FanoutGateOutcome(
2059 result=result,
2060 gate_exit=gate_exit,
2061 per_bucket_results=per_bucket_results,
2062 per_bucket_meta=per_bucket_meta,
2063 unbucketed=[],
2064 )
2065
2066
2068 subproject, sp_octrc: dict | None,
2069 cli_override: str | None, branch: str | None,
2070) -> str:
2071 """Resolve the lint profile for *subproject* under workspace fan-out.
2072
2073 Plan 197: this is now a thin adapter over the ONE resolver,
2074 :func:`oct.git.policy.resolve_effective_profile`. Fan-out and
2075 single-project resolution therefore cannot drift apart - the
2076 property is pinned by
2077 ``tests/tests_git/test_profile_resolution_workspace.py``.
2078
2079 Precedence (highest first), unchanged in shape but now shared:
2080
2081 1. **Protected-branch override** - force ``"strict"``. Pre-197 this
2082 path only honoured an EXPLICIT ``git.protected_branches`` list,
2083 so a subproject without one was gated at its manifest profile
2084 even on ``master``; the shared resolver applies
2085 :data:`~oct.git.policy.DEFAULT_PROTECTED_BRANCHES`
2086 (``main``/``master``) as the fallback, matching what
2087 single-project mode has always done. Plan 209: an explicit
2088 ``protected_branches`` list only OPTS OUT of this override for a
2089 ``nested_git`` subproject (a foreign nested repo, e.g.
2090 ``oc_explorer``); every other subproject can only add to the
2091 default protected set, never remove ``main``/``master`` from it.
2092 2. **CLI ``--profile``** - explicit user override.
2093 3. **Manifest ``subproject.profile``** - workspace-declared
2094 per-subproject default.
2095 4. **``sp_octrc.git.pre_commit_profile``** - newly honoured here;
2096 single-project mode has always read it.
2097 5. **``sp_octrc.linter.profile``** - the subproject's own
2098 ``.octrc.json`` setting.
2099 6. **``"compact"``** - final fallback
2100 (:data:`~oct.git.policy.DEFAULT_PROFILE`).
2101 """
2102 func = "_resolve_subproject_profile"
2103 from oct.git.policy import resolve_effective_profile
2104
2105 profile = resolve_effective_profile(
2106 branch, sp_octrc, cli_override,
2107 manifest_profile=getattr(subproject, "profile", None),
2108 allow_optout=getattr(subproject, "nested_git", False),
2109 )
2110 _dbg(
2111 "GIT", func,
2112 f"subproject={getattr(subproject, 'name', '?')!r} "
2113 f"branch={branch!r} -> {profile}",
2114 3,
2115 )
2116 return profile
2117
2118
2120 per_sp: dict[str, "QualityGateResult"],
2121):
2122 """Aggregate per-subproject results into a single QualityGateResult.
2123
2124 Counters (lint, format, files_checked, test_failures) are summed.
2125 Secrets findings are concatenated (preserves the
2126 ``(path, line, reason)`` tuples the G-D4 invariant uses to surface
2127 every leak). per_file is concatenated. duration_ms is the max
2128 (forward-compatible with parallel execution; v1 runs serially so
2129 it's effectively the longest single bucket).
2130 """
2131 from oct.git.quality_gate import QualityGateResult
2132 if not per_sp:
2133 return QualityGateResult()
2134 files_checked = 0
2135 lint_violations = 0
2136 format_violations = 0
2137 test_failures = 0
2138 duration_ms = 0
2139 secrets_findings: list = []
2140 per_file: list = []
2141 for r in per_sp.values():
2142 files_checked += r.files_checked
2143 lint_violations += r.lint_violations
2144 format_violations += r.format_violations
2145 test_failures += r.test_failures
2146 duration_ms = max(duration_ms, r.duration_ms)
2147 secrets_findings.extend(r.secrets_findings)
2148 per_file.extend(r.per_file)
2149 return QualityGateResult(
2150 files_checked=files_checked,
2151 lint_violations=lint_violations,
2152 format_violations=format_violations,
2153 secrets_findings=secrets_findings,
2154 test_failures=test_failures,
2155 duration_ms=duration_ms,
2156 per_file=per_file,
2157 )
2158
2159
2161 per_sp_results: dict, per_sp_meta: dict,
2162) -> list[dict]:
2163 """Shape the per-subproject results dict for the audit record."""
2164 out: list[dict] = []
2165 for name, r in per_sp_results.items():
2166 meta = per_sp_meta.get(name, {})
2167 out.append({
2168 "name": name,
2169 "profile": meta.get("profile", "compact"),
2170 "files": meta.get("files", r.files_checked),
2171 "lint_violations": r.lint_violations,
2172 "format_violations": r.format_violations,
2173 "secrets": len(r.secrets_findings),
2174 })
2175 return out
2176
2177
2178def _resolve_observe_method(octrc: dict | None) -> str:
2179 """Return ``"git"`` / ``"hash"`` for the B1.0 observer (plan 242).
2180
2181 Read from ``.octrc.json -> git.observe_unstaged_mutation_method``.
2182 An absent key resolves to ``"git"``, the git-native detection that
2183 costs about 0.2 s per pass instead of the content walk's 6 to
2184 188 s. ``"hash"`` selects the walk for a paranoid run.
2185
2186 Fails closed: any other value raises :class:`ValueError` naming the
2187 key, and the caller refuses the commit with exit code 2 before a
2188 snapshot is taken. A typo must never silently pick a method - in
2189 either direction. Independent of the mode resolved by
2190 :func:`_resolve_observe_mode`; the two keys do not interact.
2191 """
2192 from oct.git.commit_observer import METHOD_GIT, OBSERVE_METHODS
2193
2194 raw = None
2195 if isinstance(octrc, dict):
2196 git_cfg = octrc.get("git")
2197 if isinstance(git_cfg, dict):
2198 raw = git_cfg.get("observe_unstaged_mutation_method")
2199 if raw is None:
2200 return METHOD_GIT
2201 if isinstance(raw, str) and raw in OBSERVE_METHODS:
2202 return raw
2203 raise ValueError(
2204 f"Invalid octrc value for git.observe_unstaged_mutation_method: "
2205 f"{raw!r}. Expected one of {', '.join(OBSERVE_METHODS)}."
2206 )
2207
2208
2210 octrc: dict | None, ignore_unstaged_mutation: bool,
2211) -> str:
2212 """Return ``"off"`` / ``"warn"`` / ``"block"`` for the B1.0 observer.
2213
2214 Precedence (highest first): ``--ignore-unstaged-mutation`` flag
2215 forces ``"warn"``; ``.octrc.json -> git.observe_unstaged_mutation``
2216 sets the persistent default; final fallback is ``"warn"``.
2217 """
2218 if ignore_unstaged_mutation:
2219 return "warn"
2220 if isinstance(octrc, dict):
2221 git_cfg = octrc.get("git")
2222 if isinstance(git_cfg, dict):
2223 raw = git_cfg.get("observe_unstaged_mutation")
2224 if raw is False or raw == "off":
2225 return "off"
2226 if raw in ("warn", "block"):
2227 return raw
2228 return "warn"
2229
2230
2231def _safe_relative(path: Path, root: Path) -> str | None:
2232 """Return *path* relative to *root* as a forward-slash string, or
2233 ``None`` when *path* is outside *root*.
2234 """
2235 try:
2236 rel = path.resolve().relative_to(root.resolve())
2237 except ValueError:
2238 return None
2239 return str(rel).replace("\\", "/")
2240
2241
2243 root: Path, pre_snapshot: dict[str, str],
2244 staged: list[Path], stage_label: str,
2245 method: str = "hash",
2246) -> list[str]:
2247 """Re-snapshot and diff against *pre_snapshot*. Emit a stderr
2248 warning for each mutated path. Returns the list of changed paths.
2249
2250 *method* is the detection method resolved once per commit (plan
2251 242) and must match the one that produced *pre_snapshot*. Under
2252 ``"git"`` the post snapshot additionally hashes every path that
2253 carried something other than the CLEAN sentinel in *pre_snapshot*,
2254 so a file that was already dirty when the commit started is still
2255 compared hash to hash - including when it was reverted to its
2256 index content, which would otherwise leave git's dirty set and
2257 read as unchanged.
2258 """
2259 func = "_check_unstaged_mutation"
2260 try:
2261 from oct.core.git import get_repo_root
2262 from oct.git.commit_observer import (
2263 _CLEAN_SENTINEL, diff_snapshots, snapshot_unstaged_tracked,
2264 )
2265 repo_root = get_repo_root(root)
2266 staged_rel: set[str] = set()
2267 for p in staged:
2268 r = _safe_relative(p, repo_root)
2269 if r is not None:
2270 staged_rel.add(r)
2271 also_hash = {
2272 k for k, v in pre_snapshot.items() if v != _CLEAN_SENTINEL
2273 }
2274 post = snapshot_unstaged_tracked(
2275 repo_root, staged_set=staged_rel,
2276 method=method, also_hash=also_hash,
2277 )
2278 except Exception as exc:
2279 _dbg(
2280 "GIT", func,
2281 f"observer post-snapshot failed: {exc!r}; skipping",
2282 1, override=True,
2283 )
2284 return []
2285
2286 mutations = diff_snapshots(pre_snapshot, post)
2287 if mutations:
2288 click.echo(
2289 f"WARNING: {len(mutations)} unstaged tracked file(s) "
2290 f"mutated during {stage_label}:",
2291 err=True,
2292 )
2293 for rel in mutations:
2294 click.echo(f" {rel}", err=True)
2295 return mutations
2296
2297
2299 ctx, *, mutations: list[str], message: str, branch: str | None,
2300 profile: str, json_mode: bool, staged_count: int,
2301) -> None:
2302 """Abort the commit with exit code 6 (B1.0 block mode)."""
2303 click.echo(
2304 "ABORTED: tracked-file mutation detected during commit pipeline. "
2305 "Inspect the listed files and re-run with "
2306 "--ignore-unstaged-mutation to override.",
2307 err=True,
2308 )
2309 if json_mode:
2311 ctx, exit_code=6, message=message, branch=branch,
2312 profile=profile,
2313 errors=[
2314 "Unstaged tracked file mutation detected: "
2315 + ", ".join(mutations),
2316 ],
2317 )
2319 ctx, branch=branch, exit_code=6,
2320 checks_passed=False, staged_count=staged_count,
2321 tracked_mutations=mutations,
2322 )
2323 ctx.exit(6)
2324
2325
2327 root: Path, staged: list[Path], message: str, err: bool = True,
2328) -> None:
2329 """Print advisory warnings for missing docs files."""
2330 staged_names = {p.name for p in staged}
2331 staged_rel = {to_project_relative(p, root) for p in staged}
2332
2333 # Warn if new/deleted .py files without ARCHITECTURE.md staged.
2334 py_staged = [p for p in staged if p.suffix == ".py"]
2335 if len(py_staged) > 0 and "docs/ARCHITECTURE.md" not in staged_rel:
2336 has_arch = (root / "docs" / "ARCHITECTURE.md").is_file()
2337 if has_arch:
2338 click.echo(
2339 "Advisory: ARCHITECTURE.md is not staged. "
2340 "Consider updating it if you changed module structure.",
2341 err=err,
2342 )
2343
2344 # Warn if multi-file commit without CHANGELOG.md staged.
2345 if len(staged) > 3 and "CHANGELOG.md" not in staged_names:
2346 if (root / "CHANGELOG.md").is_file():
2347 click.echo(
2348 "Advisory: CHANGELOG.md is not staged for this multi-file commit.",
2349 err=err,
2350 )
2351
2352
2353# =====================================================================
2354# oct git branch (Phase 4F - G-F1)
2355# =====================================================================
2356
2357
2358@git.command(name="branch")
2359@click.option("--create", "create_name", default=None,
2360 help="Create a new branch from HEAD.")
2361@click.option("--switch", "switch_name", default=None,
2362 help="Switch to an existing branch.")
2363@click.option("--json", "json_mode", is_flag=True, help="Emit JSON output.")
2364@click.pass_context
2365@audited("oct git branch")
2367 ctx: click.Context,
2368 create_name: str | None,
2369 switch_name: str | None,
2370 json_mode: bool,
2371) -> None:
2372 """Manage branches with Option C naming conventions."""
2373 func = "git_branch_cmd"
2374 root = get_project_root(ctx)
2375 _dbg("GIT", func, f"root={root} create={create_name!r} switch={switch_name!r}", 2)
2376
2377 from oct.core.git import (
2378 branch_exists,
2379 create_branch,
2380 has_uncommitted_changes,
2381 is_git_repo,
2382 switch_branch,
2383 )
2384 from oct.git.policy import validate_branch_name
2385
2386 if not is_git_repo(root):
2387 click.echo("Not a git repository.", err=True)
2388 _branch_audit(ctx, exit_code=1)
2389 ctx.exit(1)
2390 return
2391
2392 octrc = load_octrc_safe(root)
2393
2394 # -- Mutual exclusivity ------------------------------------------------
2395 if create_name and switch_name:
2396 msg = "Error: --create and --switch are mutually exclusive."
2397 click.echo(msg, err=True)
2398 if json_mode:
2399 _emit_branch_json(exit_code=1, errors=[msg])
2400 _branch_audit(ctx, exit_code=1)
2401 ctx.exit(1)
2402 return
2403
2404 if not create_name and not switch_name:
2405 msg = "Use --create NAME or --switch NAME. Branch listing coming soon."
2406 click.echo(msg, err=True)
2407 if json_mode:
2408 _emit_branch_json(exit_code=1, errors=[msg])
2409 _branch_audit(ctx, exit_code=1)
2410 ctx.exit(1)
2411 return
2412
2413 # -- --create -----------------------------------------------------------
2414 if create_name:
2415 valid, val_msg = validate_branch_name(create_name, octrc)
2416 if not valid:
2417 click.echo(f"Error: {val_msg}", err=True)
2418 if json_mode:
2420 operation="create", branch=create_name,
2421 exit_code=1, errors=[val_msg],
2422 )
2423 _branch_audit(ctx, exit_code=1, branch=create_name)
2424 ctx.exit(1)
2425 return
2426
2427 if branch_exists(root, create_name):
2428 msg = f"Error: branch '{create_name}' already exists."
2429 click.echo(msg, err=True)
2430 if json_mode:
2432 operation="create", branch=create_name,
2433 exit_code=1, errors=[msg],
2434 )
2435 _branch_audit(ctx, exit_code=1, branch=create_name)
2436 ctx.exit(1)
2437 return
2438
2439 try:
2440 create_branch(root, create_name)
2441 except Exception as exc:
2442 msg = f"Git error: {exc}"
2443 click.echo(msg, err=True)
2444 if json_mode:
2446 operation="create", branch=create_name,
2447 exit_code=2, errors=[msg],
2448 )
2449 _branch_audit(ctx, exit_code=2, branch=create_name)
2450 ctx.exit(2)
2451 return
2452
2453 if json_mode:
2455 operation="create", branch=create_name, exit_code=0,
2456 )
2457 else:
2458 click.echo(f"Created branch '{create_name}' (not switched to it).")
2459 _branch_audit(ctx, exit_code=0, branch=create_name)
2460 return
2461
2462 # -- --switch -----------------------------------------------------------
2463 if switch_name:
2464 if not branch_exists(root, switch_name):
2465 msg = f"Error: branch '{switch_name}' does not exist."
2466 click.echo(msg, err=True)
2467 if json_mode:
2469 operation="switch", branch=switch_name,
2470 exit_code=1, errors=[msg],
2471 )
2472 _branch_audit(ctx, exit_code=1, branch=switch_name)
2473 ctx.exit(1)
2474 return
2475
2476 if has_uncommitted_changes(root):
2477 msg = (
2478 "Error: uncommitted changes would be lost. "
2479 "Commit or stash them first."
2480 )
2481 click.echo(msg, err=True)
2482 if json_mode:
2484 operation="switch", branch=switch_name,
2485 exit_code=1, errors=[msg],
2486 )
2487 _branch_audit(ctx, exit_code=1, branch=switch_name)
2488 ctx.exit(1)
2489 return
2490
2491 try:
2492 switch_branch(root, switch_name)
2493 except Exception as exc:
2494 msg = f"Git error: {exc}"
2495 click.echo(msg, err=True)
2496 if json_mode:
2498 operation="switch", branch=switch_name,
2499 exit_code=2, errors=[msg],
2500 )
2501 _branch_audit(ctx, exit_code=2, branch=switch_name)
2502 ctx.exit(2)
2503 return
2504
2505 if json_mode:
2507 operation="switch", branch=switch_name, exit_code=0,
2508 )
2509 else:
2510 click.echo(f"Switched to branch '{switch_name}'.")
2511 _branch_audit(ctx, exit_code=0, branch=switch_name)
2512 return
2513
2514
2516 *,
2517 operation: str = "",
2518 branch: str = "",
2519 exit_code: int = 0,
2520 errors: list[str] | None = None,
2521) -> None:
2522 """Emit JSON for ``oct git branch`` to stdout."""
2523 click.echo(json.dumps({
2524 "operation": operation,
2525 "branch": branch,
2526 "exit_code": exit_code,
2527 "errors": errors or [],
2528 }, indent=2))
2529
2530
2532 ctx: click.Context,
2533 *,
2534 exit_code: int,
2535 branch: str = "",
2536) -> None:
2537 """Populate the audit record for ``oct git branch``."""
2538 record = ctx.obj.get("_audit_record", AuditRecord())
2539 record.timestamp = record.timestamp or _utc_now_iso()
2540 record.command = "oct git branch"
2541 record.branch = branch or None
2542 record.checks_passed = exit_code == 0
2543 record.exit_code = exit_code
2544 ctx.obj["_audit_record"] = record
2545
2546
2547# =====================================================================
2548# oct git add (Phase 4G - staging command)
2549# =====================================================================
2550
2551
2553 *,
2554 operation: str,
2555 paths_staged: list[str],
2556 paths_skipped: list[dict],
2557 post_check: dict | None,
2558 exit_code: int,
2559 errors: list[str] | None = None,
2560) -> None:
2561 """Emit JSON for ``oct git add`` to stdout."""
2562 click.echo(json.dumps({
2563 "operation": operation,
2564 "paths_staged": paths_staged,
2565 "paths_skipped": paths_skipped,
2566 "post_check": post_check,
2567 "exit_code": exit_code,
2568 "errors": errors or [],
2569 }, indent=2))
2570
2571
2573 ctx: click.Context,
2574 *,
2575 exit_code: int,
2576 staged_count: int = 0,
2577) -> None:
2578 """Populate the audit record for ``oct git add``."""
2579 record = ctx.obj.get("_audit_record", AuditRecord())
2580 record.timestamp = record.timestamp or _utc_now_iso()
2581 record.command = "oct git add"
2582 record.staged_files = staged_count
2583 record.checks_passed = exit_code == 0
2584 record.exit_code = exit_code
2585 ctx.obj["_audit_record"] = record
2586
2587
2589 root: Path,
2590 octrc: dict | None,
2591) -> dict | None:
2592 """Run the staged-only quality gate and return a summary dict.
2593
2594 Honours ``git.add_post_check`` (default ``True``). Returns ``None``
2595 when disabled.
2596
2597 Plan 285 (OCTGIT-06): at a workspace root the staged set is gated
2598 through :func:`_run_fanout_gate` - the ONE fan-out implementation
2599 ``oct git check`` (plan 213 / D-B) and ``oct git commit`` (F1.3)
2600 already share - so the advisory and the gate cannot report different
2601 verdicts for the same staged set. They did before: this ran a single
2602 :func:`~oct.git.quality_gate.run_quality_gate` rooted at the
2603 invocation root, and at a workspace root that root has no octrc and
2604 matches no subproject, so the profile collapsed to ``compact``, every
2605 subproject-relative identity header failed the header rule against
2606 the workspace-relative path, and the header fixer counted as the
2607 format issue. A clean file therefore drew ``FAIL (lint=1 format=1)``
2608 from ``add`` while ``check --staged-only`` reported ``0/0``.
2609
2610 In workspace mode the returned ``profile`` is the ``(bucket,
2611 profile)`` list the fan-out actually used, so the JSON says which
2612 profile gated which file, and ``unbucketed`` carries the fail-closed
2613 signal: when it is non-empty NO gate ran and the verdict is 2, as it
2614 is for the commit and check paths. Invocation from inside a
2615 subproject, or in a non-workspace repo, is byte-identical to pre-285
2616 - :func:`_maybe_load_workspace` returns ``(None, False)`` in both
2617 cases - and so is the degenerate empty-staged-set case, which has no
2618 buckets to name.
2619
2620 The advisory remains advisory: ``oct git add`` still exits 0 over a
2621 FAIL verdict (the plan-208 contract). What changes is only whether
2622 the verdict is true.
2623 """
2624 func = "_post_check_summary"
2625 if isinstance(octrc, dict):
2626 git_cfg = octrc.get("git")
2627 if isinstance(git_cfg, dict):
2628 raw = git_cfg.get("add_post_check")
2629 if isinstance(raw, bool) and not raw:
2630 _dbg("GIT", func, "post_check disabled by octrc", 3)
2631 return None
2632 try:
2633 from oct.core.git import current_branch
2634 from oct.git.policy import resolve_profile_for_project
2635 from oct.git.quality_gate import run_quality_gate
2636 except ImportError:
2637 _dbg("GIT", func, "quality_gate import failed; skipping", 1, override=True)
2638 return None
2639 try:
2640 branch = current_branch(root)
2641 except Exception:
2642 branch = None
2643 # Plan 197: manifest-aware, so the post-add summary reports the same
2644 # profile the commit gate will actually apply.
2645 profile = resolve_profile_for_project(root, branch, octrc, None)
2646
2647 # Plan 285: one decision, made where the other paths make it. The
2648 # fan-out owns the per-subproject roots and profiles; nothing about
2649 # bucketing, profile resolution or the secrets pass is re-derived
2650 # here. The G-D4 invariant crosses buckets unchanged, because the
2651 # merged result concatenates every bucket's findings and the printer
2652 # still gives SECRETS DETECTED precedence over a lint/format FAIL.
2653 workspace, in_workspace_mode = _maybe_load_workspace(root)
2654 if in_workspace_mode and workspace is not None and workspace.subprojects:
2655 try:
2656 from oct.git.quality_gate import _resolve_scope
2657 staged = _resolve_scope(root, "staged")
2658 except Exception as exc:
2659 _dbg(
2660 "GIT", func,
2661 f"staged scope resolution raised {exc!r}; falling back to "
2662 f"the single-project summary",
2663 1, override=True,
2664 )
2665 staged = []
2666 if staged:
2667 try:
2668 outcome = _run_fanout_gate(
2669 workspace, staged,
2670 cli_profile=None, branch=branch,
2671 fallback_profile=profile, include_tests=False,
2672 )
2673 except Exception as exc:
2674 _dbg(
2675 "GIT", func,
2676 f"fan-out gate raised {exc!r}; skipping", 1,
2677 override=True,
2678 )
2679 return None
2680 merged = outcome.result
2681 _dbg(
2682 "GIT", func,
2683 f"fan-out summary buckets={list(outcome.per_bucket_meta)} "
2684 f"verdict={outcome.gate_exit}",
2685 3,
2686 )
2687 return {
2688 "profile": [
2689 [name, meta.get("profile", profile)]
2690 for name, meta in outcome.per_bucket_meta.items()
2691 ],
2692 "lint_violations": merged.lint_violations,
2693 "format_violations": merged.format_violations,
2694 "secrets_findings": len(merged.secrets_findings),
2695 "exit_code": outcome.gate_exit,
2696 "unbucketed": [
2697 to_project_relative(p, root) for p in outcome.unbucketed
2698 ],
2699 }
2700
2701 try:
2702 result = run_quality_gate(
2703 project_root=root, scope="staged",
2704 profile=profile, fix=False, include_tests=False,
2705 )
2706 except Exception as exc:
2707 _dbg("GIT", func, f"quality_gate raised {exc!r}; skipping", 1, override=True)
2708 return None
2709 # Plan 208 (D2): the real gate verdict is what `apply_profile_policy`
2710 # computes, NOT `result.exit_code` - `run_quality_gate` leaves that field at
2711 # its dataclass default 0, so reading it made the advisory print PASS over a
2712 # staged secret. Compute the verdict the commit gate would actually apply.
2713 from oct.git.policy import apply_profile_policy
2714 verdict = apply_profile_policy(profile, result)
2715 return {
2716 "profile": profile,
2717 "lint_violations": result.lint_violations,
2718 "format_violations": result.format_violations,
2719 "secrets_findings": len(result.secrets_findings),
2720 "exit_code": verdict,
2721 }
2722
2723
2724@git.command(name="add")
2725@click.argument("paths", nargs=-1, type=click.Path())
2726@click.option(
2727 "--patch", "-p", "patch_mode", is_flag=True,
2728 help="Interactive hunk-level staging (passthrough to git add -p).",
2729)
2730@click.option(
2731 "--update", "-u", "update_mode", is_flag=True,
2732 help="Stage all already-tracked modified files (-u).",
2733)
2734@click.option(
2735 "--all", "-A", "all_mode", is_flag=True,
2736 help="Stage all changes within project scope (-A).",
2737)
2738@click.option(
2739 "--dry-run", is_flag=True,
2740 help="Show what would be staged without staging.",
2741)
2742@click.option(
2743 "--json", "json_mode", is_flag=True, help="Emit JSON output.",
2744)
2745@click.pass_context
2746@audited("oct git add")
2748 ctx: click.Context,
2749 paths: tuple[str, ...],
2750 patch_mode: bool,
2751 update_mode: bool,
2752 all_mode: bool,
2753 dry_run: bool,
2754 json_mode: bool,
2755) -> None:
2756 """Stage files for commit (whole-file or interactive hunk)."""
2757 func = "git_add_cmd"
2758 root = get_project_root(ctx)
2759 _dbg(
2760 "GIT", func,
2761 f"root={root} paths={paths} patch={patch_mode} "
2762 f"update={update_mode} all={all_mode} dry_run={dry_run}",
2763 2,
2764 )
2765
2766 from oct.core.git import (
2767 get_repo_root,
2768 is_git_repo,
2769 stage_paths,
2770 stage_paths_interactive,
2771 )
2772
2773 if not is_git_repo(root):
2774 click.echo("Not a git repository.", err=True)
2775 _add_audit(ctx, exit_code=1)
2776 ctx.exit(1)
2777 return
2778
2779 repo_root = get_repo_root(root)
2780 octrc = load_octrc_safe(root)
2781
2782 # -- Mutual exclusivity --------------------------------------------------
2783 if patch_mode and (update_mode or all_mode):
2784 msg = "Error: --patch cannot be combined with --update or --all."
2785 click.echo(msg, err=True)
2786 if json_mode:
2788 operation="patch", paths_staged=[], paths_skipped=[],
2789 post_check=None, exit_code=1, errors=[msg],
2790 )
2791 _add_audit(ctx, exit_code=1)
2792 ctx.exit(1)
2793 return
2794
2795 if update_mode and all_mode:
2796 msg = "Error: --update and --all are mutually exclusive."
2797 click.echo(msg, err=True)
2798 if json_mode:
2800 operation="add", paths_staged=[], paths_skipped=[],
2801 post_check=None, exit_code=1, errors=[msg],
2802 )
2803 _add_audit(ctx, exit_code=1)
2804 ctx.exit(1)
2805 return
2806
2807 # -- Resolve and validate paths ------------------------------------------
2808 root_resolved = root.resolve()
2809 skipped: list[dict] = []
2810 resolved_paths: list[Path] = []
2811 for raw in paths:
2812 candidate = (Path.cwd() / raw).resolve() \
2813 if not Path(raw).is_absolute() else Path(raw).resolve()
2814 if not _is_within_project(candidate, root_resolved):
2815 skipped.append({
2816 "path": str(raw), "reason": "outside project scope",
2817 })
2818 continue
2819 resolved_paths.append(candidate)
2820
2821 if skipped and not json_mode:
2822 for entry in skipped:
2823 click.echo(
2824 f"Skipping {entry['path']} ({entry['reason']}).",
2825 err=True,
2826 )
2827
2828 # -- Empty selection guard -----------------------------------------------
2829 if not resolved_paths and not (update_mode or all_mode or patch_mode):
2830 msg = (
2831 "Error: no paths to stage. Provide PATHS, "
2832 "or use --update / --all / --patch."
2833 )
2834 click.echo(msg, err=True)
2835 if json_mode:
2837 operation="add", paths_staged=[], paths_skipped=skipped,
2838 post_check=None, exit_code=1, errors=[msg],
2839 )
2840 _add_audit(ctx, exit_code=1)
2841 ctx.exit(1)
2842 return
2843
2844 # -- Dry-run -------------------------------------------------------------
2845 if dry_run:
2846 operation = "dry-run"
2847 rel_strings = [
2848 to_project_relative(p, root) for p in resolved_paths
2849 ]
2850 if update_mode or all_mode:
2851 # Compute what git would stage for -u / -A by inspecting status.
2852 from oct.core.git import git_run as _git_run
2853 argv = ["status", "--porcelain"]
2854 proc = _git_run(argv, cwd=repo_root)
2855 for line in proc.stdout.splitlines():
2856 # Porcelain format: "XY path"; XY are status flags.
2857 if len(line) < 4:
2858 continue
2859 xy = line[:2]
2860 rel = line[3:].strip()
2861 if update_mode and xy[1] in (" ", ""):
2862 # Tracked + unmodified - git add -u skips it.
2863 continue
2864 if update_mode and xy[1] == "?":
2865 continue
2866 full = (repo_root / rel).resolve()
2867 if not _is_within_project(full, root_resolved):
2868 continue
2869 rel_strings.append(to_project_relative(full, root))
2870 if json_mode:
2872 operation=operation, paths_staged=rel_strings,
2873 paths_skipped=skipped, post_check=None, exit_code=0,
2874 )
2875 else:
2876 click.echo("Would stage:")
2877 for s in rel_strings:
2878 click.echo(f" {s}")
2879 _add_audit(ctx, exit_code=0, staged_count=len(rel_strings))
2880 return
2881
2882 # -- Patch mode ----------------------------------------------------------
2883 if patch_mode:
2884 if json_mode:
2885 click.echo(
2886 "Note: --patch is interactive; --json output is limited.",
2887 err=True,
2888 )
2889 try:
2890 rc = stage_paths_interactive(repo_root, resolved_paths)
2891 except Exception as exc:
2892 msg = f"Git error: {exc}"
2893 click.echo(msg, err=True)
2894 if json_mode:
2896 operation="patch", paths_staged=[],
2897 paths_skipped=skipped, post_check=None,
2898 exit_code=2, errors=[msg],
2899 )
2900 _add_audit(ctx, exit_code=2)
2901 ctx.exit(2)
2902 return
2903 if rc != 0:
2904 _add_audit(ctx, exit_code=rc)
2905 ctx.exit(rc)
2906 return
2907 post = _post_check_summary(root, octrc)
2908 if json_mode:
2910 operation="patch", paths_staged=[],
2911 paths_skipped=skipped, post_check=post, exit_code=0,
2912 )
2913 else:
2915 _add_audit(ctx, exit_code=0)
2916 return
2917
2918 # -- Whole-file staging --------------------------------------------------
2919 # L2: capture pre-stage index so we can decide, on a non-zero git
2920 # exit, whether anything actually got staged. Git emits exit 1
2921 # alongside the stderr "ignored path" warning when staging tracked
2922 # files inside an ignored directory; the operation succeeds but the
2923 # exit code lies. We mirror plain-git semantics by treating
2924 # "warning + something staged" as success.
2925 from oct.core.git import git_staged_files
2926 pre_index = {p.resolve() for p in git_staged_files(repo_root)}
2927
2928 git_warning: str | None = None
2929 try:
2930 stage_paths(
2931 repo_root, resolved_paths,
2932 update=update_mode, all_=all_mode,
2933 )
2934 except Exception as exc:
2935 # Re-query the index. If it grew, this was a "warning"-class
2936 # git failure (exit 1 + something staged). Treat as success
2937 # with the stderr message preserved.
2938 post_index = {p.resolve() for p in git_staged_files(repo_root)}
2939 newly_staged = post_index - pre_index
2940 if newly_staged:
2941 git_warning = str(exc)
2942 click.echo(
2943 f"Note: git emitted a warning during staging but "
2944 f"{len(newly_staged)} file(s) were staged successfully:\n"
2945 f" {git_warning}",
2946 err=True,
2947 )
2948 else:
2949 msg = f"Git error: {exc}"
2950 click.echo(msg, err=True)
2951 if json_mode:
2953 operation="add", paths_staged=[],
2954 paths_skipped=skipped,
2955 post_check=None, exit_code=2, errors=[msg],
2956 )
2957 _add_audit(ctx, exit_code=2)
2958 ctx.exit(2)
2959 return
2960
2961 # Compute what was actually staged for reporting.
2962 staged_now = git_staged_files(repo_root)
2963 staged_within = [
2964 p for p in staged_now if _is_within_project(p, root_resolved)
2965 ]
2966 staged_strings = [to_project_relative(p, root) for p in staged_within]
2967
2968 operation = "all" if all_mode else "update" if update_mode else "add"
2969 post = _post_check_summary(root, octrc)
2970
2971 if json_mode:
2973 operation=operation, paths_staged=staged_strings,
2974 paths_skipped=skipped, post_check=post, exit_code=0,
2975 )
2976 else:
2977 if resolved_paths:
2978 for p in resolved_paths:
2979 click.echo(f"Staged {to_project_relative(p, root)}")
2980 elif update_mode:
2981 click.echo(
2982 f"Staged {len(staged_strings)} tracked modified file(s).",
2983 )
2984 elif all_mode:
2985 click.echo(
2986 f"Staged {len(staged_strings)} file(s) (including untracked).",
2987 )
2989
2990 _add_audit(ctx, exit_code=0, staged_count=len(staged_strings))
2991
2992
2993def _print_post_check_advisory(post: dict | None) -> None:
2994 """Print a single-line summary of the post-stage quality gate result.
2995
2996 Plan 285: the three shapes and their precedence are unchanged, and in
2997 single-project mode the bytes are unchanged too. In workspace mode
2998 ``post["profile"]`` is the ``(bucket, profile)`` list the fan-out used
2999 rather than one profile name, and the line appends it in one short
3000 parenthesis - ``Quality gate: PASS (oc_chat strict; workspace
3001 compact)`` - so a verdict says where it looked and an operator can
3002 see at a glance that their file was gated at its own profile.
3003
3004 A non-empty ``unbucketed`` replaces that parenthesis with the files
3005 no bucket claimed: no gate ran over them, so the counts beside the
3006 verdict describe nothing and the names are the only useful detail.
3007 """
3008 if post is None:
3009 return
3010 buckets = post.get("profile")
3011 where = ""
3012 if isinstance(buckets, list):
3013 unbucketed = post.get("unbucketed") or []
3014 if unbucketed:
3015 where = (
3016 f" (assigned to no gate scope, so NOT checked: "
3017 f"{', '.join(unbucketed)})"
3018 )
3019 elif buckets:
3020 named = "; ".join(f"{name} {prof}" for name, prof in buckets)
3021 where = f" ({named})"
3022 if post["exit_code"] == 0:
3023 click.echo(f"Quality gate: PASS{where}", err=True)
3024 elif post["secrets_findings"]:
3025 # Plan 208 (D2): secrets are the non-bypassable class - call them out
3026 # first and unambiguously so a staged credential can never read as a
3027 # generic lint failure, let alone as PASS. Plan 285: a finding in ANY
3028 # bucket reaches the merged result, so this precedence holds across
3029 # the fan-out exactly as it held over the single pass.
3030 click.echo(
3031 f"Quality gate: SECRETS DETECTED "
3032 f"({post['secrets_findings']} finding(s); "
3033 f"lint={post['lint_violations']} "
3034 f"format={post['format_violations']}){where}. "
3035 f"Run `oct git check --staged-only` for details.",
3036 err=True,
3037 )
3038 else:
3039 click.echo(
3040 f"Quality gate: FAIL "
3041 f"(lint={post['lint_violations']} "
3042 f"format={post['format_violations']} "
3043 f"secrets={post['secrets_findings']}){where}. "
3044 f"Run `oct git check --staged-only` for details.",
3045 err=True,
3046 )
3047
3048
3049# =====================================================================
3050# oct git ignore (Phase 4G - .gitignore manager)
3051# =====================================================================
3052
3053
3055 *,
3056 operation: str,
3057 patterns_added: list[str] | None = None,
3058 patterns_removed: list[str] | None = None,
3059 oct_managed: list[str] | None = None,
3060 other: list[str] | None = None,
3061 exit_code: int = 0,
3062 errors: list[str] | None = None,
3063) -> None:
3064 """Emit JSON for ``oct git ignore`` to stdout."""
3065 payload: dict = {
3066 "operation": operation,
3067 "exit_code": exit_code,
3068 "errors": errors or [],
3069 }
3070 if patterns_added is not None:
3071 payload["patterns_added"] = patterns_added
3072 if patterns_removed is not None:
3073 payload["patterns_removed"] = patterns_removed
3074 if oct_managed is not None:
3075 payload["oct_managed"] = oct_managed
3076 if other is not None:
3077 payload["other"] = other
3078 click.echo(json.dumps(payload, indent=2))
3079
3080
3082 ctx: click.Context,
3083 *,
3084 exit_code: int,
3085) -> None:
3086 """Populate the audit record for ``oct git ignore``."""
3087 record = ctx.obj.get("_audit_record", AuditRecord())
3088 record.timestamp = record.timestamp or _utc_now_iso()
3089 record.command = "oct git ignore"
3090 record.checks_passed = exit_code == 0
3091 record.exit_code = exit_code
3092 ctx.obj["_audit_record"] = record
3093
3094
3095# =====================================================================
3096# F1.4 ignore --scope helpers
3097# =====================================================================
3098
3099
3101 cli_scope: str | None,
3102 octrc: dict | None,
3103) -> str:
3104 """Apply scope precedence: CLI > octrc > 'nearest'.
3105
3106 Mirrors the precedence used by ``oct git status --scope``.
3107 """
3108 if cli_scope in ("workspace", "project", "nearest"):
3109 return cli_scope
3110 if isinstance(octrc, dict):
3111 git_cfg = octrc.get("git")
3112 if isinstance(git_cfg, dict):
3113 raw = git_cfg.get("ignore_default_scope")
3114 if raw in ("workspace", "project", "nearest"):
3115 return raw
3116 return "nearest"
3117
3118
3120 *,
3121 project_root: Path,
3122 repo_root: Path,
3123 scope: str,
3124) -> tuple[Path | None, str | None]:
3125 """Resolve the directory whose ``.gitignore`` should be edited.
3126
3127 Returns ``(target_dir, error_message)`` - exactly one is ``None``.
3128
3129 Scope semantics:
3130
3131 - ``workspace``: walk up from *project_root* to find a workspace
3132 manifest; the workspace root is the target. Errors when no
3133 manifest exists above CWD.
3134 - ``project``: the *project_root* itself.
3135 - ``nearest``: workspace root if *project_root* IS a workspace
3136 root (i.e. discovery resolved through the manifest fallback);
3137 otherwise *project_root* (a subproject or a single project).
3138 """
3139 from oct.core.workspace import (
3140 WORKSPACE_MANIFEST,
3141 find_workspace_root,
3142 )
3143 if scope == "workspace":
3144 ws_path = find_workspace_root(project_root)
3145 if ws_path is None:
3146 return None, (
3147 "scope=workspace requires a workspace manifest "
3148 f"({WORKSPACE_MANIFEST}) above CWD; none found"
3149 )
3150 return ws_path, None
3151 if scope == "project":
3152 return project_root, None
3153 # scope == "nearest"
3154 project_root_resolved = project_root.resolve()
3155 is_at_workspace_root = (
3156 project_root_resolved
3157 / WORKSPACE_MANIFEST
3158 ).is_file()
3159 if is_at_workspace_root:
3160 return project_root_resolved, None
3161 return project_root, None
3162
3163
3164# =====================================================================
3165# oct git restore (Phase 4G-followup L3)
3166# =====================================================================
3167
3168
3169def _restore_audit(ctx: click.Context, *, exit_code: int) -> None:
3170 """Populate the audit record for ``oct git restore``."""
3171 record = ctx.obj.get("_audit_record", AuditRecord())
3172 record.timestamp = record.timestamp or _utc_now_iso()
3173 record.command = "oct git restore"
3174 record.checks_passed = exit_code == 0
3175 record.exit_code = exit_code
3176 ctx.obj["_audit_record"] = record
3177
3178
3179@git.command(name="restore")
3180@click.argument("paths", nargs=-1, type=click.Path())
3181@click.option(
3182 "--staged", "staged_mode", is_flag=True,
3183 help="Unstage paths from the index (leaves working tree intact).",
3184)
3185@click.option(
3186 "--all", "-A", "all_mode", is_flag=True,
3187 help="Restore all in-scope paths (requires explicit confirmation when used with --staged).",
3188)
3189@click.option(
3190 "--yes", "skip_prompt", is_flag=True,
3191 help="Skip the confirmation prompt for --staged --all.",
3192)
3193@click.option(
3194 "--json", "json_mode", is_flag=True, help="Emit JSON output.",
3195)
3196@click.pass_context
3197@audited("oct git restore")
3199 ctx: click.Context,
3200 paths: tuple[str, ...],
3201 staged_mode: bool,
3202 all_mode: bool,
3203 skip_prompt: bool,
3204 json_mode: bool,
3205) -> None:
3206 """Restore working-tree edits or unstage paths.
3207
3208 Without ``--staged``: discards unstaged working-tree edits in
3209 *paths*, reverting them to the index (i.e. ``git restore <paths>``).
3210
3211 With ``--staged``: removes *paths* from the index without touching
3212 the working tree (i.e. ``git restore --staged <paths>``).
3213
3214 With ``--all``: scope is every modified file inside the OCT
3215 project root. ``--staged --all`` requires confirmation (irreversible
3216 destruction of all staged content); pass ``--yes`` to skip.
3217 """
3218 func = "git_restore_cmd"
3219 root = get_project_root(ctx)
3220 _dbg(
3221 "GIT", func,
3222 f"root={root} paths={paths} staged={staged_mode} "
3223 f"all={all_mode} json={json_mode}",
3224 2,
3225 )
3226
3227 from oct.core.git import (
3228 get_repo_root, is_git_repo, restore_paths,
3229 )
3230
3231 if not is_git_repo(root):
3232 click.echo("Not a git repository.", err=True)
3233 _restore_audit(ctx, exit_code=1)
3234 ctx.exit(1)
3235 return
3236
3237 repo_root = get_repo_root(root)
3238 root_resolved = root.resolve()
3239
3240 if not paths and not all_mode:
3241 msg = "Error: provide PATHS or use --all."
3242 click.echo(msg, err=True)
3243 if json_mode:
3244 click.echo(json.dumps({
3245 "operation": "restore",
3246 "paths_restored": [], "exit_code": 1, "errors": [msg],
3247 }, indent=2))
3248 _restore_audit(ctx, exit_code=1)
3249 ctx.exit(1)
3250 return
3251
3252 # Resolve and scope-filter explicit paths.
3253 skipped: list[dict] = []
3254 resolved_paths: list[Path] = []
3255 for raw in paths:
3256 candidate = Path(raw)
3257 if not candidate.is_absolute():
3258 candidate = (Path.cwd() / candidate).resolve()
3259 else:
3260 candidate = candidate.resolve()
3261 if not _is_within_project(candidate, root_resolved):
3262 skipped.append(
3263 {"path": str(raw), "reason": "outside project scope"},
3264 )
3265 continue
3266 resolved_paths.append(candidate)
3267
3268 if skipped and not json_mode:
3269 for entry in skipped:
3270 click.echo(
3271 f"Skipping {entry['path']} ({entry['reason']}).",
3272 err=True,
3273 )
3274
3275 # --all mode: enumerate the in-scope candidates from git.
3276 if all_mode:
3277 from oct.core.git import git_run as _gr
3278 if staged_mode:
3279 argv = ["diff", "--cached", "--name-only"]
3280 else:
3281 argv = ["diff", "--name-only"]
3282 proc = _gr(argv, cwd=repo_root)
3283 for line in proc.stdout.splitlines():
3284 rel = line.strip()
3285 if not rel:
3286 continue
3287 full = (repo_root / rel).resolve()
3288 if _is_within_project(full, root_resolved):
3289 resolved_paths.append(full)
3290
3291 if not resolved_paths:
3292 msg = (
3293 "Nothing to restore (no in-scope paths matched)."
3294 if all_mode else
3295 "Nothing to restore."
3296 )
3297 if json_mode:
3298 click.echo(json.dumps({
3299 "operation": "restore",
3300 "paths_restored": [], "exit_code": 0,
3301 "skipped": skipped, "errors": [],
3302 }, indent=2))
3303 else:
3304 click.echo(msg)
3305 _restore_audit(ctx, exit_code=0)
3306 return
3307
3308 # Confirmation prompt for --staged --all (irreversibly drops the
3309 # staged changes for every file in scope).
3310 if staged_mode and all_mode and not skip_prompt and not json_mode:
3311 click.echo(
3312 f"About to UNSTAGE {len(resolved_paths)} file(s). This "
3313 f"discards every staged change in the project scope and "
3314 f"cannot be undone. Continue? [y/N] ",
3315 nl=False, err=True,
3316 )
3317 try:
3318 answer = click.get_text_stream("stdin").readline().strip().lower()
3319 except (KeyboardInterrupt, OSError):
3320 answer = ""
3321 if answer not in ("y", "yes"):
3322 click.echo("Aborted.", err=True)
3323 _restore_audit(ctx, exit_code=1)
3324 ctx.exit(1)
3325 return
3326
3327 try:
3328 restore_paths(repo_root, resolved_paths, staged=staged_mode)
3329 except Exception as exc:
3330 msg = f"Git error: {exc}"
3331 click.echo(msg, err=True)
3332 if json_mode:
3333 click.echo(json.dumps({
3334 "operation": "restore",
3335 "paths_restored": [], "exit_code": 2,
3336 "errors": [msg],
3337 }, indent=2))
3338 _restore_audit(ctx, exit_code=2)
3339 ctx.exit(2)
3340 return
3341
3342 rel_strings = [to_project_relative(p, root) for p in resolved_paths]
3343
3344 if json_mode:
3345 click.echo(json.dumps({
3346 "operation": "restore",
3347 "staged": staged_mode,
3348 "paths_restored": rel_strings,
3349 "skipped": skipped,
3350 "exit_code": 0,
3351 "errors": [],
3352 }, indent=2))
3353 else:
3354 verb = "Unstaged" if staged_mode else "Restored"
3355 click.echo(f"{verb} {len(rel_strings)} file(s):")
3356 for rel in rel_strings:
3357 click.echo(f" {rel}")
3358 _restore_audit(ctx, exit_code=0)
3359
3360
3361@git.command(name="ignore")
3362@click.argument("patterns", nargs=-1)
3363@click.option(
3364 "--remove", "remove_pattern", default=None,
3365 help="Remove a pattern from the OCT-managed block.",
3366)
3367@click.option(
3368 "--list", "list_mode", is_flag=True,
3369 help="List currently active ignore patterns.",
3370)
3371@click.option(
3372 "--scope", "scope",
3373 type=click.Choice(["workspace", "project", "nearest"]),
3374 default=None,
3375 help=(
3376 "Which .gitignore to target: 'workspace' (workspace-root file), "
3377 "'project' (subproject-root file), 'nearest' (workspace if at "
3378 "workspace root, subproject otherwise; default)."
3379 ),
3380)
3381@click.option(
3382 "--json", "json_mode", is_flag=True, help="Emit JSON output.",
3383)
3384@click.pass_context
3385@audited("oct git ignore")
3387 ctx: click.Context,
3388 patterns: tuple[str, ...],
3389 remove_pattern: str | None,
3390 list_mode: bool,
3391 scope: str | None,
3392 json_mode: bool,
3393) -> None:
3394 """Manage .gitignore patterns within the OCT-managed block."""
3395 func = "git_ignore_cmd"
3396 root = get_project_root(ctx)
3397 _dbg(
3398 "GIT", func,
3399 f"root={root} patterns={patterns} "
3400 f"remove={remove_pattern!r} list={list_mode} scope={scope!r}",
3401 2,
3402 )
3403
3404 from oct.core.git import get_repo_root, is_git_repo
3405 from oct.git.policy import (
3406 add_ignore_patterns,
3407 list_ignore_patterns,
3408 remove_ignore_pattern,
3409 validate_ignore_pattern,
3410 )
3411
3412 if not is_git_repo(root):
3413 click.echo("Not a git repository.", err=True)
3414 _ignore_audit(ctx, exit_code=1)
3415 ctx.exit(1)
3416 return
3417
3418 repo_root = get_repo_root(root)
3419
3420 # F1.4: resolve which .gitignore to target.
3421 octrc = load_octrc_safe(root)
3422 effective_scope = _resolve_ignore_scope(scope, octrc)
3423 target_dir, scope_err = _resolve_ignore_target_dir(
3424 project_root=root,
3425 repo_root=repo_root,
3426 scope=effective_scope,
3427 )
3428 if scope_err is not None:
3429 click.echo(f"Error: {scope_err}", err=True)
3430 if json_mode:
3432 operation="ignore", exit_code=1, errors=[scope_err],
3433 )
3434 _ignore_audit(ctx, exit_code=1)
3435 ctx.exit(1)
3436 return
3437 _dbg(
3438 "GIT", func,
3439 f"effective_scope={effective_scope} target_dir={target_dir}",
3440 3,
3441 )
3442
3443 # -- Mutual exclusivity --------------------------------------------------
3444 op_count = sum([bool(patterns), bool(remove_pattern), bool(list_mode)])
3445 if op_count > 1:
3446 msg = "Error: PATTERNS, --remove, and --list are mutually exclusive."
3447 click.echo(msg, err=True)
3448 if json_mode:
3450 operation="ignore", exit_code=1, errors=[msg],
3451 )
3452 _ignore_audit(ctx, exit_code=1)
3453 ctx.exit(1)
3454 return
3455 if op_count == 0:
3456 msg = (
3457 "Use PATTERNS to add, --remove PATTERN to remove, "
3458 "or --list to show current patterns."
3459 )
3460 click.echo(msg, err=True)
3461 if json_mode:
3463 operation="ignore", exit_code=1, errors=[msg],
3464 )
3465 _ignore_audit(ctx, exit_code=1)
3466 ctx.exit(1)
3467 return
3468
3469 # -- --list --------------------------------------------------------------
3470 if list_mode:
3471 oct_managed, other = list_ignore_patterns(target_dir)
3472 if json_mode:
3474 operation="list", oct_managed=oct_managed,
3475 other=other, exit_code=0,
3476 )
3477 else:
3478 click.echo(
3479 f"OCT-managed patterns ({len(oct_managed)}):"
3480 )
3481 for p in oct_managed:
3482 click.echo(f" {p}")
3483 click.echo()
3484 click.echo(
3485 f"Other patterns ({len(other)}, read-only via oct):"
3486 )
3487 for p in other:
3488 click.echo(f" {p}")
3489 _ignore_audit(ctx, exit_code=0)
3490 return
3491
3492 # -- --remove ------------------------------------------------------------
3493 if remove_pattern:
3494 valid, val_msg = validate_ignore_pattern(remove_pattern)
3495 if not valid:
3496 click.echo(f"Error: {val_msg}", err=True)
3497 if json_mode:
3499 operation="remove", exit_code=1, errors=[val_msg],
3500 )
3501 _ignore_audit(ctx, exit_code=1)
3502 ctx.exit(1)
3503 return
3504 try:
3505 removed, msg = remove_ignore_pattern(target_dir, remove_pattern)
3506 except OSError as exc:
3507 err = f"IO error: {exc}"
3508 click.echo(err, err=True)
3509 if json_mode:
3511 operation="remove", exit_code=2, errors=[err],
3512 )
3513 _ignore_audit(ctx, exit_code=2)
3514 ctx.exit(2)
3515 return
3516 if not removed:
3517 click.echo(f"Error: {msg}", err=True)
3518 if json_mode:
3520 operation="remove", patterns_removed=[],
3521 exit_code=1, errors=[msg],
3522 )
3523 _ignore_audit(ctx, exit_code=1)
3524 ctx.exit(1)
3525 return
3526 if json_mode:
3528 operation="remove",
3529 patterns_removed=[remove_pattern], exit_code=0,
3530 )
3531 else:
3532 click.echo(f"Removed pattern '{remove_pattern}' from .gitignore.")
3533 _ignore_audit(ctx, exit_code=0)
3534 return
3535
3536 # -- Add patterns --------------------------------------------------------
3537 validated: list[str] = []
3538 errors: list[str] = []
3539 for pat in patterns:
3540 valid, val_msg = validate_ignore_pattern(pat)
3541 if not valid:
3542 errors.append(f"{pat!r}: {val_msg}")
3543 else:
3544 validated.append(pat)
3545
3546 if errors:
3547 for e in errors:
3548 click.echo(f"Error: {e}", err=True)
3549 if json_mode:
3551 operation="add", patterns_added=[],
3552 exit_code=1, errors=errors,
3553 )
3554 _ignore_audit(ctx, exit_code=1)
3555 ctx.exit(1)
3556 return
3557
3558 try:
3559 added = add_ignore_patterns(target_dir, validated)
3560 except OSError as exc:
3561 err = f"IO error: {exc}"
3562 click.echo(err, err=True)
3563 if json_mode:
3565 operation="add", exit_code=2, errors=[err],
3566 )
3567 _ignore_audit(ctx, exit_code=2)
3568 ctx.exit(2)
3569 return
3570
3571 if json_mode:
3573 operation="add", patterns_added=added, exit_code=0,
3574 )
3575 else:
3576 if added:
3577 click.echo(
3578 f"Added {len(added)} pattern(s) to .gitignore "
3579 f"(OCT-managed block):"
3580 )
3581 for p in added:
3582 click.echo(f" {p}")
3583 else:
3584 click.echo("All patterns already present in .gitignore.")
3585 _ignore_audit(ctx, exit_code=0)
3586
3587
3588# The positional arguments are gitignore PATTERNS, not paths. On Windows,
3589# Click expands every argv token that happens to match something on disk
3590# (cmd and PowerShell do no expansion themselves), which turned
3591# "**/docs/html/" into the fourteen directories it matched and got each
3592# one rejected by validate_ignore_pattern. The attribute tells OctGroup.main
3593# to hand this command its argv verbatim; no other command is affected.
3594# See oct.cli._expand_windows_args (2026-09-27).
3595git_ignore_cmd.no_glob_expansion = True # type: ignore[attr-defined]
3596
3597
3598# =====================================================================
3599# oct git commit-msg (OI-503 - entry for pre-commit-framework commit-msg stage)
3600# =====================================================================
3601
3602
3603@git.command(name="commit-msg")
3604@click.argument(
3605 "message_file",
3606 type=click.Path(exists=True, dir_okay=False, path_type=Path),
3607)
3608@click.option("--json", "json_mode", is_flag=True, help="Emit JSON result.")
3609@click.pass_context
3610def git_commit_msg_cmd(ctx: click.Context, message_file: Path, json_mode: bool) -> None:
3611 """Validate a Conventional Commit message file.
3612
3613 Intended as the entry for the ``commit-msg`` pre-commit-framework stage:
3614 ``pre-commit`` invokes ``oct git commit-msg <file>`` with the path to
3615 ``.git/COMMIT_EDITMSG``. Exits 0 on valid, 1 on invalid, 2 on I/O errors.
3616 """
3617 func = "git_commit_msg_cmd"
3618 _dbg("GIT", func, f"validating {message_file}", 2)
3619
3620 from oct.git.conventional import (
3621 format_validation_errors,
3622 validate_ai_model_trailer,
3623 validate_commit_message,
3624 )
3625
3626 try:
3627 text = message_file.read_text(encoding="utf-8")
3628 except OSError as exc:
3629 if json_mode:
3630 click.echo(
3631 json.dumps(
3632 {"valid": False, "errors": [f"cannot read {message_file}: {exc}"]},
3633 indent=2,
3634 )
3635 )
3636 else:
3637 click.echo(
3638 f"[oct git commit-msg] cannot read {message_file}: {exc}",
3639 err=True,
3640 )
3641 ctx.exit(2)
3642 return
3643
3644 errors = list(validate_commit_message(text))
3645 # Option C commit-forensics convention (Plan 151 T6, inventory item 4):
3646 # enforce the AI-Model trailer only when ``git.require_ai_model_trailer``
3647 # is enabled in the project's ``.octrc.json`` (default off).
3648 try:
3649 _root = get_project_root(ctx)
3650 _octrc = load_octrc_safe(_root)
3651 except Exception: # pragma: no cover - config discovery is best-effort
3652 _octrc = {}
3653 if (_octrc or {}).get("git", {}).get("require_ai_model_trailer", False):
3654 errors.extend(validate_ai_model_trailer(text))
3655 valid = not errors
3656
3657 if json_mode:
3658 click.echo(
3659 json.dumps({"valid": valid, "errors": errors}, indent=2)
3660 )
3661 else:
3662 if valid:
3663 click.echo(f"[oct git commit-msg] OK: {message_file.name}")
3664 else:
3665 click.echo(format_validation_errors(errors), err=True)
3666
3667 ctx.exit(0 if valid else 1)
3668
3669
3670# =====================================================================
3671# oct git hooks (Phase 4D - G-D8, G-D9, G-D10)
3672# =====================================================================
3673
3674
3675_ENHANCED_HOOKS_TEMPLATE = """\
3676# Option C pre-commit configuration
3677# Generated by: oct git hooks install
3678# See https://pre-commit.com for more information
3679
3680repos:
3681 - repo: local
3682 hooks:
3683 - id: oct-quality-gate
3684 name: Option C Quality Gate
3685 entry: oct git check --staged-only
3686 language: system
3687 types: [python]
3688 pass_filenames: false
3689
3690 - id: oct-secrets-check
3691 name: Option C Secrets Check
3692 entry: oct git check --staged-only --profile strict
3693 language: system
3694 always_run: true
3695 pass_filenames: false
3696
3697 - id: oct-commit-msg
3698 name: Option C Commit Message
3699 entry: oct git commit-msg
3700 language: system
3701 stages: [commit-msg]
3702
3703 - repo: local
3704 hooks:
3705 - id: oct-pre-push
3706 name: Option C Pre-Push Gate
3707 entry: oct git check --include-tests
3708 language: system
3709 stages: [pre-push]
3710 pass_filenames: false
3711"""
3712
3713# Hook IDs used to identify OCT hooks in a pre-commit config.
3714_OCT_HOOK_IDS = (
3715 "oct-quality-gate",
3716 "oct-secrets-check",
3717 "oct-commit-msg",
3718 "oct-pre-push",
3719 # Legacy hook IDs from oct install-hooks (Phase 4C and earlier).
3720 "oct-lint",
3721 "oct-format-check",
3722)
3723
3724
3725@git.group(name="hooks")
3726@click.pass_context
3727def hooks_group(ctx: click.Context) -> None:
3728 """Manage Option C git hooks."""
3729 pass
3730
3731
3732@hooks_group.command(name="install")
3733@click.option("--force", is_flag=True, help="Overwrite existing configuration.")
3734@click.pass_context
3735@audited("oct git hooks install")
3736def hooks_install_cmd(ctx: click.Context, force: bool) -> None:
3737 """Generate .pre-commit-config.yaml with all Option C hooks."""
3738 func = "hooks_install_cmd"
3739 root = get_project_root(ctx)
3740 _dbg("GIT", func, f"root={root} force={force}", 2)
3741
3742 config_path = root / ".pre-commit-config.yaml"
3743 if config_path.exists() and not force:
3744 click.echo(
3745 f"Error: {config_path} already exists. Use --force to overwrite.",
3746 err=True,
3747 )
3748 record = ctx.obj.get("_audit_record", AuditRecord())
3749 record.timestamp = record.timestamp or _utc_now_iso()
3750 record.command = "oct git hooks install"
3751 record.checks_passed = False
3752 record.exit_code = 1
3753 ctx.obj["_audit_record"] = record
3754 ctx.exit(1)
3755 return
3756
3757 config_path.write_text(_ENHANCED_HOOKS_TEMPLATE, encoding="utf-8")
3758 click.echo(f"Created {config_path}")
3759 click.echo("Run 'pre-commit install' to activate the hooks.")
3760
3761 record = ctx.obj.get("_audit_record", AuditRecord())
3762 record.timestamp = record.timestamp or _utc_now_iso()
3763 record.command = "oct git hooks install"
3764 record.checks_passed = True
3765 record.exit_code = 0
3766 ctx.obj["_audit_record"] = record
3767
3768
3769@hooks_group.command(name="status")
3770@click.pass_context
3771@audited("oct git hooks status")
3772def hooks_status_cmd(ctx: click.Context) -> None:
3773 """Show installed hook state."""
3774 func = "hooks_status_cmd"
3775 root = get_project_root(ctx)
3776 _dbg("GIT", func, f"root={root}", 2)
3777
3778 config_path = root / ".pre-commit-config.yaml"
3779 expected = ("oct-quality-gate", "oct-secrets-check", "oct-commit-msg", "oct-pre-push")
3780
3781 found: set[str] = set()
3782 if config_path.is_file():
3783 content = config_path.read_text(encoding="utf-8")
3784 for hook_id in expected:
3785 if f"id: {hook_id}" in content:
3786 found.add(hook_id)
3787
3788 for hook_id in expected:
3789 status = "[INSTALLED]" if hook_id in found else "[MISSING]"
3790 click.echo(f" {status} {hook_id}")
3791
3792 record = ctx.obj.get("_audit_record", AuditRecord())
3793 record.timestamp = record.timestamp or _utc_now_iso()
3794 record.command = "oct git hooks status"
3795 record.checks_passed = True
3796 record.exit_code = 0
3797 ctx.obj["_audit_record"] = record
3798
3799
3800@hooks_group.command(name="remove")
3801@click.pass_context
3802@audited("oct git hooks remove")
3803def hooks_remove_cmd(ctx: click.Context) -> None:
3804 """Remove Option C hooks from .pre-commit-config.yaml."""
3805 func = "hooks_remove_cmd"
3806 root = get_project_root(ctx)
3807 _dbg("GIT", func, f"root={root}", 2)
3808
3809 config_path = root / ".pre-commit-config.yaml"
3810 if not config_path.is_file():
3811 click.echo("No .pre-commit-config.yaml found.", err=True)
3812 record = ctx.obj.get("_audit_record", AuditRecord())
3813 record.timestamp = record.timestamp or _utc_now_iso()
3814 record.command = "oct git hooks remove"
3815 record.checks_passed = True
3816 record.exit_code = 0
3817 ctx.obj["_audit_record"] = record
3818 return
3819
3820 content = config_path.read_text(encoding="utf-8")
3821
3822 # Check if any non-OCT hooks exist.
3823 has_non_oct = False
3824 in_oct_hook = False
3825 filtered_lines: list[str] = []
3826 for line in content.splitlines():
3827 stripped = line.strip()
3828 # Detect OCT hook blocks by their id.
3829 if stripped.startswith("- id:"):
3830 hook_id = stripped.split(":", 1)[1].strip()
3831 in_oct_hook = hook_id in _OCT_HOOK_IDS
3832 if not in_oct_hook:
3833 has_non_oct = True
3834
3835 if in_oct_hook:
3836 # Skip lines belonging to OCT hooks.
3837 # The hook block ends at the next "- id:" or "- repo:" or end of file.
3838 if stripped.startswith("- id:") or stripped.startswith("- repo:"):
3839 # Re-evaluate: is this the start of a new hook or repo?
3840 if stripped.startswith("- id:"):
3841 hook_id = stripped.split(":", 1)[1].strip()
3842 in_oct_hook = hook_id in _OCT_HOOK_IDS
3843 if in_oct_hook:
3844 continue
3845 else:
3846 has_non_oct = True
3847 filtered_lines.append(line)
3848 in_oct_hook = False
3849 continue
3850 elif stripped.startswith("- repo:"):
3851 in_oct_hook = False
3852 filtered_lines.append(line)
3853 continue
3854 else:
3855 continue
3856 else:
3857 filtered_lines.append(line)
3858
3859 if not has_non_oct:
3860 # All hooks were OCT hooks - delete the file.
3861 config_path.unlink()
3862 click.echo("Removed .pre-commit-config.yaml (all hooks were Option C hooks).")
3863 else:
3864 # Rewrite with non-OCT hooks preserved.
3865 config_path.write_text("\n".join(filtered_lines) + "\n", encoding="utf-8")
3866 click.echo("Removed Option C hooks (non-OCT hooks preserved).")
3867
3868 record = ctx.obj.get("_audit_record", AuditRecord())
3869 record.timestamp = record.timestamp or _utc_now_iso()
3870 record.command = "oct git hooks remove"
3871 record.checks_passed = True
3872 record.exit_code = 0
3873 ctx.obj["_audit_record"] = record
3874
3875
3876# =====================================================================
3877# oct git changelog (Phase 4E - G-E2)
3878# =====================================================================
3879
3880
3881@git.command(name="changelog")
3882@click.option("--release", "version", default=None,
3883 help="Release version tag (e.g. v0.13.0).")
3884@click.option("--since", default=None,
3885 help="Git ref to start from (default: last tag).")
3886@click.option("--dry-run", is_flag=True,
3887 help="Print to stdout without writing CHANGELOG.md.")
3888@click.option("--json", "json_mode", is_flag=True,
3889 help="Emit structured JSON.")
3890@click.pass_context
3891@audited("oct git changelog")
3893 ctx: click.Context,
3894 version: str | None,
3895 since: str | None,
3896 dry_run: bool,
3897 json_mode: bool,
3898) -> None:
3899 """Generate changelog from Conventional Commits history."""
3900 func = "git_changelog_cmd"
3901 root = get_project_root(ctx)
3902 _dbg("GIT", func, f"root={root} version={version!r} since={since!r}", 2)
3903
3904 from oct.git.changelog import (
3905 collect_changelog_entries,
3906 format_changelog,
3907 changelog_to_json,
3908 )
3909
3910 root = _require_git_repo(root)
3911
3912 entries = collect_changelog_entries(root, since_ref=since)
3913
3914 if json_mode:
3915 data = changelog_to_json(entries, version=version)
3916 click.echo(json.dumps(data, indent=2))
3917 record = ctx.obj.get("_audit_record", AuditRecord())
3918 record.timestamp = record.timestamp or _utc_now_iso()
3919 record.command = "oct git changelog"
3920 record.checks_passed = True
3921 record.exit_code = 0
3922 ctx.obj["_audit_record"] = record
3923 return
3924
3925 text = format_changelog(entries, version=version)
3926
3927 if dry_run or version is None:
3928 click.echo(text)
3929 else:
3930 # Prepend to CHANGELOG.md.
3931 changelog_path = root / "CHANGELOG.md"
3932 existing = ""
3933 if changelog_path.is_file():
3934 existing = changelog_path.read_text(encoding="utf-8")
3935 # Insert after the top-level heading if present, else prepend.
3936 if existing.startswith("# "):
3937 # Keep the top heading, insert after first blank line.
3938 first_newline = existing.find("\n\n")
3939 if first_newline >= 0:
3940 new_content = (
3941 existing[:first_newline + 2]
3942 + text + "\n"
3943 + existing[first_newline + 2:]
3944 )
3945 else:
3946 new_content = existing + "\n\n" + text + "\n"
3947 elif existing:
3948 new_content = text + "\n" + existing
3949 else:
3950 new_content = f"# Changelog\n\n{text}\n"
3951 changelog_path.write_text(new_content, encoding="utf-8")
3952 click.echo(f"Updated {to_project_relative(changelog_path, root)}")
3953
3954 record = ctx.obj.get("_audit_record", AuditRecord())
3955 record.timestamp = record.timestamp or _utc_now_iso()
3956 record.command = "oct git changelog"
3957 record.checks_passed = True
3958 record.exit_code = 0
3959 ctx.obj["_audit_record"] = record
3960
3961
3962# =====================================================================
3963# Helpers
3964# =====================================================================
3965
3966
3967def _require_git_repo(root: Path) -> Path:
3968 """Return the git repo root, or exit with a clear UX message.
3969
3970 - Git not installed: *"Git is not installed. Git-related features
3971 are disabled."*
3972 - Not a repo: *"Not a git repository. Run 'oct git init' to
3973 initialise."*
3974 """
3975 from oct.core.git import (
3976 is_git_repo, get_repo_root as _get_repo_root,
3977 GitNotFoundError, GitNotARepoError,
3978 )
3979 import shutil
3980 if shutil.which("git") is None:
3981 raise click.ClickException(
3982 "Git is not installed. Git-related features are disabled."
3983 )
3984 if not is_git_repo(root):
3985 raise click.ClickException(
3986 "Not a git repository. Run 'oct git init' to initialise."
3987 )
3988 try:
3989 return _get_repo_root(root)
3990 except (GitNotFoundError, GitNotARepoError) as exc:
3991 raise click.ClickException(str(exc))
3992
3993
3995 root: Path, staged: list[Path], octrc: dict | None,
3996) -> list[tuple[str, int]]:
3997 """Check staged files for size exceeding the threshold.
3998
3999 Returns a list of ``(rel_path, size_bytes)`` for oversized files.
4000 Threshold is configurable via ``octrc["git"]["large_file_threshold_mb"]``
4001 (default: 10 MB).
4002 """
4003 threshold = _LARGE_FILE_THRESHOLD_BYTES
4004 if isinstance(octrc, dict):
4005 git_cfg = octrc.get("git")
4006 if isinstance(git_cfg, dict):
4007 raw = git_cfg.get("large_file_threshold_mb")
4008 if isinstance(raw, (int, float)) and raw > 0:
4009 threshold = int(raw * 1024 * 1024)
4010
4011 oversized: list[tuple[str, int]] = []
4012 for path in staged:
4013 try:
4014 size = path.stat().st_size
4015 if size > threshold:
4016 rel = to_project_relative(path, root)
4017 oversized.append((rel, size))
4018 except OSError:
4019 continue
4020 return oversized
4021
4022
4023# Plan 199: the module-local ``_load_octrc`` that used to live here read
4024# a bare ``root / ".octrc.json"``, which made every project on the
4025# canonical ``.option_c/octrc.json`` layout invisible to the git layer.
4026# It is gone deliberately: :func:`oct.core.octrc.load_octrc_safe`,
4027# imported at module scope, is the ONE loader for the whole toolchain.
4028
4029
4030# ---------------------------------------------------------------------
4031# Auto-branch helpers (Phase 4F - G-F2)
4032# ---------------------------------------------------------------------
4033
4034
4035def _should_auto_branch(branch: str | None, octrc: dict | None) -> bool:
4036 """Return True when auto-branching is enabled for *branch*.
4037
4038 Auto-branching activates when the branch is protected and the
4039 ``git.auto_branch_on_protected`` setting is not explicitly ``False``
4040 (defaults to ``True``).
4041 """
4042 from oct.git.policy import is_protected_branch
4043
4044 if not is_protected_branch(branch, octrc):
4045 return False
4046 if isinstance(octrc, dict):
4047 git_cfg = octrc.get("git")
4048 if isinstance(git_cfg, dict):
4049 raw = git_cfg.get("auto_branch_on_protected")
4050 if isinstance(raw, bool):
4051 return raw
4052 return True # default: enabled
4053
4054
4055def _deduplicate_branch_name(root: Path, name: str) -> str:
4056 """Return *name* if it does not exist, otherwise append ``-2``, ``-3``, etc."""
4057 from oct.core.git import branch_exists
4058
4059 if not branch_exists(root, name):
4060 return name
4061 counter = 2
4062 while counter <= 100:
4063 candidate = f"{name}-{counter}"
4064 if not branch_exists(root, candidate):
4065 return candidate
4066 counter += 1
4067 # Extremely unlikely; surface as-is and let git error naturally.
4068 return f"{name}-{counter}"
4069
4070
4072 root: Path,
4073 original_branch: str,
4074 message: str,
4075 ctx: click.Context,
4076 json_mode: bool,
4077) -> str | None:
4078 """Create a feature branch from a commit message and switch to it.
4079
4080 Returns the new branch name on success, or ``None`` on failure
4081 (after emitting errors and populating the audit record).
4082 """
4083 func = "_auto_branch_for_commit"
4084 from oct.core.git import create_and_switch_branch
4085 from oct.git.policy import generate_branch_name_from_message
4086
4087 auto_name = generate_branch_name_from_message(message)
4088 if auto_name is None:
4089 msg = "Cannot auto-branch: failed to derive branch name from commit message."
4090 click.echo(msg, err=True)
4091 if json_mode:
4093 ctx, exit_code=1, message=message, branch=original_branch,
4094 profile="strict", errors=[msg],
4095 )
4096 _populate_commit_audit(ctx, branch=original_branch, exit_code=1,
4097 checks_passed=False, staged_count=0)
4098 ctx.exit(1)
4099 return None
4100
4101 final_name = _deduplicate_branch_name(root, auto_name)
4102
4103 try:
4104 create_and_switch_branch(root, final_name)
4105 except Exception as exc:
4106 msg = f"Auto-branch failed: {exc}"
4107 click.echo(msg, err=True)
4108 _dbg("GIT", func, msg, 1)
4109 if json_mode:
4111 ctx, exit_code=1, message=message, branch=original_branch,
4112 profile="strict", errors=[msg],
4113 )
4114 _populate_commit_audit(ctx, branch=original_branch, exit_code=1,
4115 checks_passed=False, staged_count=0)
4116 ctx.exit(1)
4117 return None
4118
4119 click.echo(
4120 f"Auto-created branch '{final_name}' from '{original_branch}'.",
4121 err=True,
4122 )
4123 _dbg("GIT", func, f"auto-branched to {final_name!r}", 2)
4124 return final_name
Definition git.py:1
str _resolve_observe_mode(dict|None octrc, bool ignore_unstaged_mutation)
Definition oct_git.py:2211
_maybe_load_workspace(Path root)
Definition oct_git.py:174
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:1814
None hooks_remove_cmd(click.Context ctx)
Definition oct_git.py:3803
None git_changelog_cmd(click.Context ctx, str|None version, str|None since, bool dry_run, bool json_mode)
Definition oct_git.py:3898
list[str] _create_gitattributes(Path root, bool dry_run)
Definition oct_git.py:1192
None _restore_audit(click.Context ctx, *, int exit_code)
Definition oct_git.py:3169
None git_commit_cmd(click.Context ctx, tuple[str,...] messages, bool force_commit, bool no_verify, str|None profile, bool json_mode, bool ignore_unstaged_mutation, tuple[str,...] extra_args)
Definition oct_git.py:1371
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:3063
bool _should_auto_branch(str|None branch, dict|None octrc)
Definition oct_git.py:4035
Path _require_git_repo(Path root)
Definition oct_git.py:3967
None git_status_cmd(click.Context ctx, bool json_mode, bool no_check, str|None scope)
Definition oct_git.py:443
list[str] _check_unstaged_mutation(Path root, dict[str, str] pre_snapshot, list[Path] staged, str stage_label, str method="hash")
Definition oct_git.py:2246
None hooks_status_cmd(click.Context ctx)
Definition oct_git.py:3772
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:1792
None git_restore_cmd(click.Context ctx, tuple[str,...] paths, bool staged_mode, bool all_mode, bool skip_prompt, bool json_mode)
Definition oct_git.py:3205
list[tuple[str, int]] _check_large_staged_files(Path root, list[Path] staged, dict|None octrc)
Definition oct_git.py:3996
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:2301
list[Path] _get_modified_unstaged(Path root)
Definition oct_git.py:382
str|None _safe_relative(Path path, Path root)
Definition oct_git.py:2231
None _add_audit(click.Context ctx, *, int exit_code, int staged_count=0)
Definition oct_git.py:2577
list[Path] _get_untracked_files(Path root)
Definition oct_git.py:365
dict _build_workspace_view(*, workspace, list[Path] staged, list[Path] modified, list[Path] untracked, bool no_check)
Definition oct_git.py:226
list[str] _merge_ignore_file(Path path, tuple[str,...] entries, bool dry_run)
Definition oct_git.py:1145
str _resolve_observe_method(dict|None octrc)
Definition oct_git.py:2178
None git_add_cmd(click.Context ctx, tuple[str,...] paths, bool patch_mode, bool update_mode, bool all_mode, bool dry_run, bool json_mode)
Definition oct_git.py:2755
None _advisory_checks(Path root, list[Path] staged, str message, bool err=True)
Definition oct_git.py:2328
dict|None _post_check_summary(Path root, dict|None octrc)
Definition oct_git.py:2591
str _resolve_ignore_scope(str|None cli_scope, dict|None octrc)
Definition oct_git.py:3103
FanoutGateOutcome _run_fanout_gate(workspace, list[Path] files, *, str|None cli_profile, str|None branch, str fallback_profile, bool include_tests=False, bool apply_policy=True)
Definition oct_git.py:1936
None _ignore_audit(click.Context ctx, *, int exit_code)
Definition oct_git.py:3085
None _emit_branch_json(*, str operation="", str branch="", int exit_code=0, list[str]|None errors=None)
Definition oct_git.py:2521
None git_ignore_cmd(click.Context ctx, tuple[str,...] patterns, str|None remove_pattern, bool list_mode, str|None scope, bool json_mode)
Definition oct_git.py:3393
None _branch_audit(click.Context ctx, *, int exit_code, str branch="")
Definition oct_git.py:2536
None git_init_cmd(click.Context ctx, bool dry_run, bool github, bool no_hooks)
Definition oct_git.py:1221
None git_branch_cmd(click.Context ctx, str|None create_name, str|None switch_name, bool json_mode)
Definition oct_git.py:2371
dict[str, list[Path]] _group_files_by_subproject(list[Path] staged, workspace)
Definition oct_git.py:1855
str _badge(bool|None compliant)
Definition oct_git.py:153
tuple[bool, list[str]] _quick_check_file(Path path, Path project_root)
Definition oct_git.py:109
None hooks_install_cmd(click.Context ctx, bool force)
Definition oct_git.py:3736
tuple[Path|None, str|None] _resolve_ignore_target_dir(*, Path project_root, Path repo_root, str scope)
Definition oct_git.py:3124
None git_commit_msg_cmd(click.Context ctx, Path message_file, bool json_mode)
Definition oct_git.py:3610
str _deduplicate_branch_name(Path root, str name)
Definition oct_git.py:4055
list[dict] _build_per_subproject_audit(dict per_sp_results, dict per_sp_meta)
Definition oct_git.py:2162
str _resolve_subproject_profile(subproject, dict|None sp_octrc, str|None cli_override, str|None branch)
Definition oct_git.py:2070
int _git_check_fanout(click.Context ctx, *, workspace, str scope, str|None cli_profile, str fallback_profile, str|None branch, bool include_tests, bool fix, bool json_mode)
Definition oct_git.py:740
tuple[int, int] _get_ahead_behind(Path root)
Definition oct_git.py:399
None _print_post_check_advisory(dict|None post)
Definition oct_git.py:2993
bool _generate_github_workflow(Path root, bool dry_run)
Definition oct_git.py:1198
bool _is_within_project(Path path, Path project_root_resolved)
Definition oct_git.py:160
_merge_quality_gate_results(dict[str, "QualityGateResult"] per_sp)
Definition oct_git.py:2121
None git_check_cmd(click.Context ctx, bool staged_only, bool check_all, bool include_tests, bool fix, str|None profile, bool json_mode, bool sandbox)
Definition oct_git.py:949
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:2560
str|None _auto_branch_for_commit(Path root, str original_branch, str message, click.Context ctx, bool json_mode)
Definition oct_git.py:4077
None hooks_group(click.Context ctx)
Definition oct_git.py:3727