oct
Loading...
Searching...
No Matches
oct.git.policy Namespace Reference

Functions

str|None generate_branch_name_from_message (str message)
bool is_protected_branch (str|None branch, dict|None octrc, *, bool allow_optout=False)
tuple[bool, str] validate_branch_name (str|None branch, dict|None octrc)
Profile _resolve_auto_profile (str|None branch)
Profile resolve_effective_profile (str|None branch, dict|None octrc, str|None cli_override, str|None manifest_profile=None, *, bool allow_optout=False)
str|None resolve_manifest_profile (Path project_root)
bool resolve_manifest_nested_git (Path project_root)
Profile resolve_profile_for_project (Path project_root, str|None branch, dict|None octrc, str|None cli_override)
int apply_profile_policy (str profile, "QualityGateResult" result)
tuple[bool, str] check_protected_branch_commit (str|None branch, dict|None octrc, str profile)
tuple[bool, str] validate_ignore_pattern (str pattern)
list[str] _read_gitignore (Path repo_root)
None _write_gitignore (Path repo_root, list[str] lines)
tuple[int, int] _find_oct_block (list[str] lines)
tuple[list[str], list[str]] list_ignore_patterns (Path repo_root)
list[str] add_ignore_patterns (Path repo_root, list[str] patterns)
tuple[bool, str] remove_ignore_pattern (Path repo_root, str pattern)

Variables

 Profile = Literal["proto", "compact", "strict"]
tuple VALID_PROFILES = ("proto", "compact", "strict")
Profile DEFAULT_PROFILE = "compact"
tuple DEFAULT_PROTECTED_BRANCHES = ("main", "master")
dict PROFILE_MATRIX
bool SECRETS_ALWAYS_BLOCKS = True
tuple DEFAULT_BRANCH_PATTERNS
dict _AUTO_PROFILE_MAP
dict _COMMIT_TYPE_TO_PREFIX
int _BRANCH_SLUG_MAX_LEN = 50
str OC_GITIGNORE_MARKER_START = "# --- Option C defaults ---"
str OC_GITIGNORE_MARKER_END = "# --- end Option C ---"
re _VALID_PATTERN_RE = re.compile(r"^[A-Za-z0-9_./*?\‍[\‍]\-]+$")

Detailed Description

Purpose
-------
Encode the Phase 4B profile-aware enforcement rules for the ``oct git``
quality gate. Maps a combination of profile (``proto`` / ``compact`` /
``strict``), branch context, and raw check findings to a semantic exit
code in the range 0..5 defined by blueprint §5.3.

Responsibilities
----------------
- Resolve the effective lint profile from CLI / octrc / branch context,
  honouring the protected-branch override (blueprint §7.2).
- Restrict the protected-branch opt-out to nested-repo subprojects
  (Plan 209): an ordinary subproject's ``git.protected_branches`` can
  add a branch but never remove ``main``/``master`` from protection.
- Apply the profile enforcement matrix (blueprint §5.3 / §7.1) to a
  :class:`~oct.git.quality_gate.QualityGateResult` and return the
  matching exit code.
- Enforce the invariant "secrets always blocks" regardless of profile
  (blueprint §5.3 final clause).
- Manage the OCT-managed block in ``.gitignore`` - add/remove/list
  patterns inside the fenced block, leaving user-managed lines alone.

Diagnostics
-----------
Domain: GIT
Levels:
    L1 - workspace-manifest lookup failures (degrading to the
         octrc-only chain rather than breaking the gate)
    L2 - (none) pure decision helpers; callers own lifecycle logging
    L3 - profile resolution and policy decisions
    L4 - per-field raw counts during exit-code derivation

Contracts
---------
- No subprocess, no Click. Profile *decisions* are pure; the two
  functions that read config off disk (``resolve_manifest_profile`` and
  the ``.gitignore`` block helpers) are the documented exceptions.
- ``resolve_effective_profile`` never raises; unknown values collapse
  to the documented default. It is the SINGLE resolver - the workspace
  fan-out path (``oct.git.oct_git._resolve_subproject_profile``)
  delegates to it, so the two paths cannot drift apart (Plan 197).
- ``resolve_manifest_profile`` never raises: a missing, unreadable or
  malformed manifest yields ``None``, which degrades to the pre-197
  octrc-only behaviour.
- ``apply_profile_policy`` depends only on the ``QualityGateResult``
  dataclass defined in :mod:`oct.git.quality_gate`.
- Precedence for the exit code is strictly: 5 > 4 > 3 > 2 > 1 > 0.
- Plan 209: protected-branch opt-out (an explicit
  ``git.protected_branches`` list) is a ``nested_git``-only privilege -
  every other subproject may ADD to but never REMOVE the workspace
  defaults from its protected set. ``resolve_manifest_nested_git``
  never raises, mirroring ``resolve_manifest_profile``'s degrade-to-
  safe-default contract (it returns ``False``, meaning "not exempt",
  rather than crashing or guessing).

Dependencies
------------
- oct.core.diagnostics (_dbg structured logger)
- oct.core.workspace (lazy; manifest lookup of the owning subproject's profile and nested_git flag)
- oct.git.conventional (lazy; parse_commit_message - type, scope and subject for branch names)
- oct.git.quality_gate (lazy; QualityGateResult annotation, imported only for type checking)

Function Documentation

◆ _find_oct_block()

tuple[int, int] oct.git.policy._find_oct_block ( list[str] lines)
protected
Return ``(start, end)`` line indices of the OCT block in *lines*.

``start`` is the index of the start marker, ``end`` the index of
the end marker (inclusive). Returns ``(-1, -1)`` if the block is
absent or malformed (start without end).

Definition at line 826 of file policy.py.

Here is the caller graph for this function:

◆ _read_gitignore()

list[str] oct.git.policy._read_gitignore ( Path repo_root)
protected
Return the lines of ``.gitignore`` (no trailing newlines).

Definition at line 809 of file policy.py.

Here is the caller graph for this function:

◆ _resolve_auto_profile()

Profile oct.git.policy._resolve_auto_profile ( str | None branch)
protected
Map *branch* to a profile using :data:`_AUTO_PROFILE_MAP`.

Exact matches (``main``, ``master``) are tried first, then prefix
matches (``feature/``, ``release/``). Returns :data:`DEFAULT_PROFILE`
for unrecognised patterns or ``None`` / ``"HEAD"`` branch.

Definition at line 372 of file policy.py.

Here is the caller graph for this function:

◆ _write_gitignore()

None oct.git.policy._write_gitignore ( Path repo_root,
list[str] lines )
protected
Write *lines* back to ``.gitignore`` with trailing newline.

Definition at line 817 of file policy.py.

Here is the caller graph for this function:

◆ add_ignore_patterns()

list[str] oct.git.policy.add_ignore_patterns ( Path repo_root,
list[str] patterns )
Append *patterns* to the OCT-managed block of ``.gitignore``.

Creates the block (with markers) if it does not exist. Patterns
already present anywhere in the file are skipped. Returns the list
of patterns actually added (in input order, after dedup).

Caller is responsible for validating each pattern via
:func:`validate_ignore_pattern` first.

Definition at line 878 of file policy.py.

Here is the call graph for this function:

◆ apply_profile_policy()

int oct.git.policy.apply_profile_policy ( str profile,
"QualityGateResult" result )
Derive the semantic exit code for a quality-gate result.

Exit code table (blueprint §5.3, verbatim):

==== ==========================================================
Code Meaning
==== ==========================================================
   0 all checks pass
   1 lint violations (blocking under this profile)
   2 format violations (blocking under this profile)
   3 both lint and format violations
   4 test failures (when ``--include-tests``)
   5 secrets detected - always blocking
==== ==========================================================

Precedence: ``5 > 4 > 3 > 2 > 1 > 0``. The first rule that matches
wins. Because secrets takes precedence, a profile that would
otherwise return 0 (e.g. ``proto`` with warnings-only lint) still
returns 5 if secrets are present. This is the "secrets always
blocks" invariant.

For profiles where a check is not blocking (``proto`` lint is
``warn``; ``proto`` format is ``skip``), the corresponding violation
counter never contributes to the exit code even if non-zero.

Parameters
----------
profile
    One of ``"proto"``, ``"compact"``, ``"strict"``. Unknown values
    degrade to :data:`DEFAULT_PROFILE`.
result
    A :class:`~oct.git.quality_gate.QualityGateResult` with the raw
    counts from a completed gate run.

Definition at line 653 of file policy.py.

◆ check_protected_branch_commit()

tuple[bool, str] oct.git.policy.check_protected_branch_commit ( str | None branch,
dict | None octrc,
str profile )
Check if committing directly to a protected branch.

Returns ``(blocked, message)``:

- On a **non-protected branch**: ``(False, "")``.
- On a protected branch in ``strict`` profile: ``(True, error_msg)``.
- On a protected branch in other profiles: ``(False, warning_msg)``.

The caller is responsible for printing the message and aborting if
``blocked`` is ``True``.

Definition at line 731 of file policy.py.

Here is the call graph for this function:

◆ generate_branch_name_from_message()

str | None oct.git.policy.generate_branch_name_from_message ( str message)
Derive a valid branch name from a Conventional Commits *message*.

Returns a branch name like ``feature/auth-add-jwt-tokens`` or
``None`` when the message cannot be parsed.

The generated name is guaranteed to pass
:func:`validate_branch_name` against the default patterns.

Definition at line 183 of file policy.py.

Here is the call graph for this function:

◆ is_protected_branch()

bool oct.git.policy.is_protected_branch ( str | None branch,
dict | None octrc,
* ,
bool allow_optout = False )
Return True if ``branch`` is listed in ``git.protected_branches``.

A missing ``octrc`` or a missing ``git.protected_branches`` key
falls back to :data:`DEFAULT_PROTECTED_BRANCHES`. Detached HEAD
(``branch is None``) is never considered protected - detached
workflows are per-definition outside branch policy.

Plan 209: an explicit ``protected_branches`` list is a PRIVILEGE,
not an open door. When the octrc sets one:

- ``allow_optout=False`` (the safe default, every ordinary
  subproject): the effective protected set is the UNION of the
  configured list and :data:`DEFAULT_PROTECTED_BRANCHES` - a
  subproject may ADD a protected branch but can never REMOVE
  ``main``/``master`` from protection by omitting them.
- ``allow_optout=True`` (reserved for a ``nested_git`` subproject -
  a foreign nested repo that owns its own branch policy, e.g.
  ``oc_explorer``): the configured list is honored verbatim, so an
  explicit empty list means genuinely no protected branches.

An unset or malformed (non-list) ``protected_branches`` always
falls back to :data:`DEFAULT_PROTECTED_BRANCHES`, regardless of
*allow_optout* - opting out requires an explicit list.

Definition at line 241 of file policy.py.

Here is the caller graph for this function:

◆ list_ignore_patterns()

tuple[list[str], list[str]] oct.git.policy.list_ignore_patterns ( Path repo_root)
Return ``(oct_managed, other)`` patterns from ``.gitignore``.

Patterns inside the OCT-managed block are returned in the first
list; everything else (user-managed) is returned in the second.
Comment lines and blank lines are excluded from both.

Definition at line 847 of file policy.py.

Here is the call graph for this function:

◆ remove_ignore_pattern()

tuple[bool, str] oct.git.policy.remove_ignore_pattern ( Path repo_root,
str pattern )
Remove *pattern* from the OCT-managed block.

Returns ``(removed, message)``. Refuses to touch patterns located
outside the OCT block (those are user-managed); the caller should
surface the message to the user.

Definition at line 921 of file policy.py.

Here is the call graph for this function:

◆ resolve_effective_profile()

Profile oct.git.policy.resolve_effective_profile ( str | None branch,
dict | None octrc,
str | None cli_override,
str | None manifest_profile = None,
* ,
bool allow_optout = False )
Return the profile that should be applied for the current run.

Precedence (highest wins):

1. **Protected-branch override** - if ``branch`` is listed in the
   project's protected-branches set, the result is always
   ``"strict"``. This cannot be overridden by CLI or config.
2. **CLI ``--profile``** - explicit user override from the command
   line. Must be one of :data:`VALID_PROFILES`.
3. **Workspace manifest ``subprojects[].profile``** - the profile
   the workspace declares for the owning subproject, supplied by
   the caller via *manifest_profile* (Plan 197). ``None`` means
   "no workspace, or the workspace declares nothing", which
   degrades to the pre-197 octrc-only chain below.
4. **``.octrc.json → git.pre_commit_profile``** - the persistent
   per-project default.
5. **``.octrc.json → linter.profile``** - if the git-specific key
   is absent, fall back to the linter-wide profile.
6. **:data:`DEFAULT_PROFILE`** - final fallback if nothing above
   is set.

Unknown profile strings (from any source) are silently discarded in
favour of the next lower level of precedence. Malformed ``octrc``
values are also ignored - never raise.

Plan 197: this is the ONE resolver. Both the single-project path
(``oct git check`` / ``oct git commit`` run from inside a
subproject) and the workspace fan-out path
(``oct.git.oct_git._resolve_subproject_profile``) call it, so a
file's effective profile cannot depend on the directory the gate
was invoked from. Callers that have a project root but not a
manifest profile should use :func:`resolve_profile_for_project`,
which does the lookup for them.

Plan 209: *allow_optout* is forwarded verbatim to
:func:`is_protected_branch` - pass ``True`` only when the caller
has verified the owning subproject is ``nested_git``. Defaults
False, the safe choice for every ordinary subproject.

Definition at line 399 of file policy.py.

Here is the call graph for this function:
Here is the caller graph for this function:

◆ resolve_manifest_nested_git()

bool oct.git.policy.resolve_manifest_nested_git ( Path project_root)
Return whether *project_root* is declared ``nested_git`` in the manifest.

Mirrors :func:`resolve_manifest_profile`'s walk-up-and-load shape so
the two lookups can never disagree about WHICH subproject owns
*project_root*. Returns ``False`` - never raises - for every
degenerate case (no workspace above *project_root*, an unreadable
or malformed manifest, or *project_root* falling outside every
declared subproject): the safe default is "not exempt", i.e. the
protected-branch opt-out restriction applies.

Plan 209: this is what lets :func:`resolve_profile_for_project`
pass the correct ``allow_optout`` to :func:`is_protected_branch`
without every caller having to look up the manifest itself.

Definition at line 566 of file policy.py.

Here is the caller graph for this function:

◆ resolve_manifest_profile()

str | None oct.git.policy.resolve_manifest_profile ( Path project_root)
Return the workspace-declared profile for *project_root*.

Walks upward from *project_root* to the nearest
``.option_c_workspace.json``, loads it, and returns the ``profile``
the manifest declares for the subproject that OWNS *project_root*
(deepest match wins for nested subprojects).

Returns ``None`` - never raises - when any of the following holds,
so callers degrade cleanly to the pre-197 octrc-only chain:

- *project_root* is not inside a workspace (plain single project);
- the manifest is unreadable or malformed;
- *project_root* is the workspace root itself, or otherwise falls
  outside every declared subproject;
- the owning subproject declares no profile, or declares a value
  outside :data:`VALID_PROFILES`.

Plan 197: this closes the gap that let ``oc_chat`` declare
``strict`` and be gated at ``compact`` on every feature branch.

Definition at line 498 of file policy.py.

Here is the caller graph for this function:

◆ resolve_profile_for_project()

Profile oct.git.policy.resolve_profile_for_project ( Path project_root,
str | None branch,
dict | None octrc,
str | None cli_override )
Resolve the effective profile for a project on disk.

Thin composition of :func:`resolve_manifest_profile`,
:func:`resolve_manifest_nested_git`, and
:func:`resolve_effective_profile`: the single entry point for
callers that hold a project root. Using it instead of calling
:func:`resolve_effective_profile` directly is what makes the
single-project path agree with the workspace fan-out path
(Plan 197) - including, since Plan 209, on the protected-branch
opt-out restriction.

Definition at line 620 of file policy.py.

Here is the call graph for this function:

◆ validate_branch_name()

tuple[bool, str] oct.git.policy.validate_branch_name ( str | None branch,
dict | None octrc )
Validate *branch* against the configured naming patterns.

Returns ``(valid, message)``:

- ``git.branch_naming.enabled`` is ``False`` → ``(True, "")``
- ``branch`` is ``None`` (detached HEAD) → ``(True, "")``
- Branch matches at least one pattern → ``(True, "")``
- No pattern matches → ``(False, "Branch name '...' does not ...")``

Patterns default to :data:`DEFAULT_BRANCH_PATTERNS` when not
overridden in ``.octrc.json``.

Definition at line 305 of file policy.py.

Here is the caller graph for this function:

◆ validate_ignore_pattern()

tuple[bool, str] oct.git.policy.validate_ignore_pattern ( str pattern)
Return ``(valid, message)`` for a candidate gitignore pattern.

Rejects empty strings, patterns containing newlines, leading ``!``
(negation - too easy to weaponise via ``oct git ignore``), absolute
paths (leading ``/`` is interpreted as anchored to repo root which
is allowed by gitignore but our policy is to require relative
patterns), and any character outside :data:`_VALID_PATTERN_RE`.

Definition at line 780 of file policy.py.

Variable Documentation

◆ _AUTO_PROFILE_MAP

dict oct.git.policy._AUTO_PROFILE_MAP
protected
Initial value:
1= {
2 "main": "strict",
3 "master": "strict",
4 "release/": "strict",
5 "hotfix/": "strict",
6 "feature/": "compact",
7 "bugfix/": "compact",
8 "docs/": "compact",
9 "chore/": "compact",
10}

Definition at line 149 of file policy.py.

◆ _BRANCH_SLUG_MAX_LEN

int oct.git.policy._BRANCH_SLUG_MAX_LEN = 50
protected

Definition at line 175 of file policy.py.

◆ _COMMIT_TYPE_TO_PREFIX

dict oct.git.policy._COMMIT_TYPE_TO_PREFIX
protected
Initial value:
1= {
2 "feat": "feature",
3 "fix": "bugfix",
4 "docs": "docs",
5 "style": "chore",
6 "refactor": "chore",
7 "perf": "feature",
8 "test": "chore",
9 "chore": "chore",
10 "sec": "hotfix",
11}

Definition at line 162 of file policy.py.

◆ _VALID_PATTERN_RE

re oct.git.policy._VALID_PATTERN_RE = re.compile(r"^[A-Za-z0-9_./*?\‍[\‍]\-]+$")
protected

Definition at line 777 of file policy.py.

◆ DEFAULT_BRANCH_PATTERNS

tuple oct.git.policy.DEFAULT_BRANCH_PATTERNS
Initial value:
1= (
2 r"^(main|master|develop)$",
3 r"^(release|hotfix|feature|bugfix|docs|chore)/.+$",
4)

Definition at line 141 of file policy.py.

◆ DEFAULT_PROFILE

Profile oct.git.policy.DEFAULT_PROFILE = "compact"

Definition at line 96 of file policy.py.

◆ DEFAULT_PROTECTED_BRANCHES

tuple oct.git.policy.DEFAULT_PROTECTED_BRANCHES = ("main", "master")

Definition at line 101 of file policy.py.

◆ OC_GITIGNORE_MARKER_END

str oct.git.policy.OC_GITIGNORE_MARKER_END = "# --- end Option C ---"

Definition at line 772 of file policy.py.

◆ OC_GITIGNORE_MARKER_START

str oct.git.policy.OC_GITIGNORE_MARKER_START = "# --- Option C defaults ---"

Definition at line 771 of file policy.py.

◆ Profile

oct.git.policy.Profile = Literal["proto", "compact", "strict"]

Definition at line 87 of file policy.py.

◆ PROFILE_MATRIX

dict oct.git.policy.PROFILE_MATRIX
Initial value:
1= {
2 "proto": {
3 "lint": "warn",
4 "format": "skip",
5 "secrets": "blocking", # OCT-LINT: disable=no_hardcoded_secrets reason="Profile enforcement matrix; 'secrets' is a check name, not a credential"
6 "tests": "skip",
7 },
8 "compact": {
9 "lint": "blocking",
10 "format": "blocking",
11 "secrets": "blocking", # OCT-LINT: disable=no_hardcoded_secrets reason="Profile enforcement matrix; 'secrets' is a check name, not a credential"
12 "tests": "optional",
13 },
14 "strict": {
15 "lint": "blocking",
16 "format": "blocking",
17 "secrets": "blocking", # OCT-LINT: disable=no_hardcoded_secrets reason="Profile enforcement matrix; 'secrets' is a check name, not a credential"
18 "tests": "blocking",
19 },
20}

Definition at line 112 of file policy.py.

◆ SECRETS_ALWAYS_BLOCKS

bool oct.git.policy.SECRETS_ALWAYS_BLOCKS = True

Definition at line 136 of file policy.py.

◆ VALID_PROFILES

tuple oct.git.policy.VALID_PROFILES = ("proto", "compact", "strict")

Definition at line 91 of file policy.py.