python-semantic-release 10.3.2__py3-none-any.whl → 10.4.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-semantic-release
3
- Version: 10.3.2
3
+ Version: 10.4.1
4
4
  Summary: Automatic Semantic Versioning for Python projects
5
5
  Author-email: Rolf Erik Lekang <me@rolflekang.com>
6
6
  License: MIT
@@ -1,4 +1,4 @@
1
- python_semantic_release-10.3.2.dist-info/licenses/LICENSE,sha256=NE85nszX252sdQdu0xgS9qwfYES0k8qS6gW3uO4jRGE,1083
1
+ python_semantic_release-10.4.1.dist-info/licenses/LICENSE,sha256=NE85nszX252sdQdu0xgS9qwfYES0k8qS6gW3uO4jRGE,1083
2
2
  semantic_release/__init__.py,sha256=tRJWhrn_dUt0QycXD2DoJSfEP5uwmxngH7jvbG2i-hA,1317
3
3
  semantic_release/__main__.py,sha256=pksxr6g1vkKq98Q1lShsxG8tk55IMiSMHzAHKyFU5x0,1704
4
4
  semantic_release/const.py,sha256=wInJR7vcOgT1ysm5VuJQ6lD_ZGYnCwRVKz7Uz3htQc4,861
@@ -15,7 +15,7 @@ semantic_release/changelog/template.py,sha256=eqOjtVfBbksgTK4CAZOajfkaAcKueJ-FhF
15
15
  semantic_release/cli/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
16
16
  semantic_release/cli/changelog_writer.py,sha256=2jP5b0LK61Y2tb22GQSFFmwHwXd2WjbopgsMAmsQsfY,9284
17
17
  semantic_release/cli/cli_context.py,sha256=Nop71LdVCJOeSUHgTXunMyK3xAu_QKQC2cRp1QBVkX0,4134
18
- semantic_release/cli/config.py,sha256=4UCx4-jzKpggb7dZM3fnZoi8rPiUGWtEmLiCfWlro7I,33458
18
+ semantic_release/cli/config.py,sha256=dCQ1HqzBc_8RG_oTQtBMuCnM7zOEj9E1bDblxobg8os,33569
19
19
  semantic_release/cli/const.py,sha256=h7XE2D0D__TAZSrUUtVszwvzpkHTMOiQCf97XQNbEvA,163
20
20
  semantic_release/cli/github_actions_output.py,sha256=yC0nsMvEFGACjDwB8DdmGKwNGI8aIIhDxRHrmcS7tzA,5410
21
21
  semantic_release/cli/masking_filter.py,sha256=GsTyaoZbUVJLXVMqeXhCttXK84UnBQ8cNDSHxd52sOc,3218
@@ -25,16 +25,20 @@ semantic_release/cli/commands/changelog.py,sha256=wJfd4VVfrGnu2jnIpG25cdVcbXIX-E
25
25
  semantic_release/cli/commands/generate_config.py,sha256=2xZOu3NpyhBp0pWr7d8ugKl_kjqQgpSsSMHq5wHTfrE,1699
26
26
  semantic_release/cli/commands/main.py,sha256=u1zhkkvKCZ2TtUqjzvdFTe5UZsvfws_pjqqo6CY0bBo,4351
27
27
  semantic_release/cli/commands/publish.py,sha256=CE_LJTxFnc337MfpsfdJopi7QCwwE13GqGNQ-dNgWis,2871
28
- semantic_release/cli/commands/version.py,sha256=CwFwoBP896Y9dc_p5bAyjZFbuD78tCS-q5IueQsOVSU,26634
29
- semantic_release/commit_parser/__init__.py,sha256=6euiDgj9bwOx1rP96vUjq090usviXkbo7OVOnRBGfcw,742
28
+ semantic_release/cli/commands/version.py,sha256=vlBNbQq0rW930UpI983-Rr-71fdK6qVPjMiRqMTBBUE,26616
29
+ semantic_release/commit_parser/__init__.py,sha256=uCC2YI-EDPUAa6TwWYJA8TS7baWE2bXrLEGcrhJKofI,1323
30
30
  semantic_release/commit_parser/_base.py,sha256=DLsHnbXG-39JkUbcnsBCSV2GmV35w1rasyoMhK8G0UE,3058
31
31
  semantic_release/commit_parser/angular.py,sha256=MY_fo9F4EZ-ac8wYzBR0uD94O5Li2D-8zEMR01wss4c,18534
32
- semantic_release/commit_parser/conventional.py,sha256=Iyy8txkshK8HikizFVa7pnJHBSWDhOhF45zR866QxQM,18672
33
32
  semantic_release/commit_parser/emoji.py,sha256=0VecUMMcj43UaKDZ2GaFFUG26zOJAvXsEobTHaXNkk0,17749
34
33
  semantic_release/commit_parser/scipy.py,sha256=gA9TfmrxGVvtv6kki6N_9abJx-_WlpBsSX2TBh0YgPw,19463
35
34
  semantic_release/commit_parser/tag.py,sha256=bVO2XghM0G_eW2rG9Xc2q5TPsjtxr-xcHK5RpE1u_HM,3537
36
35
  semantic_release/commit_parser/token.py,sha256=1_q8mJ4SRu7kNfa-Nxr8fEyuvCfjPgiPEitqSP1KR5g,7904
37
36
  semantic_release/commit_parser/util.py,sha256=hcLjc16o7l6y_5Hfl8IxmF4ijaJD62JdjdB2DJWAe-g,3959
37
+ semantic_release/commit_parser/conventional/__init__.py,sha256=JHPL3S6trM4Le9MXnXc6iPYxxQnE4j0LG83_BWIomOw,602
38
+ semantic_release/commit_parser/conventional/options.py,sha256=AnRgTRMF-3X5QonTqq6bqJEbmGgQBzppyRZ79hct28g,2598
39
+ semantic_release/commit_parser/conventional/options_monorepo.py,sha256=7OCpuaYhw01bfoffTGfPgEWzRRyVsdsq_n2eo8Dt8-8,3210
40
+ semantic_release/commit_parser/conventional/parser.py,sha256=Pex26cvMqAKx9NPH3KROYg7k76R0Nsbs4aHor5SbMZg,16671
41
+ semantic_release/commit_parser/conventional/parser_monorepo.py,sha256=nVAc_gK4tJ5UM_rd9KbXgBWeBgrI_q2-vav3_4748r8,21189
38
42
  semantic_release/data/templates/conventional/md/.release_notes.md.j2,sha256=DlMVAJMGqE27TwJ-2kviYaFhd3uWqXiU6Ikl15Ukne8,2512
39
43
  semantic_release/data/templates/conventional/md/CHANGELOG.md.j2,sha256=FZmrQ-qOIoSoJmAa_NFaRelfmqUpypU2xlDeScdGOf4,729
40
44
  semantic_release/data/templates/conventional/md/.components/changelog_header.md.j2,sha256=qNxTuSr59CV_yyimVU_RYp5azCnK0l6nJ03Zf0u5Ugg,166
@@ -73,8 +77,8 @@ semantic_release/version/declarations/enum.py,sha256=3n5Py9DoFkmItIdsmtQrJgmAhep
73
77
  semantic_release/version/declarations/i_version_replacer.py,sha256=oP6BxJuxwI44roI6448tomShv1sMoy9ry8TlhhIQtfc,2416
74
78
  semantic_release/version/declarations/pattern.py,sha256=MpUmsHYGAVAuFSKSb29FLcWeUCEHG_TRyhMO-2DWAAs,8308
75
79
  semantic_release/version/declarations/toml.py,sha256=2K4DtX5Qq1iHT8cG8mISPTMmp50w6Av0KmLAKZPYqq8,4931
76
- python_semantic_release-10.3.2.dist-info/METADATA,sha256=Hvdc-N4cJe-GX8gGtD08rfqjASK0I93EaYxuCU6pNK4,3927
77
- python_semantic_release-10.3.2.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
78
- python_semantic_release-10.3.2.dist-info/entry_points.txt,sha256=kzkCyDJsMOwgpFwEWKE9wxN1tXaUP6g6GIO4xtc0QuE,162
79
- python_semantic_release-10.3.2.dist-info/top_level.txt,sha256=qYA24nyg3eP-ti5UW7Vuj2aXVmM0wqVHx4mREdRZNAA,17
80
- python_semantic_release-10.3.2.dist-info/RECORD,,
80
+ python_semantic_release-10.4.1.dist-info/METADATA,sha256=_p8pbdBeY6Sz6ch5N7HguLDiBT2IzfNR5NflXHrTt38,3927
81
+ python_semantic_release-10.4.1.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
82
+ python_semantic_release-10.4.1.dist-info/entry_points.txt,sha256=kzkCyDJsMOwgpFwEWKE9wxN1tXaUP6g6GIO4xtc0QuE,162
83
+ python_semantic_release-10.4.1.dist-info/top_level.txt,sha256=qYA24nyg3eP-ti5UW7Vuj2aXVmM0wqVHx4mREdRZNAA,17
84
+ python_semantic_release-10.4.1.dist-info/RECORD,,
@@ -710,7 +710,7 @@ def version( # noqa: C901
710
710
  # are disabled, and the changelog generation is disabled or it's not
711
711
  # modified, then the HEAD commit will be tagged as a release commit
712
712
  # despite not being made by PSR
713
- if commit_changes or create_tag:
713
+ if create_tag:
714
714
  project.git_tag(
715
715
  tag_name=new_version.as_tag(),
716
716
  message=new_version.as_tag(),
@@ -39,6 +39,7 @@ from semantic_release.cli.masking_filter import MaskingFilter
39
39
  from semantic_release.commit_parser import (
40
40
  AngularCommitParser,
41
41
  CommitParser,
42
+ ConventionalCommitMonorepoParser,
42
43
  ConventionalCommitParser,
43
44
  EmojiCommitParser,
44
45
  ParseResult,
@@ -71,9 +72,10 @@ class HvcsClient(str, Enum):
71
72
  GITEA = "gitea"
72
73
 
73
74
 
74
- _known_commit_parsers: Dict[str, type[CommitParser]] = {
75
- "conventional": ConventionalCommitParser,
75
+ _known_commit_parsers: dict[str, type[CommitParser[Any, Any]]] = {
76
76
  "angular": AngularCommitParser,
77
+ "conventional": ConventionalCommitParser,
78
+ "conventional-monorepo": ConventionalCommitMonorepoParser,
77
79
  "emoji": EmojiCommitParser,
78
80
  "scipy": ScipyCommitParser,
79
81
  "tag": TagCommitParser,
@@ -7,6 +7,8 @@ from semantic_release.commit_parser.angular import (
7
7
  AngularParserOptions,
8
8
  )
9
9
  from semantic_release.commit_parser.conventional import (
10
+ ConventionalCommitMonorepoParser,
11
+ ConventionalCommitMonorepoParserOptions,
10
12
  ConventionalCommitParser,
11
13
  ConventionalCommitParserOptions,
12
14
  )
@@ -28,3 +30,24 @@ from semantic_release.commit_parser.token import (
28
30
  ParseResult,
29
31
  ParseResultType,
30
32
  )
33
+
34
+ __all__ = [
35
+ "CommitParser",
36
+ "ParserOptions",
37
+ "AngularCommitParser",
38
+ "AngularParserOptions",
39
+ "ConventionalCommitParser",
40
+ "ConventionalCommitParserOptions",
41
+ "ConventionalCommitMonorepoParser",
42
+ "ConventionalCommitMonorepoParserOptions",
43
+ "EmojiCommitParser",
44
+ "EmojiParserOptions",
45
+ "ScipyCommitParser",
46
+ "ScipyParserOptions",
47
+ "TagCommitParser",
48
+ "TagParserOptions",
49
+ "ParsedCommit",
50
+ "ParseError",
51
+ "ParseResult",
52
+ "ParseResultType",
53
+ ]
@@ -0,0 +1,17 @@
1
+ from semantic_release.commit_parser.conventional.options import (
2
+ ConventionalCommitParserOptions,
3
+ )
4
+ from semantic_release.commit_parser.conventional.options_monorepo import (
5
+ ConventionalCommitMonorepoParserOptions,
6
+ )
7
+ from semantic_release.commit_parser.conventional.parser import ConventionalCommitParser
8
+ from semantic_release.commit_parser.conventional.parser_monorepo import (
9
+ ConventionalCommitMonorepoParser,
10
+ )
11
+
12
+ __all__ = [
13
+ "ConventionalCommitParser",
14
+ "ConventionalCommitParserOptions",
15
+ "ConventionalCommitMonorepoParser",
16
+ "ConventionalCommitMonorepoParserOptions",
17
+ ]
@@ -0,0 +1,72 @@
1
+ from __future__ import annotations
2
+
3
+ from itertools import zip_longest
4
+ from typing import Tuple
5
+
6
+ from pydantic.dataclasses import dataclass
7
+
8
+ from semantic_release.commit_parser._base import ParserOptions
9
+ from semantic_release.enums import LevelBump
10
+
11
+
12
+ @dataclass
13
+ class ConventionalCommitParserOptions(ParserOptions):
14
+ """Options dataclass for the ConventionalCommitParser."""
15
+
16
+ minor_tags: Tuple[str, ...] = ("feat",)
17
+ """Commit-type prefixes that should result in a minor release bump."""
18
+
19
+ patch_tags: Tuple[str, ...] = ("fix", "perf")
20
+ """Commit-type prefixes that should result in a patch release bump."""
21
+
22
+ other_allowed_tags: Tuple[str, ...] = (
23
+ "build",
24
+ "chore",
25
+ "ci",
26
+ "docs",
27
+ "style",
28
+ "refactor",
29
+ "test",
30
+ )
31
+ """Commit-type prefixes that are allowed but do not result in a version bump."""
32
+
33
+ allowed_tags: Tuple[str, ...] = (
34
+ *minor_tags,
35
+ *patch_tags,
36
+ *other_allowed_tags,
37
+ )
38
+ """
39
+ All commit-type prefixes that are allowed.
40
+
41
+ These are used to identify a valid commit message. If a commit message does not start with
42
+ one of these prefixes, it will not be considered a valid commit message.
43
+ """
44
+
45
+ default_bump_level: LevelBump = LevelBump.NO_RELEASE
46
+ """The minimum bump level to apply to valid commit message."""
47
+
48
+ parse_squash_commits: bool = True
49
+ """Toggle flag for whether or not to parse squash commits"""
50
+
51
+ ignore_merge_commits: bool = True
52
+ """Toggle flag for whether or not to ignore merge commits"""
53
+
54
+ @property
55
+ def tag_to_level(self) -> dict[str, LevelBump]:
56
+ """A mapping of commit tags to the level bump they should result in."""
57
+ return self._tag_to_level
58
+
59
+ def __post_init__(self) -> None:
60
+ self._tag_to_level: dict[str, LevelBump] = {
61
+ str(tag): level
62
+ for tag, level in [
63
+ # we have to do a type ignore as zip_longest provides a type that is not specific enough
64
+ # for our expected output. Due to the empty second array, we know the first is always longest
65
+ # and that means no values in the first entry of the tuples will ever be a LevelBump. We
66
+ # apply a str() to make mypy happy although it will never happen.
67
+ *zip_longest(self.allowed_tags, (), fillvalue=self.default_bump_level),
68
+ *zip_longest(self.patch_tags, (), fillvalue=LevelBump.PATCH),
69
+ *zip_longest(self.minor_tags, (), fillvalue=LevelBump.MINOR),
70
+ ]
71
+ if "|" not in str(tag)
72
+ }
@@ -0,0 +1,90 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ from re import compile as regexp, error as RegExpError # noqa: N812
5
+ from typing import TYPE_CHECKING, Any, Iterable, Tuple
6
+
7
+ from pydantic import Field, field_validator
8
+ from pydantic.dataclasses import dataclass
9
+
10
+ # typing_extensions is for Python 3.8, 3.9, 3.10 compatibility
11
+ from typing_extensions import Annotated
12
+
13
+ from semantic_release.commit_parser.conventional.options import (
14
+ ConventionalCommitParserOptions,
15
+ )
16
+
17
+ if TYPE_CHECKING: # pragma: no cover
18
+ pass
19
+
20
+
21
+ @dataclass
22
+ class ConventionalCommitMonorepoParserOptions(ConventionalCommitParserOptions):
23
+ # TODO: add example into the docstring
24
+ """Options dataclass for ConventionalCommitMonorepoParser."""
25
+
26
+ path_filters: Annotated[Tuple[str, ...], Field(validate_default=True)] = (".",)
27
+ """
28
+ A set of relative paths to filter commits by. Only commits with file changes that
29
+ match these file paths or its subdirectories will be considered valid commits.
30
+
31
+ Syntax is similar to .gitignore with file path globs and inverse file match globs
32
+ via `!` prefix. Paths should be relative to the current working directory.
33
+ """
34
+
35
+ scope_prefix: str = ""
36
+ """
37
+ A prefix that will be striped from the scope when parsing commit messages.
38
+
39
+ If set, it will cause unscoped commits to be ignored. Use this in tandem with
40
+ the `path_filters` option to filter commits by directory and scope. This will
41
+ be fed into a regular expression so you must escape any special characters that
42
+ are meaningful in regular expressions (e.g. `.`, `*`, `?`, `+`, etc.) if you want
43
+ to match them literally.
44
+ """
45
+
46
+ @classmethod
47
+ @field_validator("path_filters", mode="before")
48
+ def convert_strs_to_paths(cls, value: Any) -> tuple[Path, ...]:
49
+ values = value if isinstance(value, Iterable) else [value]
50
+ results: list[Path] = []
51
+
52
+ for val in values:
53
+ if isinstance(val, (str, Path)):
54
+ results.append(Path(val))
55
+ continue
56
+
57
+ raise TypeError(f"Invalid type: {type(val)}, expected str or Path.")
58
+
59
+ return tuple(results)
60
+
61
+ @classmethod
62
+ @field_validator("path_filters", mode="after")
63
+ def resolve_path(cls, dir_paths: tuple[Path, ...]) -> tuple[Path, ...]:
64
+ return tuple(
65
+ (
66
+ Path(f"!{Path(str_path[1:]).expanduser().absolute().resolve()}")
67
+ # maintains the negation prefix if it exists
68
+ if (str_path := str(path)).startswith("!")
69
+ # otherwise, resolve the path normally
70
+ else path.expanduser().absolute().resolve()
71
+ )
72
+ for path in dir_paths
73
+ )
74
+
75
+ @classmethod
76
+ @field_validator("scope_prefix", mode="after")
77
+ def validate_scope_prefix(cls, scope_prefix: str) -> str:
78
+ if not scope_prefix:
79
+ return ""
80
+
81
+ # Allow the special case of a plain wildcard although it's not a valid regex
82
+ if scope_prefix == "*":
83
+ return ".*"
84
+
85
+ try:
86
+ regexp(scope_prefix)
87
+ except RegExpError as err:
88
+ raise ValueError(f"Invalid regex {scope_prefix!r}") from err
89
+
90
+ return scope_prefix
@@ -1,16 +1,25 @@
1
1
  from __future__ import annotations
2
2
 
3
- import re
4
3
  from functools import reduce
5
- from itertools import zip_longest
6
- from re import compile as regexp
4
+ from logging import getLogger
5
+ from re import (
6
+ DOTALL,
7
+ IGNORECASE,
8
+ MULTILINE,
9
+ Match as RegexMatch,
10
+ Pattern,
11
+ compile as regexp,
12
+ error as RegexError, # noqa: N812
13
+ )
7
14
  from textwrap import dedent
8
- from typing import TYPE_CHECKING, Tuple
15
+ from typing import TYPE_CHECKING, ClassVar
9
16
 
10
17
  from git.objects.commit import Commit
11
- from pydantic.dataclasses import dataclass
12
18
 
13
- from semantic_release.commit_parser._base import CommitParser, ParserOptions
19
+ from semantic_release.commit_parser._base import CommitParser
20
+ from semantic_release.commit_parser.conventional.options import (
21
+ ConventionalCommitParserOptions,
22
+ )
14
23
  from semantic_release.commit_parser.token import (
15
24
  ParsedCommit,
16
25
  ParsedMessageResult,
@@ -25,16 +34,10 @@ from semantic_release.commit_parser.util import (
25
34
  )
26
35
  from semantic_release.enums import LevelBump
27
36
  from semantic_release.errors import InvalidParserOptions
28
- from semantic_release.globals import logger
29
37
  from semantic_release.helpers import sort_numerically, text_reducer
30
38
 
31
- if TYPE_CHECKING: # pragma: no cover
32
- from git.objects.commit import Commit
33
-
34
-
35
- def _logged_parse_error(commit: Commit, error: str) -> ParseError:
36
- logger.debug(error)
37
- return ParseError(commit, error=error)
39
+ if TYPE_CHECKING:
40
+ pass
38
41
 
39
42
 
40
43
  # TODO: Remove from here, allow for user customization instead via options
@@ -53,69 +56,6 @@ LONG_TYPE_NAMES = {
53
56
  }
54
57
 
55
58
 
56
- @dataclass
57
- class ConventionalCommitParserOptions(ParserOptions):
58
- """Options dataclass for the ConventionalCommitParser."""
59
-
60
- minor_tags: Tuple[str, ...] = ("feat",)
61
- """Commit-type prefixes that should result in a minor release bump."""
62
-
63
- patch_tags: Tuple[str, ...] = ("fix", "perf")
64
- """Commit-type prefixes that should result in a patch release bump."""
65
-
66
- other_allowed_tags: Tuple[str, ...] = (
67
- "build",
68
- "chore",
69
- "ci",
70
- "docs",
71
- "style",
72
- "refactor",
73
- "test",
74
- )
75
- """Commit-type prefixes that are allowed but do not result in a version bump."""
76
-
77
- allowed_tags: Tuple[str, ...] = (
78
- *minor_tags,
79
- *patch_tags,
80
- *other_allowed_tags,
81
- )
82
- """
83
- All commit-type prefixes that are allowed.
84
-
85
- These are used to identify a valid commit message. If a commit message does not start with
86
- one of these prefixes, it will not be considered a valid commit message.
87
- """
88
-
89
- default_bump_level: LevelBump = LevelBump.NO_RELEASE
90
- """The minimum bump level to apply to valid commit message."""
91
-
92
- parse_squash_commits: bool = True
93
- """Toggle flag for whether or not to parse squash commits"""
94
-
95
- ignore_merge_commits: bool = True
96
- """Toggle flag for whether or not to ignore merge commits"""
97
-
98
- @property
99
- def tag_to_level(self) -> dict[str, LevelBump]:
100
- """A mapping of commit tags to the level bump they should result in."""
101
- return self._tag_to_level
102
-
103
- def __post_init__(self) -> None:
104
- self._tag_to_level: dict[str, LevelBump] = {
105
- str(tag): level
106
- for tag, level in [
107
- # we have to do a type ignore as zip_longest provides a type that is not specific enough
108
- # for our expected output. Due to the empty second array, we know the first is always longest
109
- # and that means no values in the first entry of the tuples will ever be a LevelBump. We
110
- # apply a str() to make mypy happy although it will never happen.
111
- *zip_longest(self.allowed_tags, (), fillvalue=self.default_bump_level),
112
- *zip_longest(self.patch_tags, (), fillvalue=LevelBump.PATCH),
113
- *zip_longest(self.minor_tags, (), fillvalue=LevelBump.MINOR),
114
- ]
115
- if "|" not in str(tag)
116
- }
117
-
118
-
119
59
  class ConventionalCommitParser(
120
60
  CommitParser[ParseResult, ConventionalCommitParserOptions]
121
61
  ):
@@ -128,14 +68,57 @@ class ConventionalCommitParser(
128
68
  # TODO: Deprecate in lieu of get_default_options()
129
69
  parser_options = ConventionalCommitParserOptions
130
70
 
71
+ # GitHub & Gitea use (#123), GitLab uses (!123), and BitBucket uses (pull request #123)
72
+ mr_selector = regexp(r"[\t ]+\((?:pull request )?(?P<mr_number>[#!]\d+)\)[\t ]*$")
73
+
74
+ issue_selector = regexp(
75
+ str.join(
76
+ "",
77
+ [
78
+ r"^(?:clos(?:e|es|ed|ing)|fix(?:es|ed|ing)?|resolv(?:e|es|ed|ing)|implement(?:s|ed|ing)?):",
79
+ r"[\t ]+(?P<issue_predicate>.+)[\t ]*$",
80
+ ],
81
+ ),
82
+ flags=MULTILINE | IGNORECASE,
83
+ )
84
+
85
+ notice_selector = regexp(r"^NOTICE: (?P<notice>.+)$")
86
+
87
+ common_commit_msg_filters: ClassVar[dict[str, tuple[Pattern[str], str]]] = {
88
+ "typo-extra-spaces": (regexp(r"(\S) +(\S)"), r"\1 \2"),
89
+ "git-header-commit": (
90
+ regexp(r"^[\t ]*commit [0-9a-f]+$\n?", flags=MULTILINE),
91
+ "",
92
+ ),
93
+ "git-header-author": (
94
+ regexp(r"^[\t ]*Author: .+$\n?", flags=MULTILINE),
95
+ "",
96
+ ),
97
+ "git-header-date": (
98
+ regexp(r"^[\t ]*Date: .+$\n?", flags=MULTILINE),
99
+ "",
100
+ ),
101
+ "git-squash-heading": (
102
+ regexp(
103
+ r"^[\t ]*Squashed commit of the following:.*$\n?",
104
+ flags=MULTILINE,
105
+ ),
106
+ "",
107
+ ),
108
+ }
109
+
131
110
  def __init__(self, options: ConventionalCommitParserOptions | None = None) -> None:
132
111
  super().__init__(options)
133
112
 
113
+ self._logger = getLogger(
114
+ str.join(".", [self.__module__, self.__class__.__name__])
115
+ )
116
+
134
117
  try:
135
118
  commit_type_pattern = regexp(
136
119
  r"(?P<type>%s)" % str.join("|", self.options.allowed_tags)
137
120
  )
138
- except re.error as err:
121
+ except RegexError as err:
139
122
  raise InvalidParserOptions(
140
123
  str.join(
141
124
  "\n",
@@ -167,45 +150,11 @@ class ConventionalCommitParser(
167
150
  r"(?:\n\n(?P<text>.+))?", # commit body
168
151
  ],
169
152
  ),
170
- flags=re.DOTALL,
153
+ flags=DOTALL,
171
154
  )
172
155
 
173
- # GitHub & Gitea use (#123), GitLab uses (!123), and BitBucket uses (pull request #123)
174
- self.mr_selector = regexp(
175
- r"[\t ]+\((?:pull request )?(?P<mr_number>[#!]\d+)\)[\t ]*$"
176
- )
177
- self.issue_selector = regexp(
178
- str.join(
179
- "",
180
- [
181
- r"^(?:clos(?:e|es|ed|ing)|fix(?:es|ed|ing)?|resolv(?:e|es|ed|ing)|implement(?:s|ed|ing)?):",
182
- r"[\t ]+(?P<issue_predicate>.+)[\t ]*$",
183
- ],
184
- ),
185
- flags=re.MULTILINE | re.IGNORECASE,
186
- )
187
- self.notice_selector = regexp(r"^NOTICE: (?P<notice>.+)$")
188
- self.filters = {
189
- "typo-extra-spaces": (regexp(r"(\S) +(\S)"), r"\1 \2"),
190
- "git-header-commit": (
191
- regexp(r"^[\t ]*commit [0-9a-f]+$\n?", flags=re.MULTILINE),
192
- "",
193
- ),
194
- "git-header-author": (
195
- regexp(r"^[\t ]*Author: .+$\n?", flags=re.MULTILINE),
196
- "",
197
- ),
198
- "git-header-date": (
199
- regexp(r"^[\t ]*Date: .+$\n?", flags=re.MULTILINE),
200
- "",
201
- ),
202
- "git-squash-heading": (
203
- regexp(
204
- r"^[\t ]*Squashed commit of the following:.*$\n?",
205
- flags=re.MULTILINE,
206
- ),
207
- "",
208
- ),
156
+ self.filters: dict[str, tuple[Pattern[str], str]] = {
157
+ **self.common_commit_msg_filters,
209
158
  "git-squash-commit-prefix": (
210
159
  regexp(
211
160
  str.join(
@@ -215,17 +164,20 @@ class ConventionalCommitParser(
215
164
  commit_type_pattern.pattern + r"\b", # prior to commit type
216
165
  ],
217
166
  ),
218
- flags=re.MULTILINE,
167
+ flags=MULTILINE,
219
168
  ),
220
169
  # move commit type to the start of the line
221
170
  r"\1",
222
171
  ),
223
172
  }
224
173
 
225
- @staticmethod
226
- def get_default_options() -> ConventionalCommitParserOptions:
174
+ def get_default_options(self) -> ConventionalCommitParserOptions:
227
175
  return ConventionalCommitParserOptions()
228
176
 
177
+ def log_parse_error(self, commit: Commit, error: str) -> ParseError:
178
+ self._logger.debug(error)
179
+ return ParseError(commit, error=error)
180
+
229
181
  def commit_body_components_separator(
230
182
  self, accumulator: dict[str, list[str]], text: str
231
183
  ) -> dict[str, list[str]]:
@@ -267,14 +219,20 @@ class ConventionalCommitParser(
267
219
  return accumulator
268
220
 
269
221
  def parse_message(self, message: str) -> ParsedMessageResult | None:
270
- if not (parsed := self.commit_msg_pattern.match(message)):
271
- return None
222
+ return (
223
+ self.create_parsed_message_result(match)
224
+ if (match := self.commit_msg_pattern.match(message))
225
+ else None
226
+ )
272
227
 
273
- parsed_break = parsed.group("break")
274
- parsed_scope = parsed.group("scope") or ""
275
- parsed_subject = parsed.group("subject")
276
- parsed_text = parsed.group("text")
277
- parsed_type = parsed.group("type")
228
+ def create_parsed_message_result(
229
+ self, match: RegexMatch[str]
230
+ ) -> ParsedMessageResult:
231
+ parsed_break = match.group("break")
232
+ parsed_scope = match.group("scope") or ""
233
+ parsed_subject = match.group("subject")
234
+ parsed_text = match.group("text")
235
+ parsed_type = match.group("type")
278
236
 
279
237
  linked_merge_request = ""
280
238
  if mr_match := self.mr_selector.search(parsed_subject):
@@ -322,7 +280,7 @@ class ConventionalCommitParser(
322
280
 
323
281
  def parse_commit(self, commit: Commit) -> ParseResult:
324
282
  if not (parsed_msg_result := self.parse_message(force_str(commit.message))):
325
- return _logged_parse_error(
283
+ return self.log_parse_error(
326
284
  commit,
327
285
  f"Unable to parse commit message: {commit.message!r}",
328
286
  )
@@ -342,7 +300,7 @@ class ConventionalCommitParser(
342
300
  will be returned as a list of a single ParseResult.
343
301
  """
344
302
  if self.options.ignore_merge_commits and self.is_merge_commit(commit):
345
- return _logged_parse_error(
303
+ return self.log_parse_error(
346
304
  commit, "Ignoring merge commit: %s" % commit.hexsha[:8]
347
305
  )
348
306
 
@@ -0,0 +1,467 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from fnmatch import fnmatch
5
+ from logging import getLogger
6
+ from pathlib import Path, PurePath, PurePosixPath, PureWindowsPath
7
+ from re import DOTALL, compile as regexp, error as RegexError # noqa: N812
8
+ from typing import TYPE_CHECKING
9
+
10
+ from semantic_release.commit_parser._base import CommitParser
11
+ from semantic_release.commit_parser.conventional.options import (
12
+ ConventionalCommitParserOptions,
13
+ )
14
+ from semantic_release.commit_parser.conventional.options_monorepo import (
15
+ ConventionalCommitMonorepoParserOptions,
16
+ )
17
+ from semantic_release.commit_parser.conventional.parser import ConventionalCommitParser
18
+ from semantic_release.commit_parser.token import (
19
+ ParsedCommit,
20
+ ParsedMessageResult,
21
+ ParseError,
22
+ ParseResult,
23
+ )
24
+ from semantic_release.commit_parser.util import force_str
25
+ from semantic_release.errors import InvalidParserOptions
26
+
27
+ if TYPE_CHECKING: # pragma: no cover
28
+ from git.objects.commit import Commit
29
+
30
+
31
+ class ConventionalCommitMonorepoParser(
32
+ CommitParser[ParseResult, ConventionalCommitMonorepoParserOptions]
33
+ ):
34
+ # TODO: Remove for v11 compatibility, get_default_options() will be called instead
35
+ parser_options = ConventionalCommitMonorepoParserOptions
36
+
37
+ def __init__(
38
+ self, options: ConventionalCommitMonorepoParserOptions | None = None
39
+ ) -> None:
40
+ super().__init__(options)
41
+
42
+ try:
43
+ commit_scope_pattern = regexp(
44
+ r"\(" + self.options.scope_prefix + r"(?P<scope>[^\n]+)?\)",
45
+ )
46
+ except RegexError as err:
47
+ raise InvalidParserOptions(
48
+ str.join(
49
+ "\n",
50
+ [
51
+ f"Invalid options for {self.__class__.__name__}",
52
+ "Unable to create regular expression from configured scope_prefix.",
53
+ "Please check the configured scope_prefix and remove or escape any regular expression characters.",
54
+ ],
55
+ )
56
+ ) from err
57
+
58
+ try:
59
+ commit_type_pattern = regexp(
60
+ r"(?P<type>%s)" % str.join("|", self.options.allowed_tags)
61
+ )
62
+ except RegexError as err:
63
+ raise InvalidParserOptions(
64
+ str.join(
65
+ "\n",
66
+ [
67
+ f"Invalid options for {self.__class__.__name__}",
68
+ "Unable to create regular expression from configured commit-types.",
69
+ "Please check the configured commit-types and remove or escape any regular expression characters.",
70
+ ],
71
+ )
72
+ ) from err
73
+
74
+ # This regular expression includes scope prefix into the pattern and forces a scope to be present
75
+ # PSR will match the full scope but we don't include it in the scope match,
76
+ # which implicitly strips it from being included in the returned scope.
77
+ self._strict_scope_pattern = regexp(
78
+ str.join(
79
+ "",
80
+ [
81
+ r"^" + commit_type_pattern.pattern,
82
+ commit_scope_pattern.pattern,
83
+ r"(?P<break>!)?:\s+",
84
+ r"(?P<subject>[^\n]+)",
85
+ r"(?:\n\n(?P<text>.+))?", # commit body
86
+ ],
87
+ ),
88
+ flags=DOTALL,
89
+ )
90
+
91
+ self._optional_scope_pattern = regexp(
92
+ str.join(
93
+ "",
94
+ [
95
+ r"^" + commit_type_pattern.pattern,
96
+ r"(?:\((?P<scope>[^\n]+)\))?",
97
+ r"(?P<break>!)?:\s+",
98
+ r"(?P<subject>[^\n]+)",
99
+ r"(?:\n\n(?P<text>.+))?", # commit body
100
+ ],
101
+ ),
102
+ flags=DOTALL,
103
+ )
104
+
105
+ file_select_filters, file_ignore_filters = self._process_path_filter_options(
106
+ self.options.path_filters
107
+ )
108
+ self._file_selection_filters: list[str] = file_select_filters
109
+ self._file_ignore_filters: list[str] = file_ignore_filters
110
+
111
+ self._logger = getLogger(
112
+ str.join(".", [self.__module__, self.__class__.__name__])
113
+ )
114
+
115
+ self._base_parser = ConventionalCommitParser(
116
+ options=ConventionalCommitParserOptions(
117
+ **{
118
+ k: getattr(self.options, k)
119
+ for k in ConventionalCommitParserOptions().__dataclass_fields__
120
+ }
121
+ )
122
+ )
123
+
124
+ def get_default_options(self) -> ConventionalCommitMonorepoParserOptions:
125
+ return ConventionalCommitMonorepoParserOptions()
126
+
127
+ @staticmethod
128
+ def _process_path_filter_options( # noqa: C901
129
+ path_filters: tuple[str, ...],
130
+ ) -> tuple[list[str], list[str]]:
131
+ file_ignore_filters: list[str] = []
132
+ file_selection_filters: list[str] = []
133
+ unique_selection_filters: set[str] = set()
134
+ unique_ignore_filters: set[str] = set()
135
+
136
+ for str_path in path_filters:
137
+ str_filter = str_path[1:] if str_path.startswith("!") else str_path
138
+ filter_list = (
139
+ file_ignore_filters
140
+ if str_path.startswith("!")
141
+ else file_selection_filters
142
+ )
143
+ unique_cache = (
144
+ unique_ignore_filters
145
+ if str_path.startswith("!")
146
+ else unique_selection_filters
147
+ )
148
+
149
+ # Since fnmatch is not too flexible, we will expand the path filters to include the name and any subdirectories
150
+ # as this is how gitignore is interpreted. Possible scenarios:
151
+ # | Input | Path Normalization | Filter List |
152
+ # | ---------- | ------------------ | ------------------------- |
153
+ # | / | / | /** | done
154
+ # | /./ | / | /** | done
155
+ # | /** | /** | /** | done
156
+ # | /./** | /** | /** | done
157
+ # | /* | /* | /* | done
158
+ # | . | . | ./** | done
159
+ # | ./ | . | ./** | done
160
+ # | ././ | . | ./** | done
161
+ # | ./** | ./** | ./** | done
162
+ # | ./* | ./* | ./* | done
163
+ # | .. | .. | ../** | done
164
+ # | ../ | .. | ../** | done
165
+ # | ../** | ../** | ../** | done
166
+ # | ../* | ../* | ../* | done
167
+ # | ../.. | ../.. | ../../** | done
168
+ # | ../../ | ../../ | ../../** | done
169
+ # | ../../docs | ../../docs | ../../docs, ../../docs/** | done
170
+ # | src | src | src, src/** | done
171
+ # | src/ | src | src/** | done
172
+ # | src/* | src/* | src/* | done
173
+ # | src/** | src/** | src/** | done
174
+ # | /src | /src | /src, /src/** | done
175
+ # | /src/ | /src | /src/** | done
176
+ # | /src/** | /src/** | /src/** | done
177
+ # | /src/* | /src/* | /src/* | done
178
+ # | ../d/f.txt | ../d/f.txt | ../d/f.txt, ../d/f.txt/** | done
179
+ # This expansion will occur regardless of the negation prefix
180
+
181
+ os_path: PurePath | PurePosixPath | PureWindowsPath = PurePath(str_filter)
182
+
183
+ if r"\\" in str_filter:
184
+ # Windows paths were given so we convert them to posix paths
185
+ os_path = PureWindowsPath(str_filter)
186
+ os_path = (
187
+ PureWindowsPath(
188
+ os_path.root, *os_path.parts[1:]
189
+ ) # drop any drive letter
190
+ if os_path.is_absolute()
191
+ else os_path
192
+ )
193
+ os_path = PurePosixPath(os_path.as_posix())
194
+
195
+ path_normalized = str(os_path)
196
+ if path_normalized == str(
197
+ Path(".").absolute().root
198
+ ) or path_normalized == str(Path("/**")):
199
+ path_normalized = "/**"
200
+
201
+ elif path_normalized == str(Path("/*")):
202
+ pass
203
+
204
+ elif path_normalized == str(Path(".")) or path_normalized == str(
205
+ Path("./**")
206
+ ):
207
+ path_normalized = "./**"
208
+
209
+ elif path_normalized == str(Path("./*")):
210
+ path_normalized = "./*"
211
+
212
+ elif path_normalized == str(Path("..")) or path_normalized == str(
213
+ Path("../**")
214
+ ):
215
+ path_normalized = "../**"
216
+
217
+ elif path_normalized == str(Path("../*")):
218
+ path_normalized = "../*"
219
+
220
+ elif path_normalized.endswith(("..", "../**")):
221
+ path_normalized = f"{path_normalized.rstrip('*')}/**"
222
+
223
+ elif str_filter.endswith(os.sep):
224
+ # If the path ends with a separator, it is a directory, so we add the directory and all subdirectories
225
+ path_normalized = f"{path_normalized}/**"
226
+
227
+ elif not path_normalized.endswith("*"):
228
+ all_subdirs = f"{path_normalized}/**"
229
+ if all_subdirs not in unique_cache:
230
+ unique_cache.add(all_subdirs)
231
+ filter_list.append(all_subdirs)
232
+ # And fall through to add the path as is
233
+
234
+ # END IF
235
+
236
+ # Add the normalized path to the filter list if it is not already present
237
+ if path_normalized not in unique_cache:
238
+ unique_cache.add(path_normalized)
239
+ filter_list.append(path_normalized)
240
+
241
+ return file_selection_filters, file_ignore_filters
242
+
243
+ def logged_parse_error(self, commit: Commit, error: str) -> ParseError:
244
+ self._logger.debug(error)
245
+ return ParseError(commit, error=error)
246
+
247
+ def parse(self, commit: Commit) -> ParseResult | list[ParseResult]:
248
+ if self.options.ignore_merge_commits and self._base_parser.is_merge_commit(
249
+ commit
250
+ ):
251
+ return self._base_parser.log_parse_error(
252
+ commit, "Ignoring merge commit: %s" % commit.hexsha[:8]
253
+ )
254
+
255
+ separate_commits: list[Commit] = (
256
+ self._base_parser.unsquash_commit(commit)
257
+ if self.options.parse_squash_commits
258
+ else [commit]
259
+ )
260
+
261
+ # Parse each commit individually if there were more than one
262
+ parsed_commits: list[ParseResult] = list(
263
+ map(self.parse_commit, separate_commits)
264
+ )
265
+
266
+ def add_linked_merge_request(
267
+ parsed_result: ParseResult, mr_number: str
268
+ ) -> ParseResult:
269
+ return (
270
+ parsed_result
271
+ if not isinstance(parsed_result, ParsedCommit)
272
+ else ParsedCommit(
273
+ **{
274
+ **parsed_result._asdict(),
275
+ "linked_merge_request": mr_number,
276
+ }
277
+ )
278
+ )
279
+
280
+ # TODO: improve this for other VCS systems other than GitHub & BitBucket
281
+ # Github works as the first commit in a squash merge commit has the PR number
282
+ # appended to the first line of the commit message
283
+ lead_commit = next(iter(parsed_commits))
284
+
285
+ if isinstance(lead_commit, ParsedCommit) and lead_commit.linked_merge_request:
286
+ # If the first commit has linked merge requests, assume all commits
287
+ # are part of the same PR and add the linked merge requests to all
288
+ # parsed commits
289
+ parsed_commits = [
290
+ lead_commit,
291
+ *map(
292
+ lambda parsed_result, mr=lead_commit.linked_merge_request: ( # type: ignore[misc]
293
+ add_linked_merge_request(parsed_result, mr)
294
+ ),
295
+ parsed_commits[1:],
296
+ ),
297
+ ]
298
+
299
+ elif isinstance(lead_commit, ParseError) and (
300
+ mr_match := self._base_parser.mr_selector.search(
301
+ force_str(lead_commit.message)
302
+ )
303
+ ):
304
+ # Handle BitBucket Squash Merge Commits (see #1085), which have non angular commit
305
+ # format but include the PR number in the commit subject that we want to extract
306
+ linked_merge_request = mr_match.group("mr_number")
307
+
308
+ # apply the linked MR to all commits
309
+ parsed_commits = [
310
+ add_linked_merge_request(parsed_result, linked_merge_request)
311
+ for parsed_result in parsed_commits
312
+ ]
313
+
314
+ return parsed_commits
315
+
316
+ def parse_message(
317
+ self, message: str, strict_scope: bool = False
318
+ ) -> ParsedMessageResult | None:
319
+ if (
320
+ not (parsed_match := self._strict_scope_pattern.match(message))
321
+ and strict_scope
322
+ ):
323
+ return None
324
+
325
+ if not parsed_match and not (
326
+ parsed_match := self._optional_scope_pattern.match(message)
327
+ ):
328
+ return None
329
+
330
+ return self._base_parser.create_parsed_message_result(parsed_match)
331
+
332
+ def parse_commit(self, commit: Commit) -> ParseResult:
333
+ """Attempt to parse the commit message with a regular expression into a ParseResult."""
334
+ # Multiple scenarios to consider when parsing a commit message [Truth table]:
335
+ # =======================================================================================================
336
+ # | || INPUTS || |
337
+ # | # ||------------------------+----------------+--------------|| Result |
338
+ # | || Example Commit Message | Relevant Files | Scope Prefix || |
339
+ # |----||------------------------+----------------+--------------||-------------------------------------|
340
+ # | 1 || type(prefix-cli): msg | yes | "prefix-" || ParsedCommit |
341
+ # | 2 || type(prefix-cli): msg | yes | "" || ParsedCommit |
342
+ # | 3 || type(prefix-cli): msg | no | "prefix-" || ParsedCommit |
343
+ # | 4 || type(prefix-cli): msg | no | "" || ParseError[No files] |
344
+ # | 5 || type(scope-cli): msg | yes | "prefix-" || ParsedCommit |
345
+ # | 6 || type(scope-cli): msg | yes | "" || ParsedCommit |
346
+ # | 7 || type(scope-cli): msg | no | "prefix-" || ParseError[No files & wrong scope] |
347
+ # | 8 || type(scope-cli): msg | no | "" || ParseError[No files] |
348
+ # | 9 || type(cli): msg | yes | "prefix-" || ParsedCommit |
349
+ # | 10 || type(cli): msg | yes | "" || ParsedCommit |
350
+ # | 11 || type(cli): msg | no | "prefix-" || ParseError[No files & wrong scope] |
351
+ # | 12 || type(cli): msg | no | "" || ParseError[No files] |
352
+ # | 13 || type: msg | yes | "prefix-" || ParsedCommit |
353
+ # | 14 || type: msg | yes | "" || ParsedCommit |
354
+ # | 15 || type: msg | no | "prefix-" || ParseError[No files & wrong scope] |
355
+ # | 16 || type: msg | no | "" || ParseError[No files] |
356
+ # | 17 || non-conventional msg | yes | "prefix-" || ParseError[Invalid Syntax] |
357
+ # | 18 || non-conventional msg | yes | "" || ParseError[Invalid Syntax] |
358
+ # | 19 || non-conventional msg | no | "prefix-" || ParseError[Invalid Syntax] |
359
+ # | 20 || non-conventional msg | no | "" || ParseError[Invalid Syntax] |
360
+ # =======================================================================================================
361
+
362
+ # Initial Logic Flow:
363
+ # [1] When there are no relevant files and a scope prefix is defined, we enforce a strict scope
364
+ # [2] When there are no relevant files and no scope prefix is defined, we parse scoped or unscoped commits
365
+ # [3] When there are relevant files, we parse scoped or unscoped commits regardless of any defined prefix
366
+ has_relevant_changed_files = self._has_relevant_changed_files(commit)
367
+ strict_scope = bool(
368
+ not has_relevant_changed_files and self.options.scope_prefix
369
+ )
370
+ pmsg_result = self.parse_message(
371
+ message=force_str(commit.message),
372
+ strict_scope=strict_scope,
373
+ )
374
+
375
+ if pmsg_result and (has_relevant_changed_files or strict_scope):
376
+ self._logger.debug(
377
+ "commit %s introduces a %s level_bump",
378
+ commit.hexsha[:8],
379
+ pmsg_result.bump,
380
+ )
381
+
382
+ return ParsedCommit.from_parsed_message_result(commit, pmsg_result)
383
+
384
+ if pmsg_result and not has_relevant_changed_files:
385
+ return self.logged_parse_error(
386
+ commit,
387
+ f"Commit {commit.hexsha[:7]} has no changed files matching the path filter(s)",
388
+ )
389
+
390
+ if strict_scope and self.parse_message(str(commit.message), strict_scope=False):
391
+ return self.logged_parse_error(
392
+ commit,
393
+ str.join(
394
+ " and ",
395
+ [
396
+ f"Commit {commit.hexsha[:7]} has no changed files matching the path filter(s)",
397
+ f"the scope does not match scope prefix '{self.options.scope_prefix}'",
398
+ ],
399
+ ),
400
+ )
401
+
402
+ return self.logged_parse_error(
403
+ commit,
404
+ f"Format Mismatch! Unable to parse commit message: {commit.message!r}",
405
+ )
406
+
407
+ def unsquash_commit_message(self, message: str) -> list[str]:
408
+ return self._base_parser.unsquash_commit_message(message)
409
+
410
+ def _has_relevant_changed_files(self, commit: Commit) -> bool:
411
+ # Extract git root from commit
412
+ git_root = (
413
+ Path(commit.repo.working_tree_dir or commit.repo.working_dir)
414
+ .absolute()
415
+ .resolve()
416
+ )
417
+
418
+ cwd = Path.cwd().absolute().resolve()
419
+
420
+ rel_cwd = cwd.relative_to(git_root) if git_root in cwd.parents else Path(".")
421
+
422
+ sandboxed_selection_filters: list[str] = [
423
+ str(file_filter)
424
+ for file_filter in (
425
+ (
426
+ git_root / select_filter.rstrip("/")
427
+ if Path(select_filter).is_absolute()
428
+ else git_root / rel_cwd / select_filter
429
+ )
430
+ for select_filter in self._file_selection_filters
431
+ )
432
+ if git_root in file_filter.parents
433
+ ]
434
+
435
+ sandboxed_ignore_filters: list[str] = [
436
+ str(file_filter)
437
+ for file_filter in (
438
+ (
439
+ git_root / ignore_filter.rstrip("/")
440
+ if Path(ignore_filter).is_absolute()
441
+ else git_root / rel_cwd / ignore_filter
442
+ )
443
+ for ignore_filter in self._file_ignore_filters
444
+ )
445
+ if git_root in file_filter.parents
446
+ ]
447
+
448
+ # Check if the changed files of the commit that match the path filters
449
+ for full_path in iter(
450
+ str(git_root / rel_git_path) for rel_git_path in commit.stats.files
451
+ ):
452
+ # Check if the filepath matches any of the file selection filters
453
+ if not any(
454
+ fnmatch(full_path, select_filter)
455
+ for select_filter in sandboxed_selection_filters
456
+ ):
457
+ continue
458
+
459
+ # Pass filter matches, so now evaluate if it is supposed to be ignored
460
+ if not any(
461
+ fnmatch(full_path, ignore_filter)
462
+ for ignore_filter in sandboxed_ignore_filters
463
+ ):
464
+ # No ignore filter matched, so it must be a relevant file
465
+ return True
466
+
467
+ return False