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.
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
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.
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
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
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).
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)
69from __future__
import annotations
73from pathlib
import Path
74from typing
import TYPE_CHECKING, Literal
76from oct.core.diagnostics
import _dbg
87Profile = Literal[
"proto",
"compact",
"strict"]
91VALID_PROFILES: tuple[Profile, ...] = (
"proto",
"compact",
"strict")
96DEFAULT_PROFILE: Profile =
"compact"
101DEFAULT_PROTECTED_BRANCHES: tuple[str, ...] = (
"main",
"master")
112PROFILE_MATRIX: dict[Profile, dict[str, str]] = {
116 "secrets":
"blocking",
121 "format":
"blocking",
122 "secrets":
"blocking",
127 "format":
"blocking",
128 "secrets":
"blocking",
136SECRETS_ALWAYS_BLOCKS: bool =
True
141DEFAULT_BRANCH_PATTERNS: tuple[str, ...] = (
142 r"^(main|master|develop)$",
143 r"^(release|hotfix|feature|bugfix|docs|chore)/.+$",
149_AUTO_PROFILE_MAP: dict[str, Profile] = {
152 "release/":
"strict",
154 "feature/":
"compact",
155 "bugfix/":
"compact",
162_COMMIT_TYPE_TO_PREFIX: dict[str, str] = {
175_BRANCH_SLUG_MAX_LEN: int = 50
184 """Derive a valid branch name from a Conventional Commits *message*.
186 Returns a branch name like ``feature/auth-add-jwt-tokens`` or
187 ``None`` when the message cannot be parsed.
189 The generated name is guaranteed to pass
190 :func:`validate_branch_name` against the default patterns.
192 func =
"generate_branch_name_from_message"
193 _dbg(
"GIT", func, f
"message={message[:60]!r}", 3)
197 parsed = parse_commit_message(message)
199 _dbg(
"GIT", func,
"parse failed - returning None", 4)
202 prefix = _COMMIT_TYPE_TO_PREFIX.get(parsed.type,
"chore")
205 parts: list[str] = []
207 parts.append(parsed.scope)
208 parts.append(parsed.subject)
209 raw_slug =
"-".join(parts).lower()
212 slug = re.sub(
r"[^a-z0-9-]",
"-", raw_slug)
213 slug = re.sub(
r"-{2,}",
"-", slug)
214 slug = slug.strip(
"-")
217 _dbg(
"GIT", func,
"empty slug - returning None", 4)
221 if len(slug) > _BRANCH_SLUG_MAX_LEN:
222 slug = slug[:_BRANCH_SLUG_MAX_LEN].rstrip(
"-")
224 name = f
"{prefix}/{slug}"
229 _dbg(
"GIT", func, f
"generated name {name!r} invalid - returning None", 3)
232 _dbg(
"GIT", func, f
"result={name!r}", 4)
245 allow_optout: bool =
False,
247 """Return True if ``branch`` is listed in ``git.protected_branches``.
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.
254 Plan 209: an explicit ``protected_branches`` list is a PRIVILEGE,
255 not an open door. When the octrc sets one:
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.
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.
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:
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):
287 configured = [b
for b
in candidate
if isinstance(b, str)]
289 if configured
is None:
290 protected = set(DEFAULT_PROTECTED_BRANCHES)
292 protected = set(configured)
294 protected = set(configured) | set(DEFAULT_PROTECTED_BRANCHES)
295 result = branch
in protected
296 _dbg(
"GIT", func, f
"result={result} protected={sorted(protected)}", 4)
308) -> tuple[bool, str]:
309 """Validate *branch* against the configured naming patterns.
311 Returns ``(valid, message)``:
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 ...")``
318 Patterns default to :data:`DEFAULT_BRANCH_PATTERNS` when not
319 overridden in ``.octrc.json``.
321 func =
"validate_branch_name"
322 _dbg(
"GIT", func, f
"branch={branch!r}", 3)
324 if branch
is None or branch ==
"HEAD":
329 patterns: list[str] = list(DEFAULT_BRANCH_PATTERNS)
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)]
344 _dbg(
"GIT", func,
"branch naming disabled", 4)
351 for pattern
in patterns:
353 if re.match(pattern, branch):
354 _dbg(
"GIT", func, f
"matched: {pattern!r}", 4)
360 f
"Branch name '{branch}' does not match any allowed pattern. "
361 f
"Expected patterns: {', '.join(patterns)}"
363 _dbg(
"GIT", func, f
"invalid: {msg}", 3)
373 """Map *branch* to a profile using :data:`_AUTO_PROFILE_MAP`.
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.
379 func =
"_resolve_auto_profile"
380 if branch
is None or branch ==
"HEAD":
381 return DEFAULT_PROFILE
384 exact = _AUTO_PROFILE_MAP.get(branch)
385 if exact
is not None:
386 _dbg(
"GIT", func, f
"exact match: {branch!r} -> {exact}", 4)
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)
395 _dbg(
"GIT", func, f
"no match for {branch!r}, fallback -> {DEFAULT_PROFILE}", 4)
396 return DEFAULT_PROFILE
402 cli_override: str |
None,
403 manifest_profile: str |
None =
None,
405 allow_optout: bool =
False,
407 """Return the profile that should be applied for the current run.
409 Precedence (highest wins):
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
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
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.
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.
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.
446 func =
"resolve_effective_profile"
449 f
"branch={branch!r} cli_override={cli_override!r} "
450 f
"manifest_profile={manifest_profile!r} allow_optout={allow_optout!r}",
456 _dbg(
"GIT", func,
"protected branch -> strict", 4)
460 if cli_override
and cli_override
in VALID_PROFILES:
461 _dbg(
"GIT", func, f
"cli override -> {cli_override}", 4)
465 if manifest_profile
and manifest_profile
in VALID_PROFILES:
466 _dbg(
"GIT", func, f
"manifest -> {manifest_profile}", 4)
467 return manifest_profile
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":
478 f
"Auto-selected profile: {resolved} (branch: {branch})",
481 _dbg(
"GIT", func, f
"auto -> {resolved}", 4)
483 if candidate
in VALID_PROFILES:
484 _dbg(
"GIT", func, f
"git.pre_commit_profile -> {candidate}", 4)
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)
494 _dbg(
"GIT", func, f
"fallback -> {DEFAULT_PROFILE}", 4)
495 return DEFAULT_PROFILE
499 """Return the workspace-declared profile for *project_root*.
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).
506 Returns ``None`` - never raises - when any of the following holds,
507 so callers degrade cleanly to the pre-197 octrc-only chain:
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`.
516 Plan 197: this closes the gap that let ``oc_chat`` declare
517 ``strict`` and be gated at ``compact`` on every feature branch.
519 func =
"resolve_manifest_profile"
520 _dbg(
"GIT", func, f
"project_root={project_root}", 3)
523 from oct.core.workspace
import (
524 assign_to_subproject,
529 _dbg(
"GIT", func,
"workspace module unavailable", 4)
533 ws_root = find_workspace_root(Path(project_root))
535 _dbg(
"GIT", func,
"no workspace manifest above root", 4)
537 workspace = load_workspace(ws_root)
538 subproject = assign_to_subproject(Path(project_root), workspace)
539 except Exception
as exc:
544 f
"manifest lookup failed: {exc!r}; degrading to octrc",
549 if subproject
is None:
550 _dbg(
"GIT", func,
"root owned by no subproject", 4)
553 profile = getattr(subproject,
"profile",
None)
554 if isinstance(profile, str)
and profile
in VALID_PROFILES:
557 f
"{subproject.name!r} declares {profile}",
562 _dbg(
"GIT", func, f
"{subproject.name!r} declares nothing usable", 4)
567 """Return whether *project_root* is declared ``nested_git`` in the manifest.
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.
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.
581 func =
"resolve_manifest_nested_git"
582 _dbg(
"GIT", func, f
"project_root={project_root}", 3)
585 from oct.core.workspace
import (
586 assign_to_subproject,
591 _dbg(
"GIT", func,
"workspace module unavailable", 4)
595 ws_root = find_workspace_root(Path(project_root))
597 _dbg(
"GIT", func,
"no workspace manifest above root", 4)
599 workspace = load_workspace(ws_root)
600 subproject = assign_to_subproject(Path(project_root), workspace)
601 except Exception
as exc:
606 f
"manifest lookup failed: {exc!r}; degrading to False",
611 if subproject
is None:
612 _dbg(
"GIT", func,
"root owned by no subproject", 4)
615 nested_git = bool(getattr(subproject,
"nested_git",
False))
616 _dbg(
"GIT", func, f
"{subproject.name!r} nested_git={nested_git}", 4)
624 cli_override: str |
None,
626 """Resolve the effective profile for a project on disk.
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
637 func =
"resolve_profile_for_project"
641 branch, octrc, cli_override, manifest_profile=manifest_profile,
642 allow_optout=allow_optout,
644 _dbg(
"GIT", func, f
"root={project_root} -> {profile}", 3)
654 """Derive the semantic exit code for a quality-gate result.
656 Exit code table (blueprint §5.3, verbatim):
658 ==== ==========================================================
660 ==== ==========================================================
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 ==== ==========================================================
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
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.
682 One of ``"proto"``, ``"compact"``, ``"strict"``. Unknown values
683 degrade to :data:`DEFAULT_PROFILE`.
685 A :class:`~oct.git.quality_gate.QualityGateResult` with the raw
686 counts from a completed gate run.
688 func =
"apply_profile_policy"
689 if profile
not in PROFILE_MATRIX:
690 profile = DEFAULT_PROFILE
691 matrix = PROFILE_MATRIX[profile]
693 lint_blocking = matrix[
"lint"] ==
"blocking"
694 format_blocking = matrix[
"format"] ==
"blocking"
698 tests_blocking = matrix[
"tests"] ==
"blocking"
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
707 f
"profile={profile} secrets={has_secrets} lint={has_lint} "
708 f
"format={has_format} tests={has_tests}",
717 if has_lint
and has_format:
735) -> tuple[bool, str]:
736 """Check if committing directly to a protected branch.
738 Returns ``(blocked, message)``:
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)``.
744 The caller is responsible for printing the message and aborting if
745 ``blocked`` is ``True``.
747 func =
"check_protected_branch_commit"
752 f
"Warning: committing directly to protected branch '{branch}'."
754 _dbg(
"GIT", func, f
"branch={branch!r} profile={profile}", 3)
756 if profile ==
"strict":
758 f
"Blocked: direct commit to protected branch '{branch}' "
759 f
"is not allowed under strict profile."
771OC_GITIGNORE_MARKER_START: str =
"# --- Option C defaults ---"
772OC_GITIGNORE_MARKER_END: str =
"# --- end Option C ---"
777_VALID_PATTERN_RE: re.Pattern[str] = re.compile(
r"^[A-Za-z0-9_./*?\[\]\-]+$")
781 """Return ``(valid, message)`` for a candidate gitignore pattern.
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`.
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):
802 "pattern contains characters outside the safe set "
803 "[A-Za-z0-9_./*?[]-]"
805 _dbg(
"GIT", func, f
"pattern={pattern!r} ok", 4)
810 """Return the lines of ``.gitignore`` (no trailing newlines)."""
811 path = Path(repo_root) /
".gitignore"
812 if not path.is_file():
814 return path.read_text(encoding=
"utf-8").splitlines()
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"):
823 path.write_text(body, encoding=
"utf-8")
827 """Return ``(start, end)`` line indices of the OCT block in *lines*.
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).
835 for idx, line
in enumerate(lines):
836 stripped = line.strip()
837 if stripped == OC_GITIGNORE_MARKER_START:
839 elif stripped == OC_GITIGNORE_MARKER_END
and start != -1:
842 if start == -1
or end == -1:
848 """Return ``(oct_managed, other)`` patterns from ``.gitignore``.
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.
854 func =
"list_ignore_patterns"
855 _dbg(
"GIT", func, f
"repo_root={repo_root}", 3)
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(
"#"):
866 if start != -1
and start < idx < end:
867 oct_managed.append(stripped)
869 other.append(stripped)
872 f
"oct={len(oct_managed)} other={len(other)}",
875 return oct_managed, other
882 """Append *patterns* to the OCT-managed block of ``.gitignore``.
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).
888 Caller is responsible for validating each pattern via
889 :func:`validate_ignore_pattern` first.
891 func =
"add_ignore_patterns"
892 _dbg(
"GIT", func, f
"repo_root={repo_root} patterns={len(patterns)}", 3)
894 existing: set[str] = {
895 line.strip()
for line
in lines
896 if line.strip()
and not line.strip().startswith(
"#")
898 to_add = [p
for p
in patterns
if p
not in existing]
900 _dbg(
"GIT", func,
"no new patterns to add", 4)
906 new_lines = list(lines)
907 if new_lines
and new_lines[-1].strip() !=
"":
909 new_lines.append(OC_GITIGNORE_MARKER_START)
910 new_lines.extend(to_add)
911 new_lines.append(OC_GITIGNORE_MARKER_END)
914 new_lines = list(lines[:end]) + to_add + list(lines[end:])
917 _dbg(
"GIT", func, f
"added {len(to_add)} pattern(s)", 4)
924) -> tuple[bool, str]:
925 """Remove *pattern* from the OCT-managed block.
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.
931 func =
"remove_ignore_pattern"
932 _dbg(
"GIT", func, f
"repo_root={repo_root} pattern={pattern!r}", 3)
935 return False,
".gitignore does not exist"
938 return False,
"OCT-managed block not found in .gitignore"
941 for idx
in range(start + 1, end):
942 if lines[idx].strip() == pattern:
948 for idx, line
in enumerate(lines):
949 if line.strip() == pattern
and not (start < idx < end):
951 f
"pattern '{pattern}' is outside the OCT-managed "
952 f
"block; remove it manually with a text editor"
954 return False, f
"pattern '{pattern}' not found in OCT-managed block"
956 new_lines = list(lines[:target_idx]) + list(lines[target_idx + 1:])
958 _dbg(
"GIT", func, f
"removed pattern at line {target_idx}", 4)
Profile resolve_profile_for_project(Path project_root, str|None branch, dict|None octrc, str|None cli_override)
bool resolve_manifest_nested_git(Path project_root)
bool is_protected_branch(str|None branch, dict|None octrc, *, bool allow_optout=False)
str|None generate_branch_name_from_message(str message)
Profile _resolve_auto_profile(str|None branch)
tuple[int, int] _find_oct_block(list[str] lines)
tuple[bool, str] remove_ignore_pattern(Path repo_root, str pattern)
int apply_profile_policy(str profile, "QualityGateResult" result)
tuple[bool, str] validate_branch_name(str|None branch, dict|None octrc)
list[str] add_ignore_patterns(Path repo_root, list[str] patterns)
list[str] _read_gitignore(Path repo_root)
Profile resolve_effective_profile(str|None branch, dict|None octrc, str|None cli_override, str|None manifest_profile=None, *, bool allow_optout=False)
None _write_gitignore(Path repo_root, list[str] lines)
str|None resolve_manifest_profile(Path project_root)
tuple[bool, str] validate_ignore_pattern(str pattern)
tuple[bool, str] check_protected_branch_commit(str|None branch, dict|None octrc, str profile)
tuple[list[str], list[str]] list_ignore_patterns(Path repo_root)