commit-shield 0.2.0__tar.gz → 0.3.0__tar.gz

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.
Files changed (29) hide show
  1. {commit_shield-0.2.0 → commit_shield-0.3.0}/PKG-INFO +1 -1
  2. commit_shield-0.3.0/commit_guard/__init__.py +14 -0
  3. {commit_shield-0.2.0 → commit_shield-0.3.0}/commit_guard/cli.py +81 -6
  4. {commit_shield-0.2.0 → commit_shield-0.3.0}/commit_guard/gitutil.py +9 -0
  5. commit_shield-0.3.0/commit_guard/secret_scanner.py +160 -0
  6. commit_shield-0.3.0/commit_guard/wizard.py +222 -0
  7. commit_shield-0.3.0/tests/test_secret_scanner.py +163 -0
  8. commit_shield-0.2.0/commit_guard/__init__.py +0 -5
  9. {commit_shield-0.2.0 → commit_shield-0.3.0}/.commit-guard.toml.example +0 -0
  10. {commit_shield-0.2.0 → commit_shield-0.3.0}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
  11. {commit_shield-0.2.0 → commit_shield-0.3.0}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
  12. {commit_shield-0.2.0 → commit_shield-0.3.0}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  13. {commit_shield-0.2.0 → commit_shield-0.3.0}/.github/workflows/ci.yml +0 -0
  14. {commit_shield-0.2.0 → commit_shield-0.3.0}/.github/workflows/release.yml +0 -0
  15. {commit_shield-0.2.0 → commit_shield-0.3.0}/.gitignore +0 -0
  16. {commit_shield-0.2.0 → commit_shield-0.3.0}/.pre-commit-hooks.yaml +0 -0
  17. {commit_shield-0.2.0 → commit_shield-0.3.0}/CHANGELOG.md +0 -0
  18. {commit_shield-0.2.0 → commit_shield-0.3.0}/CODE_OF_CONDUCT.md +0 -0
  19. {commit_shield-0.2.0 → commit_shield-0.3.0}/CONTRIBUTING.md +0 -0
  20. {commit_shield-0.2.0 → commit_shield-0.3.0}/LICENSE +0 -0
  21. {commit_shield-0.2.0 → commit_shield-0.3.0}/README.md +0 -0
  22. {commit_shield-0.2.0 → commit_shield-0.3.0}/commit_guard/_toml.py +0 -0
  23. {commit_shield-0.2.0 → commit_shield-0.3.0}/commit_guard/checkers.py +0 -0
  24. {commit_shield-0.2.0 → commit_shield-0.3.0}/commit_guard/config.py +0 -0
  25. {commit_shield-0.2.0 → commit_shield-0.3.0}/pyproject.toml +0 -0
  26. {commit_shield-0.2.0 → commit_shield-0.3.0}/tests/test_checkers.py +0 -0
  27. {commit_shield-0.2.0 → commit_shield-0.3.0}/tests/test_cli.py +0 -0
  28. {commit_shield-0.2.0 → commit_shield-0.3.0}/tests/test_config.py +0 -0
  29. {commit_shield-0.2.0 → commit_shield-0.3.0}/tests/test_toml.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: commit-shield
3
- Version: 0.2.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,14 @@
1
+ """
2
+ commit-shield: Lightweight Git commit message, secret leak and staged file linter.
3
+ """
4
+
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
+ ]
@@ -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
- for issue in issues:
122
- print("[commit-guard] Warning: {0}".format(issue), file=sys.stderr)
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, zero-dependency Git commit message and staged file linter.",
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", help="Validate staged files for size and secrets"
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)
@@ -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
@@ -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
@@ -0,0 +1,163 @@
1
+ """
2
+ Unit tests for secret_scanner and wizard in commit-shield.
3
+ Uses standard library unittest (zero external dependencies).
4
+ """
5
+
6
+ import unittest
7
+
8
+ from commit_guard.secret_scanner import _mask_secret, scan_git_diff, scan_text
9
+ from commit_guard.wizard import compose_commit_message
10
+
11
+
12
+ class TestSecretScanner(unittest.TestCase):
13
+ def test_mask_secret(self):
14
+ self.assertEqual(_mask_secret("1234"), "***")
15
+ masked = _mask_secret("sk-ant-1234567890abcdef")
16
+ self.assertTrue(masked.startswith("sk-a"))
17
+ self.assertTrue(masked.endswith("cdef"))
18
+ self.assertIn("...", masked)
19
+
20
+ def test_detect_aws_key(self):
21
+ text = "aws_key = 'AKIA1234567890ABCDEF'"
22
+ findings = scan_text(text)
23
+ self.assertEqual(len(findings), 1)
24
+ self.assertEqual(findings[0].rule_name, "AWS Access Key ID")
25
+
26
+ def test_detect_github_token(self):
27
+ # Construct exact 36 char token for matching
28
+ full_token = "ghp_" + ("a" * 36)
29
+ findings = scan_text(f"GH_TOKEN='{full_token}'")
30
+ self.assertEqual(len(findings), 1)
31
+ self.assertEqual(findings[0].rule_name, "GitHub Token")
32
+
33
+ def test_detect_openai_and_anthropic(self):
34
+ token_oa = "sk-" + ("a" * 25)
35
+ token_ant = "sk-ant-" + ("b" * 25)
36
+ findings = scan_text(f"OA = '{token_oa}'\nANT = '{token_ant}'")
37
+ self.assertEqual(len(findings), 2)
38
+ rule_names = {f.rule_name for f in findings}
39
+ self.assertIn("OpenAI API Key", rule_names)
40
+ self.assertIn("Anthropic API Key", rule_names)
41
+
42
+ def test_detect_pem_private_key(self):
43
+ pem = "-----BEGIN RSA PRIVATE KEY-----\nMIIE...\n-----END RSA PRIVATE KEY-----"
44
+ findings = scan_text(pem)
45
+ self.assertGreaterEqual(len(findings), 1)
46
+ self.assertEqual(findings[0].rule_name, "PEM Private Key Header")
47
+
48
+ def test_detect_hardcoded_password(self):
49
+ text = "database_password = 'MySecretP@ssw0rd!123'"
50
+ findings = scan_text(text)
51
+ self.assertEqual(len(findings), 1)
52
+ self.assertEqual(findings[0].rule_name, "Generic Hardcoded Password / Secret")
53
+
54
+ def test_false_positive_filtering(self):
55
+ text = (
56
+ "api_key = '<YOUR_API_KEY_HERE>'\n"
57
+ "password = 'example_dummy_password'\n"
58
+ "aws = 'AKIAIOSFODNN7EXAMPLE'" # Public doc example
59
+ )
60
+ findings = scan_text(text)
61
+ self.assertEqual(len(findings), 0)
62
+
63
+ def test_scan_git_diff_hunk(self):
64
+ fake_diff = (
65
+ "diff --git a/app.py b/app.py\n"
66
+ "--- a/app.py\n"
67
+ "+++ b/app.py\n"
68
+ "@@ -10,3 +10,4 @@\n"
69
+ " import os\n"
70
+ "+STRIPE_KEY = 'sk_live_' + '123456789012345678901234'\n"
71
+ "+# normal line\n"
72
+ "-old_line\n"
73
+ )
74
+ stripe_live = "sk_live_" + ("1" * 24)
75
+ sample_diff = fake_diff.replace(
76
+ "'sk_live_' + '123456789012345678901234'", f"'{stripe_live}'"
77
+ )
78
+ findings = scan_git_diff(sample_diff)
79
+ self.assertEqual(len(findings), 1)
80
+ self.assertEqual(findings[0].file_path, "app.py")
81
+ self.assertEqual(findings[0].rule_name, "Stripe Secret / Live Key")
82
+
83
+
84
+ class TestWizard(unittest.TestCase):
85
+ def test_compose_standard_message(self):
86
+ msg = compose_commit_message(
87
+ commit_type="feat",
88
+ scope="auth",
89
+ subject="add oauth2 login provider",
90
+ body="Implements Google OAuth2 provider using standard libraries.",
91
+ is_breaking=False,
92
+ issues_closed="#42",
93
+ )
94
+ expected = (
95
+ "feat(auth): add oauth2 login provider\n\n"
96
+ "Implements Google OAuth2 provider using standard libraries.\n\n"
97
+ "Closes #42"
98
+ )
99
+ self.assertEqual(msg, expected)
100
+
101
+ def test_compose_breaking_change_message(self):
102
+ msg = compose_commit_message(
103
+ commit_type="refactor",
104
+ scope="api",
105
+ subject="drop legacy v1 endpoints",
106
+ is_breaking=True,
107
+ breaking_description="v1 API endpoints have been removed in favor of v2.",
108
+ )
109
+ self.assertIn("refactor(api)!: drop legacy v1 endpoints", msg)
110
+ self.assertIn("BREAKING CHANGE: v1 API endpoints have been removed", msg)
111
+
112
+ def test_cli_json_check_msg_valid(self):
113
+ import io
114
+ import json
115
+ from contextlib import redirect_stdout
116
+
117
+ from commit_guard.cli import main
118
+
119
+ buf = io.StringIO()
120
+ with redirect_stdout(buf):
121
+ ret = main(["check-msg", "-m", "feat(cli): add json flag", "--json"])
122
+ self.assertEqual(ret, 0)
123
+ data = json.loads(buf.getvalue())
124
+ self.assertTrue(data["valid"])
125
+ self.assertEqual(data["errors"], [])
126
+
127
+ def test_cli_json_check_msg_invalid(self):
128
+ import io
129
+ import json
130
+ from contextlib import redirect_stdout
131
+
132
+ from commit_guard.cli import main
133
+
134
+ buf = io.StringIO()
135
+ with redirect_stdout(buf):
136
+ ret = main(["check-msg", "-m", "bad commit message", "--json"])
137
+ self.assertEqual(ret, 1)
138
+ data = json.loads(buf.getvalue())
139
+ self.assertFalse(data["valid"])
140
+ self.assertGreater(len(data["errors"]), 0)
141
+
142
+ def test_cli_check_staged_alias(self):
143
+ from commit_guard.cli import build_parser
144
+
145
+ parser = build_parser()
146
+ # Parse arguments without --help to avoid SystemExit
147
+ args = parser.parse_args(
148
+ ["check-staged", "file1.py", "--no-config", "--no-secrets"]
149
+ )
150
+ self.assertEqual(args.subcommand, "check-staged")
151
+ self.assertEqual(args.files, ["file1.py"])
152
+
153
+ def test_wizard_dry_run_cli(self):
154
+ from commit_guard.wizard import check_commit_message, compose_commit_message
155
+
156
+ msg = compose_commit_message("fix", "core", "resolve memory leak")
157
+ valid, errors = check_commit_message(msg)
158
+ self.assertTrue(valid)
159
+ self.assertEqual(errors, [])
160
+
161
+
162
+ if __name__ == "__main__":
163
+ unittest.main()
@@ -1,5 +0,0 @@
1
- """
2
- commit-guard: Lightweight, zero-dependency Git commit message and file linter.
3
- """
4
-
5
- __version__ = "0.2.0"
File without changes
File without changes
File without changes