commit-shield 0.2.0__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.
- commit_guard/__init__.py +5 -0
- commit_guard/_toml.py +276 -0
- commit_guard/checkers.py +277 -0
- commit_guard/cli.py +401 -0
- commit_guard/config.py +340 -0
- commit_guard/gitutil.py +106 -0
- commit_shield-0.2.0.dist-info/METADATA +179 -0
- commit_shield-0.2.0.dist-info/RECORD +11 -0
- commit_shield-0.2.0.dist-info/WHEEL +4 -0
- commit_shield-0.2.0.dist-info/entry_points.txt +3 -0
- commit_shield-0.2.0.dist-info/licenses/LICENSE +21 -0
commit_guard/config.py
ADDED
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Configuration loading for commit-guard.
|
|
3
|
+
|
|
4
|
+
Settings are resolved in order of increasing precedence:
|
|
5
|
+
|
|
6
|
+
1. built-in defaults
|
|
7
|
+
2. ``[tool.commit-guard]`` in ``pyproject.toml``
|
|
8
|
+
3. ``.commit-guard.toml`` (whole file, or a ``[commit-guard]`` table)
|
|
9
|
+
4. explicit command-line flags
|
|
10
|
+
|
|
11
|
+
Discovery walks upwards from the starting directory to the repository root, so
|
|
12
|
+
running ``git commit`` in a subdirectory picks up the project configuration.
|
|
13
|
+
Unknown keys are reported as errors rather than ignored, so that a typo such as
|
|
14
|
+
``max_header_length`` does not silently leave the default in place.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
import os
|
|
18
|
+
from typing import Any, Dict, List, Optional, Sequence, Set
|
|
19
|
+
|
|
20
|
+
from commit_guard._toml import TomlError, loads
|
|
21
|
+
|
|
22
|
+
__all__ = ["Config", "ConfigError", "load_config", "find_config_files"]
|
|
23
|
+
|
|
24
|
+
CONFIG_FILENAME = ".commit-guard.toml"
|
|
25
|
+
PYPROJECT_FILENAME = "pyproject.toml"
|
|
26
|
+
TOOL_TABLE = "commit-guard"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class ConfigError(Exception):
|
|
30
|
+
"""Raised when a configuration file is malformed or has invalid values."""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
# Conventional Commit types recognised unless configuration overrides them.
|
|
34
|
+
DEFAULT_TYPES = (
|
|
35
|
+
"feat",
|
|
36
|
+
"fix",
|
|
37
|
+
"docs",
|
|
38
|
+
"style",
|
|
39
|
+
"refactor",
|
|
40
|
+
"perf",
|
|
41
|
+
"test",
|
|
42
|
+
"build",
|
|
43
|
+
"ci",
|
|
44
|
+
"chore",
|
|
45
|
+
"revert",
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
# Filename patterns that should never be committed. These are regexes matched
|
|
49
|
+
# against the forward-slash-normalised path.
|
|
50
|
+
DEFAULT_SENSITIVE_PATTERNS = (
|
|
51
|
+
r"(^|/)\.env($|\..*)",
|
|
52
|
+
r"(^|/).*\.(pem|pkcs12|pfx|p12|jks|keystore)$",
|
|
53
|
+
r"(^|/)id_(rsa|dsa|ed25519|ecdsa)($|\..*)",
|
|
54
|
+
r"(^|/).*(secret|password|credential)s?.*\.(json|ya?ml|txt|ini|cfg)$",
|
|
55
|
+
r"(^|/)\.(npmrc|pypirc|netrc)$",
|
|
56
|
+
r"(^|/)\.aws/credentials$",
|
|
57
|
+
r"(^|/).*\.key$",
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
# Paths excluded from sensitive-name matching by default. Sample and template
|
|
61
|
+
# files are published deliberately, and ``*.key`` has enough innocent uses that
|
|
62
|
+
# shipping these keeps the first run from being all false positives.
|
|
63
|
+
DEFAULT_ALLOWLIST = (
|
|
64
|
+
"*.example",
|
|
65
|
+
"*.sample",
|
|
66
|
+
"*.template",
|
|
67
|
+
"*.dist",
|
|
68
|
+
".env.example",
|
|
69
|
+
".env.sample",
|
|
70
|
+
".env.template",
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
# Commit subjects starting with any of these are generated by git itself or by
|
|
74
|
+
# an interactive rebase, and are not the author's to format.
|
|
75
|
+
DEFAULT_SKIP_PREFIXES = (
|
|
76
|
+
"merge ",
|
|
77
|
+
"revert ",
|
|
78
|
+
"fixup!",
|
|
79
|
+
"squash!",
|
|
80
|
+
"amend!",
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
_BOOL_KEYS = (
|
|
84
|
+
"require_scope",
|
|
85
|
+
"allow_trailing_period",
|
|
86
|
+
"skip_merge_commits",
|
|
87
|
+
)
|
|
88
|
+
_INT_KEYS = (
|
|
89
|
+
"max_header_len",
|
|
90
|
+
"max_body_line_len",
|
|
91
|
+
"subject_min_len",
|
|
92
|
+
)
|
|
93
|
+
_FLOAT_KEYS = ("max_size_mb",)
|
|
94
|
+
_LIST_KEYS = (
|
|
95
|
+
"types",
|
|
96
|
+
"scopes",
|
|
97
|
+
"sensitive_patterns",
|
|
98
|
+
"extra_sensitive_patterns",
|
|
99
|
+
"allowlist",
|
|
100
|
+
"skip_prefixes",
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
KNOWN_KEYS = frozenset(_BOOL_KEYS + _INT_KEYS + _FLOAT_KEYS + _LIST_KEYS)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class Config:
|
|
107
|
+
"""Resolved commit-guard settings."""
|
|
108
|
+
|
|
109
|
+
__slots__ = (
|
|
110
|
+
"types",
|
|
111
|
+
"scopes",
|
|
112
|
+
"require_scope",
|
|
113
|
+
"max_header_len",
|
|
114
|
+
"max_body_line_len",
|
|
115
|
+
"subject_min_len",
|
|
116
|
+
"allow_trailing_period",
|
|
117
|
+
"max_size_mb",
|
|
118
|
+
"sensitive_patterns",
|
|
119
|
+
"allowlist",
|
|
120
|
+
"skip_merge_commits",
|
|
121
|
+
"skip_prefixes",
|
|
122
|
+
"sources",
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
def __init__(
|
|
126
|
+
self,
|
|
127
|
+
types: Sequence[str] = DEFAULT_TYPES,
|
|
128
|
+
scopes: Sequence[str] = (),
|
|
129
|
+
require_scope: bool = False,
|
|
130
|
+
max_header_len: int = 72,
|
|
131
|
+
max_body_line_len: int = 0,
|
|
132
|
+
subject_min_len: int = 3,
|
|
133
|
+
allow_trailing_period: bool = False,
|
|
134
|
+
max_size_mb: float = 10.0,
|
|
135
|
+
sensitive_patterns: Sequence[str] = DEFAULT_SENSITIVE_PATTERNS,
|
|
136
|
+
allowlist: Sequence[str] = DEFAULT_ALLOWLIST,
|
|
137
|
+
skip_merge_commits: bool = True,
|
|
138
|
+
skip_prefixes: Sequence[str] = DEFAULT_SKIP_PREFIXES,
|
|
139
|
+
sources: Sequence[str] = (),
|
|
140
|
+
) -> None:
|
|
141
|
+
self.types: Set[str] = set(types)
|
|
142
|
+
self.scopes: Set[str] = set(scopes)
|
|
143
|
+
self.require_scope = require_scope
|
|
144
|
+
self.max_header_len = max_header_len
|
|
145
|
+
# 0 disables the body line-length check.
|
|
146
|
+
self.max_body_line_len = max_body_line_len
|
|
147
|
+
self.subject_min_len = subject_min_len
|
|
148
|
+
self.allow_trailing_period = allow_trailing_period
|
|
149
|
+
self.max_size_mb = max_size_mb
|
|
150
|
+
self.sensitive_patterns: List[str] = list(sensitive_patterns)
|
|
151
|
+
self.allowlist: List[str] = list(allowlist)
|
|
152
|
+
self.skip_merge_commits = skip_merge_commits
|
|
153
|
+
self.skip_prefixes: List[str] = [p.lower() for p in skip_prefixes]
|
|
154
|
+
self.sources: List[str] = list(sources)
|
|
155
|
+
|
|
156
|
+
def __repr__(self) -> str:
|
|
157
|
+
return "Config(types={0}, max_header_len={1}, sources={2})".format(
|
|
158
|
+
sorted(self.types), self.max_header_len, self.sources
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def find_config_files(start_dir: Optional[str] = None) -> List[str]:
|
|
163
|
+
"""
|
|
164
|
+
Locate configuration files, nearest-last so later files win.
|
|
165
|
+
|
|
166
|
+
Walks upward from ``start_dir`` to the filesystem root, stopping after the
|
|
167
|
+
directory that contains ``.git``. Returns absolute paths.
|
|
168
|
+
"""
|
|
169
|
+
base = os.path.abspath(start_dir or os.getcwd())
|
|
170
|
+
chain: List[str] = []
|
|
171
|
+
|
|
172
|
+
current = base
|
|
173
|
+
while True:
|
|
174
|
+
chain.append(current)
|
|
175
|
+
if os.path.exists(os.path.join(current, ".git")):
|
|
176
|
+
break
|
|
177
|
+
parent = os.path.dirname(current)
|
|
178
|
+
if parent == current:
|
|
179
|
+
break
|
|
180
|
+
current = parent
|
|
181
|
+
|
|
182
|
+
found: List[str] = []
|
|
183
|
+
# Reversed so the outermost directory is processed first and the nearest
|
|
184
|
+
# configuration file ends up last in the list.
|
|
185
|
+
for directory in reversed(chain):
|
|
186
|
+
for name in (PYPROJECT_FILENAME, CONFIG_FILENAME):
|
|
187
|
+
candidate = os.path.join(directory, name)
|
|
188
|
+
if os.path.isfile(candidate):
|
|
189
|
+
found.append(candidate)
|
|
190
|
+
return found
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _extract_table(data: Dict[str, Any], path: str) -> Optional[Dict[str, Any]]:
|
|
194
|
+
"""
|
|
195
|
+
Pull the commit-guard settings table out of one parsed file.
|
|
196
|
+
|
|
197
|
+
``pyproject.toml`` must use ``[tool.commit-guard]``. A dedicated
|
|
198
|
+
``.commit-guard.toml`` may either use a ``[commit-guard]`` table or place
|
|
199
|
+
settings at the top level.
|
|
200
|
+
"""
|
|
201
|
+
if os.path.basename(path) == PYPROJECT_FILENAME:
|
|
202
|
+
tool = data.get("tool")
|
|
203
|
+
if not isinstance(tool, dict):
|
|
204
|
+
return None
|
|
205
|
+
section = tool.get(TOOL_TABLE)
|
|
206
|
+
if section is None:
|
|
207
|
+
return None
|
|
208
|
+
if not isinstance(section, dict):
|
|
209
|
+
raise ConfigError(
|
|
210
|
+
"{0}: [tool.{1}] must be a table".format(path, TOOL_TABLE)
|
|
211
|
+
)
|
|
212
|
+
return section
|
|
213
|
+
|
|
214
|
+
section = data.get(TOOL_TABLE)
|
|
215
|
+
if isinstance(section, dict):
|
|
216
|
+
return section
|
|
217
|
+
# Top-level settings; drop nested tables so a stray [tool] block in a
|
|
218
|
+
# standalone config file does not look like a setting.
|
|
219
|
+
return {k: v for k, v in data.items() if not isinstance(v, dict)}
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _coerce(path: str, key: str, value: Any) -> Any:
|
|
223
|
+
"""Validate one setting's type, raising ConfigError with the file name."""
|
|
224
|
+
where = "{0}: '{1}'".format(path, key)
|
|
225
|
+
|
|
226
|
+
if key in _BOOL_KEYS:
|
|
227
|
+
if not isinstance(value, bool):
|
|
228
|
+
raise ConfigError("{0} must be true or false".format(where))
|
|
229
|
+
return value
|
|
230
|
+
|
|
231
|
+
if key in _INT_KEYS:
|
|
232
|
+
# bool is a subclass of int; reject it explicitly.
|
|
233
|
+
if isinstance(value, bool) or not isinstance(value, int):
|
|
234
|
+
raise ConfigError("{0} must be an integer".format(where))
|
|
235
|
+
if value < 0:
|
|
236
|
+
raise ConfigError("{0} must not be negative".format(where))
|
|
237
|
+
return value
|
|
238
|
+
|
|
239
|
+
if key in _FLOAT_KEYS:
|
|
240
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
|
241
|
+
raise ConfigError("{0} must be a number".format(where))
|
|
242
|
+
if value <= 0:
|
|
243
|
+
raise ConfigError("{0} must be greater than zero".format(where))
|
|
244
|
+
return float(value)
|
|
245
|
+
|
|
246
|
+
if key in _LIST_KEYS:
|
|
247
|
+
if not isinstance(value, list) or not all(
|
|
248
|
+
isinstance(item, str) for item in value
|
|
249
|
+
):
|
|
250
|
+
raise ConfigError("{0} must be a list of strings".format(where))
|
|
251
|
+
return list(value)
|
|
252
|
+
|
|
253
|
+
raise ConfigError(
|
|
254
|
+
"{0} is not a recognised setting. Valid keys: {1}".format(
|
|
255
|
+
where, ", ".join(sorted(KNOWN_KEYS))
|
|
256
|
+
)
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def _apply(config: Config, path: str, section: Dict[str, Any]) -> None:
|
|
261
|
+
"""Merge one settings table onto an existing Config in place."""
|
|
262
|
+
values: Dict[str, Any] = {}
|
|
263
|
+
for key, raw in section.items():
|
|
264
|
+
values[key] = _coerce(path, key, raw)
|
|
265
|
+
|
|
266
|
+
if "types" in values:
|
|
267
|
+
if not values["types"]:
|
|
268
|
+
raise ConfigError("{0}: 'types' must not be empty".format(path))
|
|
269
|
+
config.types = set(values["types"])
|
|
270
|
+
if "scopes" in values:
|
|
271
|
+
config.scopes = set(values["scopes"])
|
|
272
|
+
if "sensitive_patterns" in values:
|
|
273
|
+
config.sensitive_patterns = list(values["sensitive_patterns"])
|
|
274
|
+
# extra_sensitive_patterns adds to whatever is in effect, so a project can
|
|
275
|
+
# extend the defaults without restating them.
|
|
276
|
+
if "extra_sensitive_patterns" in values:
|
|
277
|
+
config.sensitive_patterns = config.sensitive_patterns + list(
|
|
278
|
+
values["extra_sensitive_patterns"]
|
|
279
|
+
)
|
|
280
|
+
if "allowlist" in values:
|
|
281
|
+
config.allowlist = list(values["allowlist"])
|
|
282
|
+
if "skip_prefixes" in values:
|
|
283
|
+
config.skip_prefixes = [p.lower() for p in values["skip_prefixes"]]
|
|
284
|
+
|
|
285
|
+
for key in _BOOL_KEYS + _INT_KEYS + _FLOAT_KEYS:
|
|
286
|
+
if key in values:
|
|
287
|
+
setattr(config, key, values[key])
|
|
288
|
+
|
|
289
|
+
if config.require_scope and not config.scopes:
|
|
290
|
+
raise ConfigError(
|
|
291
|
+
"{0}: 'require_scope' is set but 'scopes' is empty".format(path)
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
config.sources.append(path)
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def load_config(
|
|
298
|
+
start_dir: Optional[str] = None,
|
|
299
|
+
config_path: Optional[str] = None,
|
|
300
|
+
use_files: bool = True,
|
|
301
|
+
) -> Config:
|
|
302
|
+
"""
|
|
303
|
+
Build a Config from discovered files.
|
|
304
|
+
|
|
305
|
+
``config_path`` names a single file explicitly and disables discovery; a
|
|
306
|
+
missing file is an error in that case, since the user asked for it by name.
|
|
307
|
+
``use_files=False`` returns the built-in defaults, for ``--no-config``.
|
|
308
|
+
"""
|
|
309
|
+
config = Config()
|
|
310
|
+
if not use_files:
|
|
311
|
+
return config
|
|
312
|
+
|
|
313
|
+
if config_path:
|
|
314
|
+
if not os.path.isfile(config_path):
|
|
315
|
+
raise ConfigError("configuration file not found: {0}".format(config_path))
|
|
316
|
+
paths = [os.path.abspath(config_path)]
|
|
317
|
+
else:
|
|
318
|
+
paths = find_config_files(start_dir)
|
|
319
|
+
|
|
320
|
+
for path in paths:
|
|
321
|
+
try:
|
|
322
|
+
with open(path, encoding="utf-8") as handle:
|
|
323
|
+
text = handle.read()
|
|
324
|
+
except OSError as exc:
|
|
325
|
+
raise ConfigError("cannot read {0}: {1}".format(path, exc)) from exc
|
|
326
|
+
|
|
327
|
+
try:
|
|
328
|
+
data = loads(text)
|
|
329
|
+
except TomlError as exc:
|
|
330
|
+
# A broken pyproject.toml is somebody else's problem unless the user
|
|
331
|
+
# pointed us at it; skipping keeps commit-guard usable meanwhile.
|
|
332
|
+
if os.path.basename(path) == PYPROJECT_FILENAME and not config_path:
|
|
333
|
+
continue
|
|
334
|
+
raise ConfigError("cannot parse {0}: {1}".format(path, exc)) from exc
|
|
335
|
+
|
|
336
|
+
section = _extract_table(data, path)
|
|
337
|
+
if section:
|
|
338
|
+
_apply(config, path, section)
|
|
339
|
+
|
|
340
|
+
return config
|
commit_guard/gitutil.py
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Git interaction helpers.
|
|
3
|
+
|
|
4
|
+
Every function here either returns a trustworthy answer or raises
|
|
5
|
+
:class:`GitError`. Callers must not treat a git failure as "nothing to check":
|
|
6
|
+
v0.1.0 did, which made the file checks silently pass whenever git was missing,
|
|
7
|
+
the repository was unusable, or the command changed behaviour.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
import subprocess
|
|
12
|
+
from typing import List, Optional
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"GitError",
|
|
16
|
+
"run_git",
|
|
17
|
+
"is_git_repo",
|
|
18
|
+
"git_dir",
|
|
19
|
+
"repo_root",
|
|
20
|
+
"staged_files",
|
|
21
|
+
"staged_blob_size",
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class GitError(Exception):
|
|
26
|
+
"""Raised when a git command cannot be run or fails."""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def run_git(args: List[str], cwd: Optional[str] = None) -> str:
|
|
30
|
+
"""Run a git command and return stdout, raising GitError on any failure."""
|
|
31
|
+
try:
|
|
32
|
+
completed = subprocess.run(
|
|
33
|
+
["git"] + args,
|
|
34
|
+
cwd=cwd,
|
|
35
|
+
capture_output=True,
|
|
36
|
+
check=False,
|
|
37
|
+
)
|
|
38
|
+
except FileNotFoundError as exc:
|
|
39
|
+
raise GitError("git executable not found on PATH") from exc
|
|
40
|
+
except OSError as exc:
|
|
41
|
+
raise GitError("cannot run git: {0}".format(exc)) from exc
|
|
42
|
+
|
|
43
|
+
if completed.returncode != 0:
|
|
44
|
+
detail = completed.stderr.decode("utf-8", "replace").strip()
|
|
45
|
+
raise GitError(
|
|
46
|
+
"git {0} failed (exit {1}){2}".format(
|
|
47
|
+
" ".join(args),
|
|
48
|
+
completed.returncode,
|
|
49
|
+
": " + detail if detail else "",
|
|
50
|
+
)
|
|
51
|
+
)
|
|
52
|
+
return completed.stdout.decode("utf-8", "replace")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def is_git_repo(cwd: Optional[str] = None) -> bool:
|
|
56
|
+
"""Report whether cwd is inside a git working tree."""
|
|
57
|
+
try:
|
|
58
|
+
return run_git(["rev-parse", "--is-inside-work-tree"], cwd).strip() == "true"
|
|
59
|
+
except GitError:
|
|
60
|
+
return False
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def git_dir(cwd: Optional[str] = None) -> str:
|
|
64
|
+
"""Absolute path to the repository's .git directory."""
|
|
65
|
+
path = run_git(["rev-parse", "--absolute-git-dir"], cwd).strip()
|
|
66
|
+
if not path:
|
|
67
|
+
raise GitError("could not determine the .git directory")
|
|
68
|
+
return path
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def repo_root(cwd: Optional[str] = None) -> str:
|
|
72
|
+
"""Absolute path to the top level of the working tree."""
|
|
73
|
+
path = run_git(["rev-parse", "--show-toplevel"], cwd).strip()
|
|
74
|
+
if not path:
|
|
75
|
+
raise GitError("could not determine the repository root")
|
|
76
|
+
return os.path.normpath(path)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def staged_files(cwd: Optional[str] = None) -> List[str]:
|
|
80
|
+
"""
|
|
81
|
+
Paths staged for commit, added/copied/modified only.
|
|
82
|
+
|
|
83
|
+
Uses -z so that paths containing spaces, quotes or non-ASCII characters
|
|
84
|
+
survive intact; the default output quotes and escapes such names.
|
|
85
|
+
"""
|
|
86
|
+
out = run_git(["diff", "--cached", "--name-only", "--diff-filter=ACM", "-z"], cwd)
|
|
87
|
+
return [entry for entry in out.split("\0") if entry]
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def staged_blob_size(path: str, cwd: Optional[str] = None) -> Optional[int]:
|
|
91
|
+
"""
|
|
92
|
+
Size in bytes of the staged blob for ``path``.
|
|
93
|
+
|
|
94
|
+
This is the content git would actually commit, which is not necessarily
|
|
95
|
+
what is on disk: the working-tree file may have been modified or deleted
|
|
96
|
+
after ``git add``. Returns None when the path has no staged blob.
|
|
97
|
+
"""
|
|
98
|
+
spec = ":{0}".format(path.replace("\\", "/"))
|
|
99
|
+
try:
|
|
100
|
+
out = run_git(["cat-file", "-s", spec], cwd)
|
|
101
|
+
except GitError:
|
|
102
|
+
return None
|
|
103
|
+
try:
|
|
104
|
+
return int(out.strip())
|
|
105
|
+
except ValueError:
|
|
106
|
+
return None
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: commit-shield
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Fast, zero-dependency Git commit message and staged file linter.
|
|
5
|
+
Project-URL: Homepage, https://github.com/lui01212/commit-guard
|
|
6
|
+
Project-URL: Repository, https://github.com/lui01212/commit-guard.git
|
|
7
|
+
Project-URL: Issues, https://github.com/lui01212/commit-guard/issues
|
|
8
|
+
Author-email: lui01212 <luynbv@glotechvn.com>
|
|
9
|
+
License: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: commit,conventional-commits,developer-tools,git,linter,pre-commit,security
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Environment :: Console
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
+
Classifier: Operating System :: OS Independent
|
|
17
|
+
Classifier: Programming Language :: Python :: 3
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
23
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
24
|
+
Classifier: Topic :: Software Development :: Quality Assurance
|
|
25
|
+
Classifier: Topic :: Software Development :: Version Control :: Git
|
|
26
|
+
Requires-Python: >=3.8
|
|
27
|
+
Provides-Extra: dev
|
|
28
|
+
Requires-Dist: build; extra == 'dev'
|
|
29
|
+
Requires-Dist: mypy==1.11.2; extra == 'dev'
|
|
30
|
+
Requires-Dist: ruff==0.6.9; extra == 'dev'
|
|
31
|
+
Description-Content-Type: text/markdown
|
|
32
|
+
|
|
33
|
+
# commit-shield 🛡️
|
|
34
|
+
|
|
35
|
+
[](https://pypi.org/project/commit-shield/)
|
|
36
|
+
[](https://pypi.org/project/commit-shield/)
|
|
37
|
+
[](LICENSE)
|
|
38
|
+
[](https://github.com/lui01212/commit-guard/actions)
|
|
39
|
+
[](https://github.com/lui01212/commit-guard/issues?q=is%3Aissue+state%3Aopen+label%3A%22good+first+issue%22)
|
|
40
|
+
[](https://hacktoberfest.com/)
|
|
41
|
+
|
|
42
|
+
**Fast, zero-dependency Git commit message and staged file linter.**
|
|
43
|
+
|
|
44
|
+
Enforces [Conventional Commits](https://www.conventionalcommits.org/) standards and guards against accidental commits of secrets (`.env`, `.pem`, `id_rsa`) or oversized files (> 10MB).
|
|
45
|
+
|
|
46
|
+
---
|
|
47
|
+
|
|
48
|
+
## ⚡ Why commit-shield?
|
|
49
|
+
|
|
50
|
+
- **Zero dependencies:** Written in pure standard Python. Instant install, lightweight, no massive node_modules or heavy binary dependencies.
|
|
51
|
+
- **Fast:** Runs in milliseconds during `git commit`.
|
|
52
|
+
- **Pre-commit ready:** Seamless drop-in integration with the popular `pre-commit` framework.
|
|
53
|
+
- **Dual protection:** Validates both commit message formatting AND safeguards against accidentally committed credentials/blobs.
|
|
54
|
+
|
|
55
|
+
---
|
|
56
|
+
|
|
57
|
+
## 📦 Installation
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
pip install commit-shield
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Or install from source:
|
|
64
|
+
```bash
|
|
65
|
+
git clone https://github.com/lui01212/commit-guard.git
|
|
66
|
+
cd commit-guard
|
|
67
|
+
pip install -e .
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
---
|
|
71
|
+
|
|
72
|
+
## 🚀 Usage
|
|
73
|
+
|
|
74
|
+
### 1. Check Commit Messages
|
|
75
|
+
|
|
76
|
+
Validate commit message strings directly:
|
|
77
|
+
|
|
78
|
+
```bash
|
|
79
|
+
# Valid commit message -> exit code 0
|
|
80
|
+
commit-guard check-msg -m "feat(auth): add google oauth2 login provider"
|
|
81
|
+
|
|
82
|
+
# Invalid commit message -> exit code 1 with actionable errors
|
|
83
|
+
commit-guard check-msg -m "fixed stuff"
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Output:
|
|
87
|
+
```text
|
|
88
|
+
[commit-guard] Commit message validation failed:
|
|
89
|
+
- Header does not follow Conventional Commits format: '<type>(<scope>): <description>'.
|
|
90
|
+
Received: 'fixed stuff'
|
|
91
|
+
Allowed types: build, chore, ci, docs, feat, fix, perf, refactor, revert, style, test
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
### 2. Check Staged Files (Secrets & Large Blobs)
|
|
95
|
+
|
|
96
|
+
```bash
|
|
97
|
+
# Checks all currently staged files in git
|
|
98
|
+
commit-guard check-files --strict
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
### 3. One-Click Git Hook Setup (No pre-commit framework needed)
|
|
102
|
+
|
|
103
|
+
Install the hook directly into your local `.git/hooks/commit-msg`:
|
|
104
|
+
|
|
105
|
+
```bash
|
|
106
|
+
commit-guard install
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
---
|
|
110
|
+
|
|
111
|
+
## 🔧 Integration with `pre-commit`
|
|
112
|
+
|
|
113
|
+
Add this to your `.pre-commit-config.yaml`:
|
|
114
|
+
|
|
115
|
+
```yaml
|
|
116
|
+
repos:
|
|
117
|
+
- repo: https://github.com/lui01212/commit-guard
|
|
118
|
+
rev: v0.2.0
|
|
119
|
+
hooks:
|
|
120
|
+
- id: commit-guard-msg
|
|
121
|
+
- id: commit-guard-files
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
---
|
|
125
|
+
|
|
126
|
+
## ⚙️ Configuration
|
|
127
|
+
|
|
128
|
+
`commit-guard` works zero-config out of the box, but can be fully customized via `.commit-guard.toml` or `[tool.commit-guard]` in `pyproject.toml`:
|
|
129
|
+
|
|
130
|
+
```toml
|
|
131
|
+
# .commit-guard.toml
|
|
132
|
+
max_header_len = 72
|
|
133
|
+
require_scope = false
|
|
134
|
+
skip_merge_commits = true
|
|
135
|
+
max_size_mb = 10.0
|
|
136
|
+
|
|
137
|
+
# Add extra sensitive patterns to protect
|
|
138
|
+
extra_sensitive_patterns = ["*.secret", "*_token.json"]
|
|
139
|
+
|
|
140
|
+
# Allowlist false-positives
|
|
141
|
+
allowlist = ["*.example", "*.sample", "*.template"]
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
See [.commit-guard.toml.example](.commit-guard.toml.example) for all available options.
|
|
145
|
+
|
|
146
|
+
---
|
|
147
|
+
|
|
148
|
+
## 📋 Allowed Commit Types
|
|
149
|
+
|
|
150
|
+
| Type | Purpose |
|
|
151
|
+
| :--- | :--- |
|
|
152
|
+
| `feat` | A new feature |
|
|
153
|
+
| `fix` | A bug fix |
|
|
154
|
+
| `docs` | Documentation only changes |
|
|
155
|
+
| `style` | Formatting, missing semi-colons, white-space changes |
|
|
156
|
+
| `refactor` | Code restructuring without fixing bugs or adding features |
|
|
157
|
+
| `perf` | Performance improvement |
|
|
158
|
+
| `test` | Adding missing tests or correcting existing tests |
|
|
159
|
+
| `build` | Changes that affect the build system or dependencies |
|
|
160
|
+
| `ci` | Changes to CI configuration files and scripts |
|
|
161
|
+
| `chore` | Maintenance tasks, tooling updates |
|
|
162
|
+
| `revert` | Reverting a previous commit |
|
|
163
|
+
|
|
164
|
+
---
|
|
165
|
+
|
|
166
|
+
## 🤝 Contributing
|
|
167
|
+
|
|
168
|
+
Contributions are warmly welcomed! We have plenty of beginner-friendly tasks:
|
|
169
|
+
- Adding custom pattern checks.
|
|
170
|
+
- Expanding sensitive file extension detections.
|
|
171
|
+
- Adding localized error messages (Vietnamese, Spanish, etc.).
|
|
172
|
+
|
|
173
|
+
Please see [CONTRIBUTING.md](CONTRIBUTING.md) for details on how to get started.
|
|
174
|
+
|
|
175
|
+
---
|
|
176
|
+
|
|
177
|
+
## 📄 License
|
|
178
|
+
|
|
179
|
+
[MIT License](LICENSE) © 2026 lui01212
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
commit_guard/__init__.py,sha256=TZJxOCvu8aTIKoQClPZU-fen_hVfyKLoayOmMay3-8E,110
|
|
2
|
+
commit_guard/_toml.py,sha256=cbGmGwfJayHIrJu-CDzDMdacpS_Ys-QwpNouW8ZKKhw,7847
|
|
3
|
+
commit_guard/checkers.py,sha256=TOoXmNLGlm3bO6rjtC83pzUdEp7ib3uh2W9BEtLMLWM,9126
|
|
4
|
+
commit_guard/cli.py,sha256=cT9gzRfWsB4NfeZ1Cy7W6XD8ftZ-YgQf462Jmp71EbA,13061
|
|
5
|
+
commit_guard/config.py,sha256=aylDj6hTB58g4QJNLfxDNBYEGBY2iGgU86zJMpPsF2s,11102
|
|
6
|
+
commit_guard/gitutil.py,sha256=7db5sJ7llHk6mJLrcSQ9xetDJhhHvIopRDzMY5OMj8w,3351
|
|
7
|
+
commit_shield-0.2.0.dist-info/METADATA,sha256=jwp9ni46H7j9lct9Vk1smdJmhx7sgQjzmti-HamX06M,5891
|
|
8
|
+
commit_shield-0.2.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
9
|
+
commit_shield-0.2.0.dist-info/entry_points.txt,sha256=miu01XDEo2TnpI6yISlO1teLXGnR3nMrfBpkoiqqNkM,93
|
|
10
|
+
commit_shield-0.2.0.dist-info/licenses/LICENSE,sha256=RWxza2uvDjCv6Rh2BuAIA6Y_trYT1WF7EZ5fUFYldWc,1065
|
|
11
|
+
commit_shield-0.2.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 lui01212
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|