Option C Tools
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- Apply the profile enforcement matrix (blueprint §5.3 / §7.1) to a
18 :class:`~oct.git.quality_gate.QualityGateResult` and return the
19 matching exit code.
20- Enforce the invariant "secrets always blocks" regardless of profile
21 (blueprint §5.3 final clause).
22- Manage the OCT-managed block in ``.gitignore`` — add/remove/list
23 patterns inside the fenced block, leaving user-managed lines alone.
24
25Diagnostics
26-----------
27Domain: GIT
28Levels:
29 L3 — profile resolution and policy decisions
30 L4 — per-field raw counts during exit-code derivation
31
32Contracts
33---------
34- This module is pure: no filesystem, no subprocess, no Click.
35- ``resolve_effective_profile`` never raises; unknown values collapse
36 to the documented default.
37- ``apply_profile_policy`` depends only on the ``QualityGateResult``
38 dataclass defined in :mod:`oct.git.quality_gate`.
39- Precedence for the exit code is strictly: 5 > 4 > 3 > 2 > 1 > 0.
40"""
41
42from __future__ import annotations
43
44import re
45import sys
46from pathlib import Path
47from typing import TYPE_CHECKING, Literal
48
49from oct.core.diagnostics import _dbg
50
51
52if TYPE_CHECKING: # pragma: no cover - avoid import cycle at runtime
53 from oct.git.quality_gate import QualityGateResult
54
55
56# ---------------------------------------------------------------------
57# Constants
58# ---------------------------------------------------------------------
59
60Profile = Literal["proto", "compact", "strict"]
61
62#: The three canonical profile names. Anything else resolves to
63#: ``DEFAULT_PROFILE``.
64VALID_PROFILES: tuple[Profile, ...] = ("proto", "compact", "strict")
65
66#: Fallback profile used when no other source (CLI, config, branch
67#: override) selects one. Blueprint §7 names "compact" as the project
68#: default.
69DEFAULT_PROFILE: Profile = "compact"
70
71#: Default protected branches written into ``.octrc.json`` when a
72#: project does not override them (blueprint §7.2). Branches listed
73#: here force the profile to ``strict`` regardless of CLI or config.
74DEFAULT_PROTECTED_BRANCHES: tuple[str, ...] = ("main", "master")
75
76#: Profile enforcement matrix (blueprint §5.3 / §7.1, verbatim).
77#:
78#: ``lint`` / ``format`` / ``tests`` values are one of:
79#: - ``"blocking"``: a violation produces a non-zero exit code.
80#: - ``"optional"``: the check runs and reports, but failures are
81#: advisory — exit code is unaffected (OI-531).
82#: - ``"warn"``: violations are reported but do not affect exit code.
83#: - ``"skip"``: the check is not run at all.
84#: ``secrets`` is always ``"blocking"`` — see :data:`SECRETS_ALWAYS_BLOCKS`.
85PROFILE_MATRIX: dict[Profile, dict[str, str]] = {
86 "proto": {
87 "lint": "warn",
88 "format": "skip",
89 "secrets": "blocking",
90 "tests": "skip",
91 },
92 "compact": {
93 "lint": "blocking",
94 "format": "blocking",
95 "secrets": "blocking",
96 "tests": "optional",
97 },
98 "strict": {
99 "lint": "blocking",
100 "format": "blocking",
101 "secrets": "blocking",
102 "tests": "blocking",
103 },
104}
105
106#: Invariant: secrets findings are always blocking. This is asserted as
107#: a unit test so any accidental weakening of :data:`PROFILE_MATRIX`
108#: produces a CI failure rather than a silent security regression.
109SECRETS_ALWAYS_BLOCKS: bool = True
110
111#: Default branch naming patterns (Phase 4E — G-E3, blueprint §7.1).
112#: Matches main/master/develop as exact names and common prefix-based
113#: patterns. Projects may override via ``git.branch_naming.patterns``.
114DEFAULT_BRANCH_PATTERNS: tuple[str, ...] = (
115 r"^(main|master|develop)$",
116 r"^(release|hotfix|feature|bugfix|docs|chore)/.+$",
117)
118
119#: Auto-profile mapping (Phase 4E — G-E4, blueprint §7.3). Maps branch
120#: name prefixes to profiles. Exact matches are tried first, then
121#: prefix matches. Unrecognised branches fall back to DEFAULT_PROFILE.
122_AUTO_PROFILE_MAP: dict[str, Profile] = {
123 "main": "strict",
124 "master": "strict",
125 "release/": "strict",
126 "hotfix/": "strict",
127 "feature/": "compact",
128 "bugfix/": "compact",
129 "docs/": "compact",
130 "chore/": "compact",
131}
132
133#: Maps Conventional Commit types to branch-name prefixes for auto-
134#: branching on protected branches (Phase 4F — G-F2).
135_COMMIT_TYPE_TO_PREFIX: dict[str, str] = {
136 "feat": "feature",
137 "fix": "bugfix",
138 "docs": "docs",
139 "style": "chore",
140 "refactor": "chore",
141 "perf": "feature",
142 "test": "chore",
143 "chore": "chore",
144 "sec": "hotfix",
145}
146
147#: Maximum slug length for auto-generated branch names.
148_BRANCH_SLUG_MAX_LEN: int = 50
149
150
151# ---------------------------------------------------------------------
152# Branch name generation (Phase 4F — G-F2)
153# ---------------------------------------------------------------------
154
155
156def generate_branch_name_from_message(message: str) -> str | None:
157 """Derive a valid branch name from a Conventional Commits *message*.
158
159 Returns a branch name like ``feature/auth-add-jwt-tokens`` or
160 ``None`` when the message cannot be parsed.
161
162 The generated name is guaranteed to pass
163 :func:`validate_branch_name` against the default patterns.
164 """
165 func = "generate_branch_name_from_message"
166 _dbg("GIT", func, f"message={message[:60]!r}", 3)
167
168 from oct.git.conventional import parse_commit_message
169
170 parsed = parse_commit_message(message)
171 if parsed is None:
172 _dbg("GIT", func, "parse failed — returning None", 4)
173 return None
174
175 prefix = _COMMIT_TYPE_TO_PREFIX.get(parsed.type, "chore")
176
177 # Build slug from optional scope + subject.
178 parts: list[str] = []
179 if parsed.scope:
180 parts.append(parsed.scope)
181 parts.append(parsed.subject)
182 raw_slug = "-".join(parts).lower()
183
184 # Slugify: keep only alphanumeric + hyphens.
185 slug = re.sub(r"[^a-z0-9-]", "-", raw_slug)
186 slug = re.sub(r"-{2,}", "-", slug)
187 slug = slug.strip("-")
188
189 if not slug:
190 _dbg("GIT", func, "empty slug — returning None", 4)
191 return None
192
193 # Truncate to max length.
194 if len(slug) > _BRANCH_SLUG_MAX_LEN:
195 slug = slug[:_BRANCH_SLUG_MAX_LEN].rstrip("-")
196
197 name = f"{prefix}/{slug}"
198
199 # Sanity-check against default patterns.
200 valid, _ = validate_branch_name(name, None)
201 if not valid:
202 _dbg("GIT", func, f"generated name {name!r} invalid — returning None", 3)
203 return None
204
205 _dbg("GIT", func, f"result={name!r}", 4)
206 return name
207
208
209# ---------------------------------------------------------------------
210# Profile resolution
211# ---------------------------------------------------------------------
212
213
214def is_protected_branch(branch: str | None, octrc: dict | None) -> bool:
215 """Return True if ``branch`` is listed in ``git.protected_branches``.
216
217 A missing ``octrc`` or a missing ``git.protected_branches`` key
218 falls back to :data:`DEFAULT_PROTECTED_BRANCHES`. Detached HEAD
219 (``branch is None``) is never considered protected — detached
220 workflows are per-definition outside branch policy.
221 """
222 func = "is_protected_branch"
223 _dbg("GIT", func, f"branch={branch!r}", 3)
224 if branch is None or not branch:
225 return False
226 if branch == "HEAD":
227 # :func:`oct.core.git.current_branch` returns the literal "HEAD"
228 # when detached. Treat it the same as ``None``.
229 return False
230 protected: list[str] | tuple[str, ...] = DEFAULT_PROTECTED_BRANCHES
231 if isinstance(octrc, dict):
232 git_cfg = octrc.get("git")
233 if isinstance(git_cfg, dict):
234 candidate = git_cfg.get("protected_branches")
235 if isinstance(candidate, list):
236 # Only accept strings; ignore junk entries silently so a
237 # malformed config cannot downgrade the gate.
238 protected = [b for b in candidate if isinstance(b, str)]
239 result = branch in protected
240 _dbg("GIT", func, f"result={result} protected={list(protected)}", 4)
241 return result
242
243
244# ---------------------------------------------------------------------
245# Branch naming validation (Phase 4E — G-E3)
246# ---------------------------------------------------------------------
247
248
250 branch: str | None,
251 octrc: dict | None,
252) -> tuple[bool, str]:
253 """Validate *branch* against the configured naming patterns.
254
255 Returns ``(valid, message)``:
256
257 - ``git.branch_naming.enabled`` is ``False`` → ``(True, "")``
258 - ``branch`` is ``None`` (detached HEAD) → ``(True, "")``
259 - Branch matches at least one pattern → ``(True, "")``
260 - No pattern matches → ``(False, "Branch name '...' does not ...")``
261
262 Patterns default to :data:`DEFAULT_BRANCH_PATTERNS` when not
263 overridden in ``.octrc.json``.
264 """
265 func = "validate_branch_name"
266 _dbg("GIT", func, f"branch={branch!r}", 3)
267
268 if branch is None or branch == "HEAD":
269 return True, ""
270
271 # Read config.
272 enabled = True
273 patterns: list[str] = list(DEFAULT_BRANCH_PATTERNS)
274
275 if isinstance(octrc, dict):
276 git_cfg = octrc.get("git")
277 if isinstance(git_cfg, dict):
278 naming_cfg = git_cfg.get("branch_naming")
279 if isinstance(naming_cfg, dict):
280 raw_enabled = naming_cfg.get("enabled")
281 if isinstance(raw_enabled, bool):
282 enabled = raw_enabled
283 raw_patterns = naming_cfg.get("patterns")
284 if isinstance(raw_patterns, list):
285 patterns = [p for p in raw_patterns if isinstance(p, str)]
286
287 if not enabled:
288 _dbg("GIT", func, "branch naming disabled", 4)
289 return True, ""
290
291 if not patterns:
292 # No patterns → everything is valid.
293 return True, ""
294
295 for pattern in patterns:
296 try:
297 if re.match(pattern, branch):
298 _dbg("GIT", func, f"matched: {pattern!r}", 4)
299 return True, ""
300 except re.error:
301 continue
302
303 msg = (
304 f"Branch name '{branch}' does not match any allowed pattern. "
305 f"Expected patterns: {', '.join(patterns)}"
306 )
307 _dbg("GIT", func, f"invalid: {msg}", 3)
308 return False, msg
309
310
311# ---------------------------------------------------------------------
312# Auto-profile resolution (Phase 4E — G-E4)
313# ---------------------------------------------------------------------
314
315
316def _resolve_auto_profile(branch: str | None) -> Profile:
317 """Map *branch* to a profile using :data:`_AUTO_PROFILE_MAP`.
318
319 Exact matches (``main``, ``master``) are tried first, then prefix
320 matches (``feature/``, ``release/``). Returns :data:`DEFAULT_PROFILE`
321 for unrecognised patterns or ``None`` / ``"HEAD"`` branch.
322 """
323 func = "_resolve_auto_profile"
324 if branch is None or branch == "HEAD":
325 return DEFAULT_PROFILE
326
327 # Exact match first.
328 exact = _AUTO_PROFILE_MAP.get(branch)
329 if exact is not None:
330 _dbg("GIT", func, f"exact match: {branch!r} -> {exact}", 4)
331 return exact
332
333 # Prefix match.
334 for prefix, profile in _AUTO_PROFILE_MAP.items():
335 if prefix.endswith("/") and branch.startswith(prefix):
336 _dbg("GIT", func, f"prefix match: {prefix!r} -> {profile}", 4)
337 return profile
338
339 _dbg("GIT", func, f"no match for {branch!r}, fallback -> {DEFAULT_PROFILE}", 4)
340 return DEFAULT_PROFILE
341
342
344 branch: str | None,
345 octrc: dict | None,
346 cli_override: str | None,
347) -> Profile:
348 """Return the profile that should be applied for the current run.
349
350 Precedence (highest wins):
351
352 1. **Protected-branch override** — if ``branch`` is listed in the
353 project's protected-branches set, the result is always
354 ``"strict"``. This cannot be overridden by CLI or config.
355 2. **CLI ``--profile``** — explicit user override from the command
356 line. Must be one of :data:`VALID_PROFILES`.
357 3. **``.octrc.json → git.pre_commit_profile``** — the persistent
358 per-project default.
359 4. **``.octrc.json → linter.profile``** — if the git-specific key
360 is absent, fall back to the linter-wide profile.
361 5. **:data:`DEFAULT_PROFILE`** — final fallback if nothing above
362 is set.
363
364 Unknown profile strings (from any source) are silently discarded in
365 favour of the next lower level of precedence. Malformed ``octrc``
366 values are also ignored — never raise.
367 """
368 func = "resolve_effective_profile"
369 _dbg(
370 "GIT", func,
371 f"branch={branch!r} cli_override={cli_override!r}",
372 3,
373 )
374
375 # 1. Protected branch override (unconditional).
376 if is_protected_branch(branch, octrc):
377 _dbg("GIT", func, "protected branch -> strict", 4)
378 return "strict"
379
380 # 2. CLI override.
381 if cli_override and cli_override in VALID_PROFILES:
382 _dbg("GIT", func, f"cli override -> {cli_override}", 4)
383 return cli_override # type: ignore[return-value]
384
385 # 3 & 4. octrc config.
386 if isinstance(octrc, dict):
387 git_cfg = octrc.get("git")
388 if isinstance(git_cfg, dict):
389 candidate = git_cfg.get("pre_commit_profile")
390 if isinstance(candidate, str):
391 if candidate == "auto":
392 resolved = _resolve_auto_profile(branch)
393 print(
394 f"Auto-selected profile: {resolved} (branch: {branch})",
395 file=sys.stderr,
396 )
397 _dbg("GIT", func, f"auto -> {resolved}", 4)
398 return resolved
399 if candidate in VALID_PROFILES:
400 _dbg("GIT", func, f"git.pre_commit_profile -> {candidate}", 4)
401 return candidate # type: ignore[return-value]
402 linter_cfg = octrc.get("linter")
403 if isinstance(linter_cfg, dict):
404 candidate = linter_cfg.get("profile")
405 if isinstance(candidate, str) and candidate in VALID_PROFILES:
406 _dbg("GIT", func, f"linter.profile -> {candidate}", 4)
407 return candidate # type: ignore[return-value]
408
409 # 5. Final fallback.
410 _dbg("GIT", func, f"fallback -> {DEFAULT_PROFILE}", 4)
411 return DEFAULT_PROFILE
412
413
414# ---------------------------------------------------------------------
415# Exit code derivation
416# ---------------------------------------------------------------------
417
418
419def apply_profile_policy(profile: str, result: "QualityGateResult") -> int:
420 """Derive the semantic exit code for a quality-gate result.
421
422 Exit code table (blueprint §5.3, verbatim):
423
424 ==== ==========================================================
425 Code Meaning
426 ==== ==========================================================
427 0 all checks pass
428 1 lint violations (blocking under this profile)
429 2 format violations (blocking under this profile)
430 3 both lint and format violations
431 4 test failures (when ``--include-tests``)
432 5 secrets detected — always blocking
433 ==== ==========================================================
434
435 Precedence: ``5 > 4 > 3 > 2 > 1 > 0``. The first rule that matches
436 wins. Because secrets takes precedence, a profile that would
437 otherwise return 0 (e.g. ``proto`` with warnings-only lint) still
438 returns 5 if secrets are present. This is the "secrets always
439 blocks" invariant.
440
441 For profiles where a check is not blocking (``proto`` lint is
442 ``warn``; ``proto`` format is ``skip``), the corresponding violation
443 counter never contributes to the exit code even if non-zero.
444
445 Parameters
446 ----------
447 profile
448 One of ``"proto"``, ``"compact"``, ``"strict"``. Unknown values
449 degrade to :data:`DEFAULT_PROFILE`.
450 result
451 A :class:`~oct.git.quality_gate.QualityGateResult` with the raw
452 counts from a completed gate run.
453 """
454 func = "apply_profile_policy"
455 if profile not in PROFILE_MATRIX:
456 profile = DEFAULT_PROFILE
457 matrix = PROFILE_MATRIX[profile]
458
459 lint_blocking = matrix["lint"] == "blocking"
460 format_blocking = matrix["format"] == "blocking"
461 # OI-531: 'optional' is truly non-blocking — test failures under the
462 # compact profile surface as a warning, never an exit code. Only
463 # 'blocking' drives the exit.
464 tests_blocking = matrix["tests"] == "blocking"
465
466 has_secrets = bool(result.secrets_findings)
467 has_lint = result.lint_violations > 0 and lint_blocking
468 has_format = result.format_violations > 0 and format_blocking
469 has_tests = result.test_failures > 0 and tests_blocking
470
471 _dbg(
472 "GIT", func,
473 f"profile={profile} secrets={has_secrets} lint={has_lint} "
474 f"format={has_format} tests={has_tests}",
475 4,
476 )
477
478 # Precedence 5 > 4 > 3 > 2 > 1 > 0.
479 if has_secrets:
480 return 5
481 if has_tests:
482 return 4
483 if has_lint and has_format:
484 return 3
485 if has_format:
486 return 2
487 if has_lint:
488 return 1
489 return 0
490
491
492# ---------------------------------------------------------------------
493# Protected-branch commit guard (Phase 4D — G-D7)
494# ---------------------------------------------------------------------
495
496
498 branch: str | None,
499 octrc: dict | None,
500 profile: str,
501) -> tuple[bool, str]:
502 """Check if committing directly to a protected branch.
503
504 Returns ``(blocked, message)``:
505
506 - On a **non-protected branch**: ``(False, "")``.
507 - On a protected branch in ``strict`` profile: ``(True, error_msg)``.
508 - On a protected branch in other profiles: ``(False, warning_msg)``.
509
510 The caller is responsible for printing the message and aborting if
511 ``blocked`` is ``True``.
512 """
513 func = "check_protected_branch_commit"
514 if not is_protected_branch(branch, octrc):
515 return False, ""
516
517 msg = (
518 f"Warning: committing directly to protected branch '{branch}'."
519 )
520 _dbg("GIT", func, f"branch={branch!r} profile={profile}", 3)
521
522 if profile == "strict":
523 return True, (
524 f"Blocked: direct commit to protected branch '{branch}' "
525 f"is not allowed under strict profile."
526 )
527 return False, msg
528
529
530# ---------------------------------------------------------------------
531# .gitignore — OCT-managed block helpers (Phase 4G)
532# ---------------------------------------------------------------------
533
534#: Marker comments that delimit the OCT-managed block in ``.gitignore``.
535#: The literals are kept stable for backwards compatibility with files
536#: created by older versions of ``oct git init``.
537OC_GITIGNORE_MARKER_START: str = "# --- Option C defaults ---"
538OC_GITIGNORE_MARKER_END: str = "# --- end Option C ---"
539
540#: Characters that are legal in gitignore patterns. Anything else is
541#: refused by :func:`validate_ignore_pattern` to keep the block
542#: hand-readable and to prevent accidental injection.
543_VALID_PATTERN_RE: re.Pattern[str] = re.compile(r"^[A-Za-z0-9_./*?\‍[\‍]\-]+$")
544
545
546def validate_ignore_pattern(pattern: str) -> tuple[bool, str]:
547 """Return ``(valid, message)`` for a candidate gitignore pattern.
548
549 Rejects empty strings, patterns containing newlines, leading ``!``
550 (negation — too easy to weaponise via ``oct git ignore``), absolute
551 paths (leading ``/`` is interpreted as anchored to repo root which
552 is allowed by gitignore but our policy is to require relative
553 patterns), and any character outside :data:`_VALID_PATTERN_RE`.
554 """
555 func = "validate_ignore_pattern"
556 if not isinstance(pattern, str):
557 return False, f"pattern must be a string, got {type(pattern).__name__}"
558 if not pattern.strip():
559 return False, "pattern is empty"
560 if "\n" in pattern or "\r" in pattern:
561 return False, "pattern contains a newline"
562 if pattern.startswith("!"):
563 return False, "negation patterns ('!...') are not allowed"
564 if pattern.startswith("/"):
565 return False, "absolute patterns (leading '/') are not allowed"
566 if not _VALID_PATTERN_RE.match(pattern):
567 return False, (
568 "pattern contains characters outside the safe set "
569 "[A-Za-z0-9_./*?[]-]"
570 )
571 _dbg("GIT", func, f"pattern={pattern!r} ok", 4)
572 return True, ""
573
574
575def _read_gitignore(repo_root: Path) -> list[str]:
576 """Return the lines of ``.gitignore`` (no trailing newlines)."""
577 path = Path(repo_root) / ".gitignore"
578 if not path.is_file():
579 return []
580 return path.read_text(encoding="utf-8").splitlines()
581
582
583def _write_gitignore(repo_root: Path, lines: list[str]) -> None:
584 """Write *lines* back to ``.gitignore`` with trailing newline."""
585 path = Path(repo_root) / ".gitignore"
586 body = "\n".join(lines)
587 if body and not body.endswith("\n"):
588 body += "\n"
589 path.write_text(body, encoding="utf-8")
590
591
592def _find_oct_block(lines: list[str]) -> tuple[int, int]:
593 """Return ``(start, end)`` line indices of the OCT block in *lines*.
594
595 ``start`` is the index of the start marker, ``end`` the index of
596 the end marker (inclusive). Returns ``(-1, -1)`` if the block is
597 absent or malformed (start without end).
598 """
599 start = -1
600 end = -1
601 for idx, line in enumerate(lines):
602 stripped = line.strip()
603 if stripped == OC_GITIGNORE_MARKER_START:
604 start = idx
605 elif stripped == OC_GITIGNORE_MARKER_END and start != -1:
606 end = idx
607 break
608 if start == -1 or end == -1:
609 return -1, -1
610 return start, end
611
612
613def list_ignore_patterns(repo_root: Path) -> tuple[list[str], list[str]]:
614 """Return ``(oct_managed, other)`` patterns from ``.gitignore``.
615
616 Patterns inside the OCT-managed block are returned in the first
617 list; everything else (user-managed) is returned in the second.
618 Comment lines and blank lines are excluded from both.
619 """
620 func = "list_ignore_patterns"
621 _dbg("GIT", func, f"repo_root={repo_root}", 3)
622 lines = _read_gitignore(repo_root)
623 if not lines:
624 return [], []
625 start, end = _find_oct_block(lines)
626 oct_managed: list[str] = []
627 other: list[str] = []
628 for idx, line in enumerate(lines):
629 stripped = line.strip()
630 if not stripped or stripped.startswith("#"):
631 continue
632 if start != -1 and start < idx < end:
633 oct_managed.append(stripped)
634 else:
635 other.append(stripped)
636 _dbg(
637 "GIT", func,
638 f"oct={len(oct_managed)} other={len(other)}",
639 4,
640 )
641 return oct_managed, other
642
643
645 repo_root: Path,
646 patterns: list[str],
647) -> list[str]:
648 """Append *patterns* to the OCT-managed block of ``.gitignore``.
649
650 Creates the block (with markers) if it does not exist. Patterns
651 already present anywhere in the file are skipped. Returns the list
652 of patterns actually added (in input order, after dedup).
653
654 Caller is responsible for validating each pattern via
655 :func:`validate_ignore_pattern` first.
656 """
657 func = "add_ignore_patterns"
658 _dbg("GIT", func, f"repo_root={repo_root} patterns={len(patterns)}", 3)
659 lines = _read_gitignore(repo_root)
660 existing: set[str] = {
661 line.strip() for line in lines
662 if line.strip() and not line.strip().startswith("#")
663 }
664 to_add = [p for p in patterns if p not in existing]
665 if not to_add:
666 _dbg("GIT", func, "no new patterns to add", 4)
667 return []
668
669 start, end = _find_oct_block(lines)
670 if start == -1:
671 # Block does not exist — append a fresh one.
672 new_lines = list(lines)
673 if new_lines and new_lines[-1].strip() != "":
674 new_lines.append("")
675 new_lines.append(OC_GITIGNORE_MARKER_START)
676 new_lines.extend(to_add)
677 new_lines.append(OC_GITIGNORE_MARKER_END)
678 else:
679 # Insert before the end marker.
680 new_lines = list(lines[:end]) + to_add + list(lines[end:])
681
682 _write_gitignore(repo_root, new_lines)
683 _dbg("GIT", func, f"added {len(to_add)} pattern(s)", 4)
684 return to_add
685
686
688 repo_root: Path,
689 pattern: str,
690) -> tuple[bool, str]:
691 """Remove *pattern* from the OCT-managed block.
692
693 Returns ``(removed, message)``. Refuses to touch patterns located
694 outside the OCT block (those are user-managed); the caller should
695 surface the message to the user.
696 """
697 func = "remove_ignore_pattern"
698 _dbg("GIT", func, f"repo_root={repo_root} pattern={pattern!r}", 3)
699 lines = _read_gitignore(repo_root)
700 if not lines:
701 return False, ".gitignore does not exist"
702 start, end = _find_oct_block(lines)
703 if start == -1:
704 return False, "OCT-managed block not found in .gitignore"
705
706 target_idx = -1
707 for idx in range(start + 1, end):
708 if lines[idx].strip() == pattern:
709 target_idx = idx
710 break
711
712 if target_idx == -1:
713 # Check whether it's outside the block — give a helpful error.
714 for idx, line in enumerate(lines):
715 if line.strip() == pattern and not (start < idx < end):
716 return False, (
717 f"pattern '{pattern}' is outside the OCT-managed "
718 f"block; remove it manually with a text editor"
719 )
720 return False, f"pattern '{pattern}' not found in OCT-managed block"
721
722 new_lines = list(lines[:target_idx]) + list(lines[target_idx + 1:])
723 _write_gitignore(repo_root, new_lines)
724 _dbg("GIT", func, f"removed pattern at line {target_idx}", 4)
725 return True, ""
str|None generate_branch_name_from_message(str message)
Definition policy.py:156
Profile _resolve_auto_profile(str|None branch)
Definition policy.py:316
tuple[int, int] _find_oct_block(list[str] lines)
Definition policy.py:592
tuple[bool, str] remove_ignore_pattern(Path repo_root, str pattern)
Definition policy.py:690
int apply_profile_policy(str profile, "QualityGateResult" result)
Definition policy.py:419
tuple[bool, str] validate_branch_name(str|None branch, dict|None octrc)
Definition policy.py:252
list[str] add_ignore_patterns(Path repo_root, list[str] patterns)
Definition policy.py:647
list[str] _read_gitignore(Path repo_root)
Definition policy.py:575
bool is_protected_branch(str|None branch, dict|None octrc)
Definition policy.py:214
Profile resolve_effective_profile(str|None branch, dict|None octrc, str|None cli_override)
Definition policy.py:347
None _write_gitignore(Path repo_root, list[str] lines)
Definition policy.py:583
tuple[bool, str] validate_ignore_pattern(str pattern)
Definition policy.py:546
tuple[bool, str] check_protected_branch_commit(str|None branch, dict|None octrc, str profile)
Definition policy.py:501
tuple[list[str], list[str]] list_ignore_patterns(Path repo_root)
Definition policy.py:613