commit-shield 0.2.0__py3-none-any.whl → 0.3.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 +11 -2
- commit_guard/cli.py +81 -6
- commit_guard/gitutil.py +9 -0
- commit_guard/secret_scanner.py +160 -0
- commit_guard/wizard.py +222 -0
- {commit_shield-0.2.0.dist-info → commit_shield-0.3.0.dist-info}/METADATA +1 -1
- commit_shield-0.3.0.dist-info/RECORD +13 -0
- commit_shield-0.2.0.dist-info/RECORD +0 -11
- {commit_shield-0.2.0.dist-info → commit_shield-0.3.0.dist-info}/WHEEL +0 -0
- {commit_shield-0.2.0.dist-info → commit_shield-0.3.0.dist-info}/entry_points.txt +0 -0
- {commit_shield-0.2.0.dist-info → commit_shield-0.3.0.dist-info}/licenses/LICENSE +0 -0
commit_guard/__init__.py
CHANGED
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
"""
|
|
2
|
-
commit-
|
|
2
|
+
commit-shield: Lightweight Git commit message, secret leak and staged file linter.
|
|
3
3
|
"""
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
from commit_guard.checkers import check_commit_message, check_file_path
|
|
6
|
+
from commit_guard.secret_scanner import scan_git_diff, scan_text
|
|
7
|
+
|
|
8
|
+
__version__ = "0.3.0"
|
|
9
|
+
__all__ = [
|
|
10
|
+
"check_commit_message",
|
|
11
|
+
"check_file_path",
|
|
12
|
+
"scan_git_diff",
|
|
13
|
+
"scan_text",
|
|
14
|
+
]
|
commit_guard/cli.py
CHANGED
|
@@ -4,6 +4,7 @@ Supports standalone usage, git hook usage, and pre-commit framework integration.
|
|
|
4
4
|
"""
|
|
5
5
|
|
|
6
6
|
import argparse
|
|
7
|
+
import json
|
|
7
8
|
import os
|
|
8
9
|
import stat
|
|
9
10
|
import sys
|
|
@@ -17,8 +18,11 @@ from commit_guard.gitutil import (
|
|
|
17
18
|
git_dir,
|
|
18
19
|
is_git_repo,
|
|
19
20
|
staged_blob_size,
|
|
21
|
+
staged_diff,
|
|
20
22
|
staged_files,
|
|
21
23
|
)
|
|
24
|
+
from commit_guard.secret_scanner import scan_git_diff
|
|
25
|
+
from commit_guard.wizard import run_wizard
|
|
22
26
|
|
|
23
27
|
EXIT_OK = 0
|
|
24
28
|
EXIT_FAIL = 1
|
|
@@ -66,6 +70,15 @@ def cmd_check_msg(args: argparse.Namespace) -> int:
|
|
|
66
70
|
msg, max_header_len=args.max_header_len, config=config
|
|
67
71
|
)
|
|
68
72
|
|
|
73
|
+
if getattr(args, "json", False):
|
|
74
|
+
res = {
|
|
75
|
+
"valid": is_valid,
|
|
76
|
+
"errors": errors,
|
|
77
|
+
"message": msg.strip(),
|
|
78
|
+
}
|
|
79
|
+
print(json.dumps(res, indent=2))
|
|
80
|
+
return EXIT_OK if is_valid else EXIT_FAIL
|
|
81
|
+
|
|
69
82
|
if is_valid:
|
|
70
83
|
if not args.quiet:
|
|
71
84
|
print("[commit-guard] Commit message valid. [OK]")
|
|
@@ -107,6 +120,7 @@ def cmd_check_files(args: argparse.Namespace) -> int:
|
|
|
107
120
|
return EXIT_OK
|
|
108
121
|
|
|
109
122
|
has_issues = False
|
|
123
|
+
all_issues: List[str] = []
|
|
110
124
|
for path in files_to_check:
|
|
111
125
|
# Prefer the staged blob size: the working tree may have moved on.
|
|
112
126
|
size_bytes = staged_blob_size(path) if from_index else None
|
|
@@ -118,12 +132,40 @@ def cmd_check_files(args: argparse.Namespace) -> int:
|
|
|
118
132
|
)
|
|
119
133
|
if not is_valid:
|
|
120
134
|
has_issues = True
|
|
121
|
-
|
|
122
|
-
|
|
135
|
+
all_issues.extend(issues)
|
|
136
|
+
if not getattr(args, "json", False):
|
|
137
|
+
for issue in issues:
|
|
138
|
+
print("[commit-guard] Warning: {0}".format(issue), file=sys.stderr)
|
|
139
|
+
|
|
140
|
+
# Content-level secret scan on staged git diff (inspired by git-secrets / gitleaks)
|
|
141
|
+
secret_issues: List[str] = []
|
|
142
|
+
if from_index and not getattr(args, "no_secrets", False):
|
|
143
|
+
diff_text = staged_diff()
|
|
144
|
+
if diff_text:
|
|
145
|
+
findings = scan_git_diff(diff_text)
|
|
146
|
+
for f in findings:
|
|
147
|
+
msg = f.format_message()
|
|
148
|
+
secret_issues.append(msg)
|
|
149
|
+
all_issues.append(msg)
|
|
150
|
+
has_issues = True
|
|
151
|
+
if not getattr(args, "json", False):
|
|
152
|
+
print(f"[commit-guard] Warning: {msg}", file=sys.stderr)
|
|
153
|
+
|
|
154
|
+
if getattr(args, "json", False):
|
|
155
|
+
payload = {
|
|
156
|
+
"valid": not has_issues,
|
|
157
|
+
"issues": all_issues,
|
|
158
|
+
"secret_issues": secret_issues,
|
|
159
|
+
"files_checked": len(files_to_check),
|
|
160
|
+
}
|
|
161
|
+
print(json.dumps(payload, indent=2))
|
|
162
|
+
if has_issues and args.strict:
|
|
163
|
+
return EXIT_FAIL
|
|
164
|
+
return EXIT_OK
|
|
123
165
|
|
|
124
166
|
if has_issues and args.strict:
|
|
125
167
|
print(
|
|
126
|
-
"\n[commit-guard] Aborting commit due to sensitive or oversized "
|
|
168
|
+
"\n[commit-guard] Aborting commit due to sensitive, secret or oversized "
|
|
127
169
|
"files (strict mode).",
|
|
128
170
|
file=sys.stderr,
|
|
129
171
|
)
|
|
@@ -297,6 +339,15 @@ def cmd_uninstall(args: argparse.Namespace) -> int:
|
|
|
297
339
|
return EXIT_OK
|
|
298
340
|
|
|
299
341
|
|
|
342
|
+
def cmd_wizard(args: argparse.Namespace) -> int:
|
|
343
|
+
"""Run interactive Conventional Commit wizard."""
|
|
344
|
+
try:
|
|
345
|
+
config = _resolve_config(args)
|
|
346
|
+
except ConfigError as exc:
|
|
347
|
+
return _fail(str(exc))
|
|
348
|
+
return run_wizard(config=config, dry_run=getattr(args, "dry_run", False))
|
|
349
|
+
|
|
350
|
+
|
|
300
351
|
def _add_config_flags(parser: argparse.ArgumentParser) -> None:
|
|
301
352
|
"""Flags shared by every validating subcommand."""
|
|
302
353
|
parser.add_argument(
|
|
@@ -310,12 +361,15 @@ def _add_config_flags(parser: argparse.ArgumentParser) -> None:
|
|
|
310
361
|
parser.add_argument(
|
|
311
362
|
"-q", "--quiet", action="store_true", help="Suppress success messages"
|
|
312
363
|
)
|
|
364
|
+
parser.add_argument(
|
|
365
|
+
"--json", action="store_true", help="Output machine-readable JSON results"
|
|
366
|
+
)
|
|
313
367
|
|
|
314
368
|
|
|
315
369
|
def build_parser() -> argparse.ArgumentParser:
|
|
316
370
|
parser = argparse.ArgumentParser(
|
|
317
371
|
prog="commit-guard",
|
|
318
|
-
description="Fast
|
|
372
|
+
description="Fast Git commit message, secret leak and staged file linter.",
|
|
319
373
|
)
|
|
320
374
|
parser.add_argument(
|
|
321
375
|
"--version", action="version", version="%(prog)s {0}".format(__version__)
|
|
@@ -323,6 +377,19 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
323
377
|
|
|
324
378
|
subparsers = parser.add_subparsers(dest="subcommand", help="Available subcommands")
|
|
325
379
|
|
|
380
|
+
# commit / cz (interactive wizard)
|
|
381
|
+
p_cz = subparsers.add_parser(
|
|
382
|
+
"commit", aliases=["cz"], help="Interactive Conventional Commit wizard"
|
|
383
|
+
)
|
|
384
|
+
p_cz.add_argument(
|
|
385
|
+
"--dry-run",
|
|
386
|
+
action="store_true",
|
|
387
|
+
help="Generate commit message without committing",
|
|
388
|
+
)
|
|
389
|
+
_add_config_flags(p_cz)
|
|
390
|
+
p_cz.set_defaults(func=cmd_wizard)
|
|
391
|
+
|
|
392
|
+
# check-msg
|
|
326
393
|
p_msg = subparsers.add_parser("check-msg", help="Validate commit message format")
|
|
327
394
|
p_msg.add_argument(
|
|
328
395
|
"file", nargs="?", help="Path to commit message file (e.g. .git/COMMIT_EDITMSG)"
|
|
@@ -337,8 +404,11 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
337
404
|
_add_config_flags(p_msg)
|
|
338
405
|
p_msg.set_defaults(func=cmd_check_msg)
|
|
339
406
|
|
|
407
|
+
# check-files / check-staged
|
|
340
408
|
p_files = subparsers.add_parser(
|
|
341
|
-
"check-files",
|
|
409
|
+
"check-files",
|
|
410
|
+
aliases=["check-staged"],
|
|
411
|
+
help="Validate staged files for size, sensitive patterns and secrets",
|
|
342
412
|
)
|
|
343
413
|
p_files.add_argument(
|
|
344
414
|
"files", nargs="*", help="File paths to inspect (defaults to git staged files)"
|
|
@@ -352,7 +422,12 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
352
422
|
p_files.add_argument(
|
|
353
423
|
"--strict",
|
|
354
424
|
action="store_true",
|
|
355
|
-
help="Fail with exit 1 if sensitive files detected",
|
|
425
|
+
help="Fail with exit 1 if sensitive files or secrets detected",
|
|
426
|
+
)
|
|
427
|
+
p_files.add_argument(
|
|
428
|
+
"--no-secrets",
|
|
429
|
+
action="store_true",
|
|
430
|
+
help="Skip scanning staged diffs for hardcoded credentials",
|
|
356
431
|
)
|
|
357
432
|
_add_config_flags(p_files)
|
|
358
433
|
p_files.set_defaults(func=cmd_check_files)
|
commit_guard/gitutil.py
CHANGED
|
@@ -19,6 +19,7 @@ __all__ = [
|
|
|
19
19
|
"repo_root",
|
|
20
20
|
"staged_files",
|
|
21
21
|
"staged_blob_size",
|
|
22
|
+
"staged_diff",
|
|
22
23
|
]
|
|
23
24
|
|
|
24
25
|
|
|
@@ -104,3 +105,11 @@ def staged_blob_size(path: str, cwd: Optional[str] = None) -> Optional[int]:
|
|
|
104
105
|
return int(out.strip())
|
|
105
106
|
except ValueError:
|
|
106
107
|
return None
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def staged_diff(cwd: Optional[str] = None) -> str:
|
|
111
|
+
"""Return unified diff of staged changes for secret scanning."""
|
|
112
|
+
try:
|
|
113
|
+
return run_git(["diff", "--cached", "-U0"], cwd)
|
|
114
|
+
except GitError:
|
|
115
|
+
return ""
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Content-level secret and credential scanner for git staged diffs and source files.
|
|
3
|
+
Inspired by AWS git-secrets and gitleaks.
|
|
4
|
+
Zero external dependencies.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import re
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from typing import List, Optional, Tuple
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class SecretFinding:
|
|
14
|
+
rule_name: str
|
|
15
|
+
file_path: str
|
|
16
|
+
line_number: int
|
|
17
|
+
secret_preview: str
|
|
18
|
+
line_content: str
|
|
19
|
+
|
|
20
|
+
def format_message(self) -> str:
|
|
21
|
+
return (
|
|
22
|
+
f"[SECRET LEAK] {self.file_path}:{self.line_number}: "
|
|
23
|
+
f"Detected {self.rule_name} ('{self.secret_preview}')"
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _mask_secret(secret: str) -> str:
|
|
28
|
+
"""Mask sensitive string leaving only small prefix and suffix visible."""
|
|
29
|
+
if len(secret) <= 8:
|
|
30
|
+
return "***"
|
|
31
|
+
return secret[:4] + "..." + secret[-4:]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
# Precompiled secret detection rules
|
|
35
|
+
SECRET_RULES: List[Tuple[str, re.Pattern]] = [
|
|
36
|
+
("AWS Access Key ID", re.compile(r"\b(?:AKIA|ABIA|ACCA|ASIA)[A-Z0-9]{16}\b")),
|
|
37
|
+
(
|
|
38
|
+
"GitHub Token",
|
|
39
|
+
re.compile(
|
|
40
|
+
r"\b(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{36}\b|\bgithub_pat_[A-Za-z0-9_]{82}\b"
|
|
41
|
+
),
|
|
42
|
+
),
|
|
43
|
+
("OpenAI API Key", re.compile(r"\bsk-[a-zA-Z0-9]{20,64}\b")),
|
|
44
|
+
("Anthropic API Key", re.compile(r"\bsk-ant-[a-zA-Z0-9_\-]{20,128}\b")),
|
|
45
|
+
("Slack Token", re.compile(r"\bxox[baprs](?:-[0-9a-zA-Z]{5,48})+\b")),
|
|
46
|
+
(
|
|
47
|
+
"Stripe Secret / Live Key",
|
|
48
|
+
re.compile(r"\b(?:sk|pk)_(?:test|live)_[0-9a-zA-Z]{24,34}\b"),
|
|
49
|
+
),
|
|
50
|
+
("Google Cloud API Key", re.compile(r"\bAIza[0-9A-Za-z\-_]{35}\b")),
|
|
51
|
+
(
|
|
52
|
+
"PEM Private Key Header",
|
|
53
|
+
re.compile(r"-----BEGIN (?:RSA |EC |DSA |OPENSSH )?PRIVATE KEY-----"),
|
|
54
|
+
),
|
|
55
|
+
(
|
|
56
|
+
"Generic Hardcoded Password / Secret",
|
|
57
|
+
re.compile(
|
|
58
|
+
r"""(?i)(?:[a-zA-Z0-9_\-\.]*(?:password|passwd|secret|api_key|apikey|access_token|auth_token))\s*[:=]\s*['"]([A-Za-z0-9_\-!@#$%^&*()+=]{8,})['"]"""
|
|
59
|
+
),
|
|
60
|
+
),
|
|
61
|
+
]
|
|
62
|
+
|
|
63
|
+
# Patterns that indicate false positives / test dummies / placeholders
|
|
64
|
+
ALLOWLIST_PATTERNS = [
|
|
65
|
+
re.compile(
|
|
66
|
+
r"(?i)(?:example|placeholder|dummy|test_fake|<YOUR_|fake_|YOUR_KEY|REPLACE_ME)"
|
|
67
|
+
),
|
|
68
|
+
re.compile(r"AKIAIOSFODNN7EXAMPLE"),
|
|
69
|
+
]
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _is_false_positive(line: str, matched_secret: str) -> bool:
|
|
73
|
+
"""Check if match appears to be an obvious mock/placeholder or comment."""
|
|
74
|
+
for pattern in ALLOWLIST_PATTERNS:
|
|
75
|
+
if pattern.search(line) or pattern.search(matched_secret):
|
|
76
|
+
return True
|
|
77
|
+
return False
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def scan_text(text: str, file_path: str = "<staged>") -> List[SecretFinding]:
|
|
81
|
+
"""Scan arbitrary text content line by line for secrets."""
|
|
82
|
+
findings: List[SecretFinding] = []
|
|
83
|
+
lines = text.splitlines()
|
|
84
|
+
|
|
85
|
+
for idx, line in enumerate(lines, start=1):
|
|
86
|
+
stripped = line.strip()
|
|
87
|
+
if not stripped:
|
|
88
|
+
continue
|
|
89
|
+
|
|
90
|
+
for rule_name, regex in SECRET_RULES:
|
|
91
|
+
match = regex.search(stripped)
|
|
92
|
+
if match:
|
|
93
|
+
matched_secret = match.group(0)
|
|
94
|
+
if not _is_false_positive(stripped, matched_secret):
|
|
95
|
+
findings.append(
|
|
96
|
+
SecretFinding(
|
|
97
|
+
rule_name=rule_name,
|
|
98
|
+
file_path=file_path,
|
|
99
|
+
line_number=idx,
|
|
100
|
+
secret_preview=_mask_secret(matched_secret),
|
|
101
|
+
line_content=stripped[:120],
|
|
102
|
+
)
|
|
103
|
+
)
|
|
104
|
+
break
|
|
105
|
+
|
|
106
|
+
return findings
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def scan_git_diff(diff_output: str) -> List[SecretFinding]:
|
|
110
|
+
"""
|
|
111
|
+
Parse unified git diff output (e.g. git diff --cached -U0) and scan only
|
|
112
|
+
added lines ('+...') for committed secrets.
|
|
113
|
+
"""
|
|
114
|
+
findings: List[SecretFinding] = []
|
|
115
|
+
current_file: Optional[str] = None
|
|
116
|
+
current_line_num: int = 0
|
|
117
|
+
|
|
118
|
+
diff_file_re = re.compile(r"^\+\+\+\s+b/(.*)$")
|
|
119
|
+
hunk_header_re = re.compile(r"^@@\s+-\d+(?:,\d+)?\s+\+(\d+)(?:,\d+)?\s+@@")
|
|
120
|
+
|
|
121
|
+
for raw_line in diff_output.splitlines():
|
|
122
|
+
# Check for new file header: +++ b/path/to/file
|
|
123
|
+
file_match = diff_file_re.match(raw_line)
|
|
124
|
+
if file_match:
|
|
125
|
+
current_file = file_match.group(1).strip()
|
|
126
|
+
continue
|
|
127
|
+
|
|
128
|
+
# Check for hunk header: @@ -1,1 +10,1 @@
|
|
129
|
+
hunk_match = hunk_header_re.match(raw_line)
|
|
130
|
+
if hunk_match:
|
|
131
|
+
current_line_num = int(hunk_match.group(1))
|
|
132
|
+
continue
|
|
133
|
+
|
|
134
|
+
# Check for added lines
|
|
135
|
+
if raw_line.startswith("+") and not raw_line.startswith("+++"):
|
|
136
|
+
added_content = raw_line[1:] # strip leading '+'
|
|
137
|
+
file_name = current_file or "<unknown>"
|
|
138
|
+
|
|
139
|
+
# Scan the line for secrets
|
|
140
|
+
for rule_name, regex in SECRET_RULES:
|
|
141
|
+
match = regex.search(added_content)
|
|
142
|
+
if match:
|
|
143
|
+
matched_secret = match.group(0)
|
|
144
|
+
if not _is_false_positive(added_content, matched_secret):
|
|
145
|
+
findings.append(
|
|
146
|
+
SecretFinding(
|
|
147
|
+
rule_name=rule_name,
|
|
148
|
+
file_path=file_name,
|
|
149
|
+
line_number=current_line_num,
|
|
150
|
+
secret_preview=_mask_secret(matched_secret),
|
|
151
|
+
line_content=added_content.strip()[:120],
|
|
152
|
+
)
|
|
153
|
+
)
|
|
154
|
+
break
|
|
155
|
+
|
|
156
|
+
current_line_num += 1
|
|
157
|
+
elif not raw_line.startswith("-"):
|
|
158
|
+
current_line_num += 1
|
|
159
|
+
|
|
160
|
+
return findings
|
commit_guard/wizard.py
ADDED
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Interactive commit wizard for writing Conventional Commits.
|
|
3
|
+
Inspired by Commitizen (cz commit) with zero external dependencies.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import sys
|
|
7
|
+
from typing import List, Optional, Tuple
|
|
8
|
+
|
|
9
|
+
from commit_guard.checkers import check_commit_message
|
|
10
|
+
from commit_guard.config import Config
|
|
11
|
+
from commit_guard.gitutil import run_git
|
|
12
|
+
|
|
13
|
+
COMMIT_TYPES: List[Tuple[str, str]] = [
|
|
14
|
+
("feat", "A new feature for the user or system"),
|
|
15
|
+
("fix", "A bug fix"),
|
|
16
|
+
("docs", "Documentation only changes"),
|
|
17
|
+
("style", "Code style & formatting changes (no logic changes)"),
|
|
18
|
+
("refactor", "Code refactoring (neither fixes a bug nor adds a feature)"),
|
|
19
|
+
("perf", "A code change that improves performance"),
|
|
20
|
+
("test", "Adding or updating unit/integration tests"),
|
|
21
|
+
("build", "Changes that affect the build system or packaging"),
|
|
22
|
+
("ci", "Changes to CI/CD configuration files and scripts"),
|
|
23
|
+
("chore", "Routine maintenance tasks, dependencies, tooling"),
|
|
24
|
+
("revert", "Reverts a previous commit"),
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def prompt_choice(prompt: str, choices: List[Tuple[str, str]]) -> str:
|
|
29
|
+
"""Display a numbered menu and prompt the user to select one."""
|
|
30
|
+
print(f"\n{prompt}\n")
|
|
31
|
+
for idx, (code, desc) in enumerate(choices, start=1):
|
|
32
|
+
print(f" [{idx:2d}] {code:10s} - {desc}")
|
|
33
|
+
print()
|
|
34
|
+
|
|
35
|
+
while True:
|
|
36
|
+
try:
|
|
37
|
+
raw = input("Select type (1-11): ").strip()
|
|
38
|
+
except (EOFError, KeyboardInterrupt):
|
|
39
|
+
print("\nAborted.")
|
|
40
|
+
sys.exit(1)
|
|
41
|
+
|
|
42
|
+
if raw.isdigit():
|
|
43
|
+
val = int(raw)
|
|
44
|
+
if 1 <= val <= len(choices):
|
|
45
|
+
return choices[val - 1][0]
|
|
46
|
+
else:
|
|
47
|
+
# User typed the code name directly
|
|
48
|
+
matching = [c for c, _ in choices if c == raw.lower()]
|
|
49
|
+
if matching:
|
|
50
|
+
return matching[0]
|
|
51
|
+
|
|
52
|
+
print(f"Invalid selection. Please enter a number between 1 and {len(choices)}.")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def prompt_text(prompt: str, required: bool = False, default: str = "") -> str:
|
|
56
|
+
"""Prompt the user for a text input."""
|
|
57
|
+
suffix = f" (default: {default})" if default else ""
|
|
58
|
+
suffix += " [required]" if required else " [optional, Enter to skip]"
|
|
59
|
+
|
|
60
|
+
while True:
|
|
61
|
+
try:
|
|
62
|
+
val = input(f"{prompt}{suffix}: ").strip()
|
|
63
|
+
except (EOFError, KeyboardInterrupt):
|
|
64
|
+
print("\nAborted.")
|
|
65
|
+
sys.exit(1)
|
|
66
|
+
|
|
67
|
+
if not val and default:
|
|
68
|
+
return default
|
|
69
|
+
if not val and required:
|
|
70
|
+
print("This field is required. Please provide a description.")
|
|
71
|
+
continue
|
|
72
|
+
return val
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def prompt_yes_no(prompt: str, default: bool = False) -> bool:
|
|
76
|
+
"""Prompt for a yes/no question."""
|
|
77
|
+
hint = "[y/N]" if not default else "[Y/n]"
|
|
78
|
+
while True:
|
|
79
|
+
try:
|
|
80
|
+
ans = input(f"{prompt} {hint}: ").strip().lower()
|
|
81
|
+
except (EOFError, KeyboardInterrupt):
|
|
82
|
+
print("\nAborted.")
|
|
83
|
+
sys.exit(1)
|
|
84
|
+
|
|
85
|
+
if not ans:
|
|
86
|
+
return default
|
|
87
|
+
if ans in ("y", "yes"):
|
|
88
|
+
return True
|
|
89
|
+
if ans in ("n", "no"):
|
|
90
|
+
return False
|
|
91
|
+
print("Please answer 'y' or 'n'.")
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def compose_commit_message(
|
|
95
|
+
commit_type: str,
|
|
96
|
+
scope: str,
|
|
97
|
+
subject: str,
|
|
98
|
+
body: str = "",
|
|
99
|
+
is_breaking: bool = False,
|
|
100
|
+
breaking_description: str = "",
|
|
101
|
+
issues_closed: str = "",
|
|
102
|
+
) -> str:
|
|
103
|
+
"""Format components into a standard Conventional Commit string."""
|
|
104
|
+
breaking_mark = "!" if is_breaking else ""
|
|
105
|
+
scope_part = f"({scope})" if scope else ""
|
|
106
|
+
|
|
107
|
+
# Strip any trailing period from subject
|
|
108
|
+
subject_clean = subject.rstrip(".")
|
|
109
|
+
|
|
110
|
+
header = f"{commit_type}{scope_part}{breaking_mark}: {subject_clean}"
|
|
111
|
+
sections = [header]
|
|
112
|
+
|
|
113
|
+
if body.strip():
|
|
114
|
+
sections.append(body.strip())
|
|
115
|
+
|
|
116
|
+
footer_items = []
|
|
117
|
+
if is_breaking and breaking_description.strip():
|
|
118
|
+
footer_items.append(f"BREAKING CHANGE: {breaking_description.strip()}")
|
|
119
|
+
|
|
120
|
+
if issues_closed.strip():
|
|
121
|
+
for item in issues_closed.split(","):
|
|
122
|
+
clean_item = item.strip()
|
|
123
|
+
if clean_item:
|
|
124
|
+
if not clean_item.lower().startswith(("closes", "fixes", "resolves")):
|
|
125
|
+
clean_item = f"Closes {clean_item}"
|
|
126
|
+
footer_items.append(clean_item)
|
|
127
|
+
|
|
128
|
+
if footer_items:
|
|
129
|
+
sections.append("\n".join(footer_items))
|
|
130
|
+
|
|
131
|
+
return "\n\n".join(sections)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def run_wizard(config: Optional[Config] = None, dry_run: bool = False) -> int:
|
|
135
|
+
"""
|
|
136
|
+
Run interactive wizard in terminal to construct and execute a Conventional Commit.
|
|
137
|
+
"""
|
|
138
|
+
print("\n" + "=" * 60)
|
|
139
|
+
print("🛡️ commit-shield: Interactive Conventional Commit Wizard")
|
|
140
|
+
print("=" * 60)
|
|
141
|
+
|
|
142
|
+
# 1. Type
|
|
143
|
+
commit_type = prompt_choice(
|
|
144
|
+
"Select the type of change you are committing:", COMMIT_TYPES
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
# 2. Scope
|
|
148
|
+
scope = prompt_text("Scope of this change (e.g. auth, api, cli, config)")
|
|
149
|
+
|
|
150
|
+
# 3. Subject
|
|
151
|
+
subject = prompt_text(
|
|
152
|
+
"Short imperative description (e.g. add google login)", required=True
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
# 4. Long Body
|
|
156
|
+
print("\nDetailed body description (optional). Press Enter twice to finish:")
|
|
157
|
+
body_lines: List[str] = []
|
|
158
|
+
while True:
|
|
159
|
+
try:
|
|
160
|
+
line = input()
|
|
161
|
+
except (EOFError, KeyboardInterrupt):
|
|
162
|
+
break
|
|
163
|
+
if not line and (not body_lines or not body_lines[-1]):
|
|
164
|
+
break
|
|
165
|
+
body_lines.append(line)
|
|
166
|
+
body = "\n".join(body_lines).strip()
|
|
167
|
+
|
|
168
|
+
# 5. Breaking change
|
|
169
|
+
is_breaking = prompt_yes_no(
|
|
170
|
+
"Are there any BREAKING CHANGES in this commit?", default=False
|
|
171
|
+
)
|
|
172
|
+
breaking_desc = ""
|
|
173
|
+
if is_breaking:
|
|
174
|
+
breaking_desc = prompt_text("Describe the breaking changes", required=True)
|
|
175
|
+
|
|
176
|
+
# 6. Issue reference
|
|
177
|
+
issues = prompt_text("Issues closed by this commit (e.g. #123, PROJ-45)")
|
|
178
|
+
|
|
179
|
+
# Compose
|
|
180
|
+
commit_msg = compose_commit_message(
|
|
181
|
+
commit_type=commit_type,
|
|
182
|
+
scope=scope,
|
|
183
|
+
subject=subject,
|
|
184
|
+
body=body,
|
|
185
|
+
is_breaking=is_breaking,
|
|
186
|
+
breaking_description=breaking_desc,
|
|
187
|
+
issues_closed=issues,
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
# Validate composed message
|
|
191
|
+
valid, errors = check_commit_message(commit_msg, config=config)
|
|
192
|
+
print("\n" + "-" * 60)
|
|
193
|
+
print("Generated Commit Message:")
|
|
194
|
+
print("-" * 60)
|
|
195
|
+
print(commit_msg)
|
|
196
|
+
print("-" * 60)
|
|
197
|
+
|
|
198
|
+
if not valid:
|
|
199
|
+
print("\n❌ Warning: Composed message failed validation:", file=sys.stderr)
|
|
200
|
+
for err in errors:
|
|
201
|
+
print(f" - {err}", file=sys.stderr)
|
|
202
|
+
print()
|
|
203
|
+
|
|
204
|
+
if dry_run:
|
|
205
|
+
print("[Dry Run] Commit message generated. Not committing.")
|
|
206
|
+
return 0
|
|
207
|
+
|
|
208
|
+
# Confirmation
|
|
209
|
+
confirm = prompt_yes_no(
|
|
210
|
+
"Proceed to commit staged changes with this message?", default=True
|
|
211
|
+
)
|
|
212
|
+
if not confirm:
|
|
213
|
+
print("Commit aborted.")
|
|
214
|
+
return 0
|
|
215
|
+
|
|
216
|
+
try:
|
|
217
|
+
run_git(["commit", "-m", commit_msg])
|
|
218
|
+
print("✅ Commit created successfully!")
|
|
219
|
+
return 0
|
|
220
|
+
except Exception as e:
|
|
221
|
+
print(f"❌ Git commit failed: {e}", file=sys.stderr)
|
|
222
|
+
return 1
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: commit-shield
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Fast, zero-dependency Git commit message and staged file linter.
|
|
5
5
|
Project-URL: Homepage, https://github.com/lui01212/commit-guard
|
|
6
6
|
Project-URL: Repository, https://github.com/lui01212/commit-guard.git
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
commit_guard/__init__.py,sha256=RtZCdXsua6NGZAfdSup7UQZ0-zL42lbZ8UUZhOjrUuw,355
|
|
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=j8vV5T0R49CfUZLC7rF5CBNtokw-xq8wv04aSPD_FtU,15593
|
|
5
|
+
commit_guard/config.py,sha256=aylDj6hTB58g4QJNLfxDNBYEGBY2iGgU86zJMpPsF2s,11102
|
|
6
|
+
commit_guard/gitutil.py,sha256=80W9MaX3CMxh6N_pZEZAAWhrwE5PFsWHoJZ84Mt0mOE,3597
|
|
7
|
+
commit_guard/secret_scanner.py,sha256=ugBw8I62NR9nQnjjCLMvKrKfHkYaC4fDRvFV-VmaoMc,5504
|
|
8
|
+
commit_guard/wizard.py,sha256=VJ5A2blJT81Sv0F-nmWR7TyDmeYYQlOcnTfCyqVuP94,6967
|
|
9
|
+
commit_shield-0.3.0.dist-info/METADATA,sha256=fmx7NF3IJshYyef71sDtcIspT0Ei9EPP1YTbbCqMcvw,5891
|
|
10
|
+
commit_shield-0.3.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
11
|
+
commit_shield-0.3.0.dist-info/entry_points.txt,sha256=miu01XDEo2TnpI6yISlO1teLXGnR3nMrfBpkoiqqNkM,93
|
|
12
|
+
commit_shield-0.3.0.dist-info/licenses/LICENSE,sha256=RWxza2uvDjCv6Rh2BuAIA6Y_trYT1WF7EZ5fUFYldWc,1065
|
|
13
|
+
commit_shield-0.3.0.dist-info/RECORD,,
|
|
@@ -1,11 +0,0 @@
|
|
|
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,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|