oct
Loading...
Searching...
No Matches
policy.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3# oct/git/policy.py
4
5"""
6Purpose
7-------
8Encode the Phase 4B profile-aware enforcement rules for the ``oct git``
9quality gate. Maps a combination of profile (``proto`` / ``compact`` /
10``strict``), branch context, and raw check findings to a semantic exit
11code in the range 0..5 defined by blueprint §5.3.
12
13Responsibilities
14----------------
15- Resolve the effective lint profile from CLI / octrc / branch context,
16 honouring the protected-branch override (blueprint §7.2).
17- Restrict the protected-branch opt-out to nested-repo subprojects
18 (Plan 209): an ordinary subproject's ``git.protected_branches`` can
19 add a branch but never remove ``main``/``master`` from protection.
20- Apply the profile enforcement matrix (blueprint §5.3 / §7.1) to a
21 :class:`~oct.git.quality_gate.QualityGateResult` and return the
22 matching exit code.
23- Enforce the invariant "secrets always blocks" regardless of profile
24 (blueprint §5.3 final clause).
25- Manage the OCT-managed block in ``.gitignore`` - add/remove/list
26 patterns inside the fenced block, leaving user-managed lines alone.
27
28Diagnostics
29-----------
30Domain: GIT
31Levels:
32 L1 - workspace-manifest lookup failures (degrading to the
33 octrc-only chain rather than breaking the gate)
34 L2 - (none) pure decision helpers; callers own lifecycle logging
35 L3 - profile resolution and policy decisions
36 L4 - per-field raw counts during exit-code derivation
37
38Contracts
39---------
40- No subprocess, no Click. Profile *decisions* are pure; the two
41 functions that read config off disk (``resolve_manifest_profile`` and
42 the ``.gitignore`` block helpers) are the documented exceptions.
43- ``resolve_effective_profile`` never raises; unknown values collapse
44 to the documented default. It is the SINGLE resolver - the workspace
45 fan-out path (``oct.git.oct_git._resolve_subproject_profile``)
46 delegates to it, so the two paths cannot drift apart (Plan 197).
47- ``resolve_manifest_profile`` never raises: a missing, unreadable or
48 malformed manifest yields ``None``, which degrades to the pre-197
49 octrc-only behaviour.
50- ``apply_profile_policy`` depends only on the ``QualityGateResult``
51 dataclass defined in :mod:`oct.git.quality_gate`.
52- Precedence for the exit code is strictly: 5 > 4 > 3 > 2 > 1 > 0.
53- Plan 209: protected-branch opt-out (an explicit
54 ``git.protected_branches`` list) is a ``nested_git``-only privilege -
55 every other subproject may ADD to but never REMOVE the workspace
56 defaults from its protected set. ``resolve_manifest_nested_git``
57 never raises, mirroring ``resolve_manifest_profile``'s degrade-to-
58 safe-default contract (it returns ``False``, meaning "not exempt",
59 rather than crashing or guessing).
60
61Dependencies
62------------
63- oct.core.diagnostics (_dbg structured logger)
64- oct.core.workspace (lazy; manifest lookup of the owning subproject's profile and nested_git flag)
65- oct.git.conventional (lazy; parse_commit_message - type, scope and subject for branch names)
66- oct.git.quality_gate (lazy; QualityGateResult annotation, imported only for type checking)
67"""
68
69from __future__ import annotations
70
71import re
72import sys
73from pathlib import Path
74from typing import TYPE_CHECKING, Literal
75
76from oct.core.diagnostics import _dbg
77
78
79if TYPE_CHECKING: # pragma: no cover - avoid import cycle at runtime
80 from oct.git.quality_gate import QualityGateResult
81
82
83# ---------------------------------------------------------------------
84# Constants
85# ---------------------------------------------------------------------
86
87Profile = Literal["proto", "compact", "strict"]
88
89#: The three canonical profile names. Anything else resolves to
90#: ``DEFAULT_PROFILE``.
91VALID_PROFILES: tuple[Profile, ...] = ("proto", "compact", "strict")
92
93#: Fallback profile used when no other source (CLI, config, branch
94#: override) selects one. Blueprint §7 names "compact" as the project
95#: default.
96DEFAULT_PROFILE: Profile = "compact"
97
98#: Default protected branches written into ``.octrc.json`` when a
99#: project does not override them (blueprint §7.2). Branches listed
100#: here force the profile to ``strict`` regardless of CLI or config.
101DEFAULT_PROTECTED_BRANCHES: tuple[str, ...] = ("main", "master")
102
103#: Profile enforcement matrix (blueprint §5.3 / §7.1, verbatim).
104#:
105#: ``lint`` / ``format`` / ``tests`` values are one of:
106#: - ``"blocking"``: a violation produces a non-zero exit code.
107#: - ``"optional"``: the check runs and reports, but failures are
108#: advisory - exit code is unaffected (OI-531).
109#: - ``"warn"``: violations are reported but do not affect exit code.
110#: - ``"skip"``: the check is not run at all.
111#: ``secrets`` is always ``"blocking"`` - see :data:`SECRETS_ALWAYS_BLOCKS`.
112PROFILE_MATRIX: dict[Profile, dict[str, str]] = {
113 "proto": {
114 "lint": "warn",
115 "format": "skip",
116 "secrets": "blocking", # OCT-LINT: disable=no_hardcoded_secrets reason="Profile enforcement matrix; 'secrets' is a check name, not a credential"
117 "tests": "skip",
118 },
119 "compact": {
120 "lint": "blocking",
121 "format": "blocking",
122 "secrets": "blocking", # OCT-LINT: disable=no_hardcoded_secrets reason="Profile enforcement matrix; 'secrets' is a check name, not a credential"
123 "tests": "optional",
124 },
125 "strict": {
126 "lint": "blocking",
127 "format": "blocking",
128 "secrets": "blocking", # OCT-LINT: disable=no_hardcoded_secrets reason="Profile enforcement matrix; 'secrets' is a check name, not a credential"
129 "tests": "blocking",
130 },
131}
132
133#: Invariant: secrets findings are always blocking. This is asserted as
134#: a unit test so any accidental weakening of :data:`PROFILE_MATRIX`
135#: produces a CI failure rather than a silent security regression.
136SECRETS_ALWAYS_BLOCKS: bool = True
137
138#: Default branch naming patterns (Phase 4E - G-E3, blueprint §7.1).
139#: Matches main/master/develop as exact names and common prefix-based
140#: patterns. Projects may override via ``git.branch_naming.patterns``.
141DEFAULT_BRANCH_PATTERNS: tuple[str, ...] = (
142 r"^(main|master|develop)$",
143 r"^(release|hotfix|feature|bugfix|docs|chore)/.+$",
144)
145
146#: Auto-profile mapping (Phase 4E - G-E4, blueprint §7.3). Maps branch
147#: name prefixes to profiles. Exact matches are tried first, then
148#: prefix matches. Unrecognised branches fall back to DEFAULT_PROFILE.
149_AUTO_PROFILE_MAP: dict[str, Profile] = {
150 "main": "strict",
151 "master": "strict",
152 "release/": "strict",
153 "hotfix/": "strict",
154 "feature/": "compact",
155 "bugfix/": "compact",
156 "docs/": "compact",
157 "chore/": "compact",
158}
159
160#: Maps Conventional Commit types to branch-name prefixes for auto-
161#: branching on protected branches (Phase 4F - G-F2).
162_COMMIT_TYPE_TO_PREFIX: dict[str, str] = {
163 "feat": "feature",
164 "fix": "bugfix",
165 "docs": "docs",
166 "style": "chore",
167 "refactor": "chore",
168 "perf": "feature",
169 "test": "chore",
170 "chore": "chore",
171 "sec": "hotfix",
172}
173
174#: Maximum slug length for auto-generated branch names.
175_BRANCH_SLUG_MAX_LEN: int = 50
176
177
178# ---------------------------------------------------------------------
179# Branch name generation (Phase 4F - G-F2)
180# ---------------------------------------------------------------------
181
182
183def generate_branch_name_from_message(message: str) -> str | None:
184 """Derive a valid branch name from a Conventional Commits *message*.
185
186 Returns a branch name like ``feature/auth-add-jwt-tokens`` or
187 ``None`` when the message cannot be parsed.
188
189 The generated name is guaranteed to pass
190 :func:`validate_branch_name` against the default patterns.
191 """
192 func = "generate_branch_name_from_message"
193 _dbg("GIT", func, f"message={message[:60]!r}", 3)
194
195 from oct.git.conventional import parse_commit_message
196
197 parsed = parse_commit_message(message)
198 if parsed is None:
199 _dbg("GIT", func, "parse failed - returning None", 4)
200 return None
201
202 prefix = _COMMIT_TYPE_TO_PREFIX.get(parsed.type, "chore")
203
204 # Build slug from optional scope + subject.
205 parts: list[str] = []
206 if parsed.scope:
207 parts.append(parsed.scope)
208 parts.append(parsed.subject)
209 raw_slug = "-".join(parts).lower()
210
211 # Slugify: keep only alphanumeric + hyphens.
212 slug = re.sub(r"[^a-z0-9-]", "-", raw_slug)
213 slug = re.sub(r"-{2,}", "-", slug)
214 slug = slug.strip("-")
215
216 if not slug:
217 _dbg("GIT", func, "empty slug - returning None", 4)
218 return None
219
220 # Truncate to max length.
221 if len(slug) > _BRANCH_SLUG_MAX_LEN:
222 slug = slug[:_BRANCH_SLUG_MAX_LEN].rstrip("-")
223
224 name = f"{prefix}/{slug}"
225
226 # Sanity-check against default patterns.
227 valid, _ = validate_branch_name(name, None)
228 if not valid:
229 _dbg("GIT", func, f"generated name {name!r} invalid - returning None", 3)
230 return None
231
232 _dbg("GIT", func, f"result={name!r}", 4)
233 return name
234
235
236# ---------------------------------------------------------------------
237# Profile resolution
238# ---------------------------------------------------------------------
239
240
242 branch: str | None,
243 octrc: dict | None,
244 *,
245 allow_optout: bool = False,
246) -> bool:
247 """Return True if ``branch`` is listed in ``git.protected_branches``.
248
249 A missing ``octrc`` or a missing ``git.protected_branches`` key
250 falls back to :data:`DEFAULT_PROTECTED_BRANCHES`. Detached HEAD
251 (``branch is None``) is never considered protected - detached
252 workflows are per-definition outside branch policy.
253
254 Plan 209: an explicit ``protected_branches`` list is a PRIVILEGE,
255 not an open door. When the octrc sets one:
256
257 - ``allow_optout=False`` (the safe default, every ordinary
258 subproject): the effective protected set is the UNION of the
259 configured list and :data:`DEFAULT_PROTECTED_BRANCHES` - a
260 subproject may ADD a protected branch but can never REMOVE
261 ``main``/``master`` from protection by omitting them.
262 - ``allow_optout=True`` (reserved for a ``nested_git`` subproject -
263 a foreign nested repo that owns its own branch policy, e.g.
264 ``oc_explorer``): the configured list is honored verbatim, so an
265 explicit empty list means genuinely no protected branches.
266
267 An unset or malformed (non-list) ``protected_branches`` always
268 falls back to :data:`DEFAULT_PROTECTED_BRANCHES`, regardless of
269 *allow_optout* - opting out requires an explicit list.
270 """
271 func = "is_protected_branch"
272 _dbg("GIT", func, f"branch={branch!r} allow_optout={allow_optout}", 3)
273 if branch is None or not branch:
274 return False
275 if branch == "HEAD":
276 # :func:`oct.core.git.current_branch` returns the literal "HEAD"
277 # when detached. Treat it the same as ``None``.
278 return False
279 configured: list[str] | None = None
280 if isinstance(octrc, dict):
281 git_cfg = octrc.get("git")
282 if isinstance(git_cfg, dict):
283 candidate = git_cfg.get("protected_branches")
284 if isinstance(candidate, list):
285 # Only accept strings; ignore junk entries silently so a
286 # malformed config cannot downgrade the gate.
287 configured = [b for b in candidate if isinstance(b, str)]
288 protected: set[str]
289 if configured is None:
290 protected = set(DEFAULT_PROTECTED_BRANCHES)
291 elif allow_optout:
292 protected = set(configured)
293 else:
294 protected = set(configured) | set(DEFAULT_PROTECTED_BRANCHES)
295 result = branch in protected
296 _dbg("GIT", func, f"result={result} protected={sorted(protected)}", 4)
297 return result
298
299
300# ---------------------------------------------------------------------
301# Branch naming validation (Phase 4E - G-E3)
302# ---------------------------------------------------------------------
303
304
306 branch: str | None,
307 octrc: dict | None,
308) -> tuple[bool, str]:
309 """Validate *branch* against the configured naming patterns.
310
311 Returns ``(valid, message)``:
312
313 - ``git.branch_naming.enabled`` is ``False`` → ``(True, "")``
314 - ``branch`` is ``None`` (detached HEAD) → ``(True, "")``
315 - Branch matches at least one pattern → ``(True, "")``
316 - No pattern matches → ``(False, "Branch name '...' does not ...")``
317
318 Patterns default to :data:`DEFAULT_BRANCH_PATTERNS` when not
319 overridden in ``.octrc.json``.
320 """
321 func = "validate_branch_name"
322 _dbg("GIT", func, f"branch={branch!r}", 3)
323
324 if branch is None or branch == "HEAD":
325 return True, ""
326
327 # Read config.
328 enabled = True
329 patterns: list[str] = list(DEFAULT_BRANCH_PATTERNS)
330
331 if isinstance(octrc, dict):
332 git_cfg = octrc.get("git")
333 if isinstance(git_cfg, dict):
334 naming_cfg = git_cfg.get("branch_naming")
335 if isinstance(naming_cfg, dict):
336 raw_enabled = naming_cfg.get("enabled")
337 if isinstance(raw_enabled, bool):
338 enabled = raw_enabled
339 raw_patterns = naming_cfg.get("patterns")
340 if isinstance(raw_patterns, list):
341 patterns = [p for p in raw_patterns if isinstance(p, str)]
342
343 if not enabled:
344 _dbg("GIT", func, "branch naming disabled", 4)
345 return True, ""
346
347 if not patterns:
348 # No patterns → everything is valid.
349 return True, ""
350
351 for pattern in patterns:
352 try:
353 if re.match(pattern, branch):
354 _dbg("GIT", func, f"matched: {pattern!r}", 4)
355 return True, ""
356 except re.error:
357 continue
358
359 msg = (
360 f"Branch name '{branch}' does not match any allowed pattern. "
361 f"Expected patterns: {', '.join(patterns)}"
362 )
363 _dbg("GIT", func, f"invalid: {msg}", 3)
364 return False, msg
365
366
367# ---------------------------------------------------------------------
368# Auto-profile resolution (Phase 4E - G-E4)
369# ---------------------------------------------------------------------
370
371
372def _resolve_auto_profile(branch: str | None) -> Profile:
373 """Map *branch* to a profile using :data:`_AUTO_PROFILE_MAP`.
374
375 Exact matches (``main``, ``master``) are tried first, then prefix
376 matches (``feature/``, ``release/``). Returns :data:`DEFAULT_PROFILE`
377 for unrecognised patterns or ``None`` / ``"HEAD"`` branch.
378 """
379 func = "_resolve_auto_profile"
380 if branch is None or branch == "HEAD":
381 return DEFAULT_PROFILE
382
383 # Exact match first.
384 exact = _AUTO_PROFILE_MAP.get(branch)
385 if exact is not None:
386 _dbg("GIT", func, f"exact match: {branch!r} -> {exact}", 4)
387 return exact
388
389 # Prefix match.
390 for prefix, profile in _AUTO_PROFILE_MAP.items():
391 if prefix.endswith("/") and branch.startswith(prefix):
392 _dbg("GIT", func, f"prefix match: {prefix!r} -> {profile}", 4)
393 return profile
394
395 _dbg("GIT", func, f"no match for {branch!r}, fallback -> {DEFAULT_PROFILE}", 4)
396 return DEFAULT_PROFILE
397
398
400 branch: str | None,
401 octrc: dict | None,
402 cli_override: str | None,
403 manifest_profile: str | None = None,
404 *,
405 allow_optout: bool = False,
406) -> Profile:
407 """Return the profile that should be applied for the current run.
408
409 Precedence (highest wins):
410
411 1. **Protected-branch override** - if ``branch`` is listed in the
412 project's protected-branches set, the result is always
413 ``"strict"``. This cannot be overridden by CLI or config.
414 2. **CLI ``--profile``** - explicit user override from the command
415 line. Must be one of :data:`VALID_PROFILES`.
416 3. **Workspace manifest ``subprojects[].profile``** - the profile
417 the workspace declares for the owning subproject, supplied by
418 the caller via *manifest_profile* (Plan 197). ``None`` means
419 "no workspace, or the workspace declares nothing", which
420 degrades to the pre-197 octrc-only chain below.
421 4. **``.octrc.json → git.pre_commit_profile``** - the persistent
422 per-project default.
423 5. **``.octrc.json → linter.profile``** - if the git-specific key
424 is absent, fall back to the linter-wide profile.
425 6. **:data:`DEFAULT_PROFILE`** - final fallback if nothing above
426 is set.
427
428 Unknown profile strings (from any source) are silently discarded in
429 favour of the next lower level of precedence. Malformed ``octrc``
430 values are also ignored - never raise.
431
432 Plan 197: this is the ONE resolver. Both the single-project path
433 (``oct git check`` / ``oct git commit`` run from inside a
434 subproject) and the workspace fan-out path
435 (``oct.git.oct_git._resolve_subproject_profile``) call it, so a
436 file's effective profile cannot depend on the directory the gate
437 was invoked from. Callers that have a project root but not a
438 manifest profile should use :func:`resolve_profile_for_project`,
439 which does the lookup for them.
440
441 Plan 209: *allow_optout* is forwarded verbatim to
442 :func:`is_protected_branch` - pass ``True`` only when the caller
443 has verified the owning subproject is ``nested_git``. Defaults
444 False, the safe choice for every ordinary subproject.
445 """
446 func = "resolve_effective_profile"
447 _dbg(
448 "GIT", func,
449 f"branch={branch!r} cli_override={cli_override!r} "
450 f"manifest_profile={manifest_profile!r} allow_optout={allow_optout!r}",
451 3,
452 )
453
454 # 1. Protected branch override (unconditional).
455 if is_protected_branch(branch, octrc, allow_optout=allow_optout):
456 _dbg("GIT", func, "protected branch -> strict", 4)
457 return "strict"
458
459 # 2. CLI override.
460 if cli_override and cli_override in VALID_PROFILES:
461 _dbg("GIT", func, f"cli override -> {cli_override}", 4)
462 return cli_override # type: ignore[return-value]
463
464 # 3. Workspace manifest declaration for the owning subproject.
465 if manifest_profile and manifest_profile in VALID_PROFILES:
466 _dbg("GIT", func, f"manifest -> {manifest_profile}", 4)
467 return manifest_profile # type: ignore[return-value]
468
469 # 4 & 5. octrc config.
470 if isinstance(octrc, dict):
471 git_cfg = octrc.get("git")
472 if isinstance(git_cfg, dict):
473 candidate = git_cfg.get("pre_commit_profile")
474 if isinstance(candidate, str):
475 if candidate == "auto":
476 resolved = _resolve_auto_profile(branch)
477 print(
478 f"Auto-selected profile: {resolved} (branch: {branch})",
479 file=sys.stderr,
480 )
481 _dbg("GIT", func, f"auto -> {resolved}", 4)
482 return resolved
483 if candidate in VALID_PROFILES:
484 _dbg("GIT", func, f"git.pre_commit_profile -> {candidate}", 4)
485 return candidate # type: ignore[return-value]
486 linter_cfg = octrc.get("linter")
487 if isinstance(linter_cfg, dict):
488 candidate = linter_cfg.get("profile")
489 if isinstance(candidate, str) and candidate in VALID_PROFILES:
490 _dbg("GIT", func, f"linter.profile -> {candidate}", 4)
491 return candidate # type: ignore[return-value]
492
493 # 6. Final fallback.
494 _dbg("GIT", func, f"fallback -> {DEFAULT_PROFILE}", 4)
495 return DEFAULT_PROFILE
496
497
498def resolve_manifest_profile(project_root: Path) -> str | None:
499 """Return the workspace-declared profile for *project_root*.
500
501 Walks upward from *project_root* to the nearest
502 ``.option_c_workspace.json``, loads it, and returns the ``profile``
503 the manifest declares for the subproject that OWNS *project_root*
504 (deepest match wins for nested subprojects).
505
506 Returns ``None`` - never raises - when any of the following holds,
507 so callers degrade cleanly to the pre-197 octrc-only chain:
508
509 - *project_root* is not inside a workspace (plain single project);
510 - the manifest is unreadable or malformed;
511 - *project_root* is the workspace root itself, or otherwise falls
512 outside every declared subproject;
513 - the owning subproject declares no profile, or declares a value
514 outside :data:`VALID_PROFILES`.
515
516 Plan 197: this closes the gap that let ``oc_chat`` declare
517 ``strict`` and be gated at ``compact`` on every feature branch.
518 """
519 func = "resolve_manifest_profile"
520 _dbg("GIT", func, f"project_root={project_root}", 3)
521
522 try:
523 from oct.core.workspace import (
524 assign_to_subproject,
525 find_workspace_root,
526 load_workspace,
527 )
528 except ImportError: # pragma: no cover - defensive
529 _dbg("GIT", func, "workspace module unavailable", 4)
530 return None
531
532 try:
533 ws_root = find_workspace_root(Path(project_root))
534 if ws_root is None:
535 _dbg("GIT", func, "no workspace manifest above root", 4)
536 return None
537 workspace = load_workspace(ws_root)
538 subproject = assign_to_subproject(Path(project_root), workspace)
539 except Exception as exc:
540 # A broken manifest must never break the gate - degrade to the
541 # octrc-only chain rather than crashing or, worse, guessing.
542 _dbg(
543 "GIT", func,
544 f"manifest lookup failed: {exc!r}; degrading to octrc",
545 1, override=True,
546 )
547 return None
548
549 if subproject is None:
550 _dbg("GIT", func, "root owned by no subproject", 4)
551 return None
552
553 profile = getattr(subproject, "profile", None)
554 if isinstance(profile, str) and profile in VALID_PROFILES:
555 _dbg(
556 "GIT", func,
557 f"{subproject.name!r} declares {profile}",
558 4,
559 )
560 return profile
561
562 _dbg("GIT", func, f"{subproject.name!r} declares nothing usable", 4)
563 return None
564
565
566def resolve_manifest_nested_git(project_root: Path) -> bool:
567 """Return whether *project_root* is declared ``nested_git`` in the manifest.
568
569 Mirrors :func:`resolve_manifest_profile`'s walk-up-and-load shape so
570 the two lookups can never disagree about WHICH subproject owns
571 *project_root*. Returns ``False`` - never raises - for every
572 degenerate case (no workspace above *project_root*, an unreadable
573 or malformed manifest, or *project_root* falling outside every
574 declared subproject): the safe default is "not exempt", i.e. the
575 protected-branch opt-out restriction applies.
576
577 Plan 209: this is what lets :func:`resolve_profile_for_project`
578 pass the correct ``allow_optout`` to :func:`is_protected_branch`
579 without every caller having to look up the manifest itself.
580 """
581 func = "resolve_manifest_nested_git"
582 _dbg("GIT", func, f"project_root={project_root}", 3)
583
584 try:
585 from oct.core.workspace import (
586 assign_to_subproject,
587 find_workspace_root,
588 load_workspace,
589 )
590 except ImportError: # pragma: no cover - defensive
591 _dbg("GIT", func, "workspace module unavailable", 4)
592 return False
593
594 try:
595 ws_root = find_workspace_root(Path(project_root))
596 if ws_root is None:
597 _dbg("GIT", func, "no workspace manifest above root", 4)
598 return False
599 workspace = load_workspace(ws_root)
600 subproject = assign_to_subproject(Path(project_root), workspace)
601 except Exception as exc:
602 # A broken manifest must never break the gate, and must never
603 # grant an opt-out by accident - degrade to "not exempt".
604 _dbg(
605 "GIT", func,
606 f"manifest lookup failed: {exc!r}; degrading to False",
607 1, override=True,
608 )
609 return False
610
611 if subproject is None:
612 _dbg("GIT", func, "root owned by no subproject", 4)
613 return False
614
615 nested_git = bool(getattr(subproject, "nested_git", False))
616 _dbg("GIT", func, f"{subproject.name!r} nested_git={nested_git}", 4)
617 return nested_git
618
619
621 project_root: Path,
622 branch: str | None,
623 octrc: dict | None,
624 cli_override: str | None,
625) -> Profile:
626 """Resolve the effective profile for a project on disk.
627
628 Thin composition of :func:`resolve_manifest_profile`,
629 :func:`resolve_manifest_nested_git`, and
630 :func:`resolve_effective_profile`: the single entry point for
631 callers that hold a project root. Using it instead of calling
632 :func:`resolve_effective_profile` directly is what makes the
633 single-project path agree with the workspace fan-out path
634 (Plan 197) - including, since Plan 209, on the protected-branch
635 opt-out restriction.
636 """
637 func = "resolve_profile_for_project"
638 manifest_profile = resolve_manifest_profile(project_root)
639 allow_optout = resolve_manifest_nested_git(project_root)
641 branch, octrc, cli_override, manifest_profile=manifest_profile,
642 allow_optout=allow_optout,
643 )
644 _dbg("GIT", func, f"root={project_root} -> {profile}", 3)
645 return profile
646
647
648# ---------------------------------------------------------------------
649# Exit code derivation
650# ---------------------------------------------------------------------
651
652
653def apply_profile_policy(profile: str, result: "QualityGateResult") -> int:
654 """Derive the semantic exit code for a quality-gate result.
655
656 Exit code table (blueprint §5.3, verbatim):
657
658 ==== ==========================================================
659 Code Meaning
660 ==== ==========================================================
661 0 all checks pass
662 1 lint violations (blocking under this profile)
663 2 format violations (blocking under this profile)
664 3 both lint and format violations
665 4 test failures (when ``--include-tests``)
666 5 secrets detected - always blocking
667 ==== ==========================================================
668
669 Precedence: ``5 > 4 > 3 > 2 > 1 > 0``. The first rule that matches
670 wins. Because secrets takes precedence, a profile that would
671 otherwise return 0 (e.g. ``proto`` with warnings-only lint) still
672 returns 5 if secrets are present. This is the "secrets always
673 blocks" invariant.
674
675 For profiles where a check is not blocking (``proto`` lint is
676 ``warn``; ``proto`` format is ``skip``), the corresponding violation
677 counter never contributes to the exit code even if non-zero.
678
679 Parameters
680 ----------
681 profile
682 One of ``"proto"``, ``"compact"``, ``"strict"``. Unknown values
683 degrade to :data:`DEFAULT_PROFILE`.
684 result
685 A :class:`~oct.git.quality_gate.QualityGateResult` with the raw
686 counts from a completed gate run.
687 """
688 func = "apply_profile_policy"
689 if profile not in PROFILE_MATRIX:
690 profile = DEFAULT_PROFILE
691 matrix = PROFILE_MATRIX[profile]
692
693 lint_blocking = matrix["lint"] == "blocking"
694 format_blocking = matrix["format"] == "blocking"
695 # OI-531: 'optional' is truly non-blocking - test failures under the
696 # compact profile surface as a warning, never an exit code. Only
697 # 'blocking' drives the exit.
698 tests_blocking = matrix["tests"] == "blocking"
699
700 has_secrets = bool(result.secrets_findings)
701 has_lint = result.lint_violations > 0 and lint_blocking
702 has_format = result.format_violations > 0 and format_blocking
703 has_tests = result.test_failures > 0 and tests_blocking
704
705 _dbg(
706 "GIT", func,
707 f"profile={profile} secrets={has_secrets} lint={has_lint} "
708 f"format={has_format} tests={has_tests}",
709 4,
710 )
711
712 # Precedence 5 > 4 > 3 > 2 > 1 > 0.
713 if has_secrets:
714 return 5
715 if has_tests:
716 return 4
717 if has_lint and has_format:
718 return 3
719 if has_format:
720 return 2
721 if has_lint:
722 return 1
723 return 0
724
725
726# ---------------------------------------------------------------------
727# Protected-branch commit guard (Phase 4D - G-D7)
728# ---------------------------------------------------------------------
729
730
732 branch: str | None,
733 octrc: dict | None,
734 profile: str,
735) -> tuple[bool, str]:
736 """Check if committing directly to a protected branch.
737
738 Returns ``(blocked, message)``:
739
740 - On a **non-protected branch**: ``(False, "")``.
741 - On a protected branch in ``strict`` profile: ``(True, error_msg)``.
742 - On a protected branch in other profiles: ``(False, warning_msg)``.
743
744 The caller is responsible for printing the message and aborting if
745 ``blocked`` is ``True``.
746 """
747 func = "check_protected_branch_commit"
748 if not is_protected_branch(branch, octrc):
749 return False, ""
750
751 msg = (
752 f"Warning: committing directly to protected branch '{branch}'."
753 )
754 _dbg("GIT", func, f"branch={branch!r} profile={profile}", 3)
755
756 if profile == "strict":
757 return True, (
758 f"Blocked: direct commit to protected branch '{branch}' "
759 f"is not allowed under strict profile."
760 )
761 return False, msg
762
763
764# ---------------------------------------------------------------------
765# .gitignore - OCT-managed block helpers (Phase 4G)
766# ---------------------------------------------------------------------
767
768#: Marker comments that delimit the OCT-managed block in ``.gitignore``.
769#: The literals are kept stable for backwards compatibility with files
770#: created by older versions of ``oct git init``.
771OC_GITIGNORE_MARKER_START: str = "# --- Option C defaults ---"
772OC_GITIGNORE_MARKER_END: str = "# --- end Option C ---"
773
774#: Characters that are legal in gitignore patterns. Anything else is
775#: refused by :func:`validate_ignore_pattern` to keep the block
776#: hand-readable and to prevent accidental injection.
777_VALID_PATTERN_RE: re.Pattern[str] = re.compile(r"^[A-Za-z0-9_./*?\‍[\‍]\-]+$")
778
779
780def validate_ignore_pattern(pattern: str) -> tuple[bool, str]:
781 """Return ``(valid, message)`` for a candidate gitignore pattern.
782
783 Rejects empty strings, patterns containing newlines, leading ``!``
784 (negation - too easy to weaponise via ``oct git ignore``), absolute
785 paths (leading ``/`` is interpreted as anchored to repo root which
786 is allowed by gitignore but our policy is to require relative
787 patterns), and any character outside :data:`_VALID_PATTERN_RE`.
788 """
789 func = "validate_ignore_pattern"
790 if not isinstance(pattern, str):
791 return False, f"pattern must be a string, got {type(pattern).__name__}"
792 if not pattern.strip():
793 return False, "pattern is empty"
794 if "\n" in pattern or "\r" in pattern:
795 return False, "pattern contains a newline"
796 if pattern.startswith("!"):
797 return False, "negation patterns ('!...') are not allowed"
798 if pattern.startswith("/"):
799 return False, "absolute patterns (leading '/') are not allowed"
800 if not _VALID_PATTERN_RE.match(pattern):
801 return False, (
802 "pattern contains characters outside the safe set "
803 "[A-Za-z0-9_./*?[]-]"
804 )
805 _dbg("GIT", func, f"pattern={pattern!r} ok", 4)
806 return True, ""
807
808
809def _read_gitignore(repo_root: Path) -> list[str]:
810 """Return the lines of ``.gitignore`` (no trailing newlines)."""
811 path = Path(repo_root) / ".gitignore"
812 if not path.is_file():
813 return []
814 return path.read_text(encoding="utf-8").splitlines()
815
816
817def _write_gitignore(repo_root: Path, lines: list[str]) -> None:
818 """Write *lines* back to ``.gitignore`` with trailing newline."""
819 path = Path(repo_root) / ".gitignore"
820 body = "\n".join(lines)
821 if body and not body.endswith("\n"):
822 body += "\n"
823 path.write_text(body, encoding="utf-8")
824
825
826def _find_oct_block(lines: list[str]) -> tuple[int, int]:
827 """Return ``(start, end)`` line indices of the OCT block in *lines*.
828
829 ``start`` is the index of the start marker, ``end`` the index of
830 the end marker (inclusive). Returns ``(-1, -1)`` if the block is
831 absent or malformed (start without end).
832 """
833 start = -1
834 end = -1
835 for idx, line in enumerate(lines):
836 stripped = line.strip()
837 if stripped == OC_GITIGNORE_MARKER_START:
838 start = idx
839 elif stripped == OC_GITIGNORE_MARKER_END and start != -1:
840 end = idx
841 break
842 if start == -1 or end == -1:
843 return -1, -1
844 return start, end
845
846
847def list_ignore_patterns(repo_root: Path) -> tuple[list[str], list[str]]:
848 """Return ``(oct_managed, other)`` patterns from ``.gitignore``.
849
850 Patterns inside the OCT-managed block are returned in the first
851 list; everything else (user-managed) is returned in the second.
852 Comment lines and blank lines are excluded from both.
853 """
854 func = "list_ignore_patterns"
855 _dbg("GIT", func, f"repo_root={repo_root}", 3)
856 lines = _read_gitignore(repo_root)
857 if not lines:
858 return [], []
859 start, end = _find_oct_block(lines)
860 oct_managed: list[str] = []
861 other: list[str] = []
862 for idx, line in enumerate(lines):
863 stripped = line.strip()
864 if not stripped or stripped.startswith("#"):
865 continue
866 if start != -1 and start < idx < end:
867 oct_managed.append(stripped)
868 else:
869 other.append(stripped)
870 _dbg(
871 "GIT", func,
872 f"oct={len(oct_managed)} other={len(other)}",
873 4,
874 )
875 return oct_managed, other
876
877
879 repo_root: Path,
880 patterns: list[str],
881) -> list[str]:
882 """Append *patterns* to the OCT-managed block of ``.gitignore``.
883
884 Creates the block (with markers) if it does not exist. Patterns
885 already present anywhere in the file are skipped. Returns the list
886 of patterns actually added (in input order, after dedup).
887
888 Caller is responsible for validating each pattern via
889 :func:`validate_ignore_pattern` first.
890 """
891 func = "add_ignore_patterns"
892 _dbg("GIT", func, f"repo_root={repo_root} patterns={len(patterns)}", 3)
893 lines = _read_gitignore(repo_root)
894 existing: set[str] = {
895 line.strip() for line in lines
896 if line.strip() and not line.strip().startswith("#")
897 }
898 to_add = [p for p in patterns if p not in existing]
899 if not to_add:
900 _dbg("GIT", func, "no new patterns to add", 4)
901 return []
902
903 start, end = _find_oct_block(lines)
904 if start == -1:
905 # Block does not exist - append a fresh one.
906 new_lines = list(lines)
907 if new_lines and new_lines[-1].strip() != "":
908 new_lines.append("")
909 new_lines.append(OC_GITIGNORE_MARKER_START)
910 new_lines.extend(to_add)
911 new_lines.append(OC_GITIGNORE_MARKER_END)
912 else:
913 # Insert before the end marker.
914 new_lines = list(lines[:end]) + to_add + list(lines[end:])
915
916 _write_gitignore(repo_root, new_lines)
917 _dbg("GIT", func, f"added {len(to_add)} pattern(s)", 4)
918 return to_add
919
920
922 repo_root: Path,
923 pattern: str,
924) -> tuple[bool, str]:
925 """Remove *pattern* from the OCT-managed block.
926
927 Returns ``(removed, message)``. Refuses to touch patterns located
928 outside the OCT block (those are user-managed); the caller should
929 surface the message to the user.
930 """
931 func = "remove_ignore_pattern"
932 _dbg("GIT", func, f"repo_root={repo_root} pattern={pattern!r}", 3)
933 lines = _read_gitignore(repo_root)
934 if not lines:
935 return False, ".gitignore does not exist"
936 start, end = _find_oct_block(lines)
937 if start == -1:
938 return False, "OCT-managed block not found in .gitignore"
939
940 target_idx = -1
941 for idx in range(start + 1, end):
942 if lines[idx].strip() == pattern:
943 target_idx = idx
944 break
945
946 if target_idx == -1:
947 # Check whether it's outside the block - give a helpful error.
948 for idx, line in enumerate(lines):
949 if line.strip() == pattern and not (start < idx < end):
950 return False, (
951 f"pattern '{pattern}' is outside the OCT-managed "
952 f"block; remove it manually with a text editor"
953 )
954 return False, f"pattern '{pattern}' not found in OCT-managed block"
955
956 new_lines = list(lines[:target_idx]) + list(lines[target_idx + 1:])
957 _write_gitignore(repo_root, new_lines)
958 _dbg("GIT", func, f"removed pattern at line {target_idx}", 4)
959 return True, ""
Profile resolve_profile_for_project(Path project_root, str|None branch, dict|None octrc, str|None cli_override)
Definition policy.py:625
bool resolve_manifest_nested_git(Path project_root)
Definition policy.py:566
bool is_protected_branch(str|None branch, dict|None octrc, *, bool allow_optout=False)
Definition policy.py:246
str|None generate_branch_name_from_message(str message)
Definition policy.py:183
Profile _resolve_auto_profile(str|None branch)
Definition policy.py:372
tuple[int, int] _find_oct_block(list[str] lines)
Definition policy.py:826
tuple[bool, str] remove_ignore_pattern(Path repo_root, str pattern)
Definition policy.py:924
int apply_profile_policy(str profile, "QualityGateResult" result)
Definition policy.py:653
tuple[bool, str] validate_branch_name(str|None branch, dict|None octrc)
Definition policy.py:308
list[str] add_ignore_patterns(Path repo_root, list[str] patterns)
Definition policy.py:881
list[str] _read_gitignore(Path repo_root)
Definition policy.py:809
Profile resolve_effective_profile(str|None branch, dict|None octrc, str|None cli_override, str|None manifest_profile=None, *, bool allow_optout=False)
Definition policy.py:406
None _write_gitignore(Path repo_root, list[str] lines)
Definition policy.py:817
str|None resolve_manifest_profile(Path project_root)
Definition policy.py:498
tuple[bool, str] validate_ignore_pattern(str pattern)
Definition policy.py:780
tuple[bool, str] check_protected_branch_commit(str|None branch, dict|None octrc, str profile)
Definition policy.py:735
tuple[list[str], list[str]] list_ignore_patterns(Path repo_root)
Definition policy.py:847