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/cli.py ADDED
@@ -0,0 +1,401 @@
1
+ """
2
+ CLI entry-point for commit-guard.
3
+ Supports standalone usage, git hook usage, and pre-commit framework integration.
4
+ """
5
+
6
+ import argparse
7
+ import os
8
+ import stat
9
+ import sys
10
+ from typing import List, Optional
11
+
12
+ from commit_guard import __version__
13
+ from commit_guard.checkers import check_commit_message, check_file_path
14
+ from commit_guard.config import Config, ConfigError, load_config
15
+ from commit_guard.gitutil import (
16
+ GitError,
17
+ git_dir,
18
+ is_git_repo,
19
+ staged_blob_size,
20
+ staged_files,
21
+ )
22
+
23
+ EXIT_OK = 0
24
+ EXIT_FAIL = 1
25
+ # Distinct from a validation failure so scripts can tell "the commit is bad"
26
+ # apart from "commit-guard could not do its job".
27
+ EXIT_ERROR = 2
28
+
29
+ HOOK_MARKER = "# commit-guard automatic hook"
30
+
31
+
32
+ def _resolve_config(args: argparse.Namespace) -> Config:
33
+ """Load configuration, honouring --config and --no-config."""
34
+ return load_config(
35
+ config_path=getattr(args, "config", None),
36
+ use_files=not getattr(args, "no_config", False),
37
+ )
38
+
39
+
40
+ def _fail(message: str) -> int:
41
+ print("[commit-guard] Error: {0}".format(message), file=sys.stderr)
42
+ return EXIT_ERROR
43
+
44
+
45
+ def cmd_check_msg(args: argparse.Namespace) -> int:
46
+ """Validate a commit message file or text argument."""
47
+ try:
48
+ config = _resolve_config(args)
49
+ except ConfigError as exc:
50
+ return _fail(str(exc))
51
+
52
+ if args.file:
53
+ if not os.path.exists(args.file):
54
+ return _fail("commit message file not found: {0}".format(args.file))
55
+ try:
56
+ with open(args.file, encoding="utf-8", errors="replace") as handle:
57
+ msg = handle.read()
58
+ except OSError as exc:
59
+ return _fail("cannot read {0}: {1}".format(args.file, exc))
60
+ elif args.message is not None:
61
+ msg = args.message
62
+ else:
63
+ msg = sys.stdin.read()
64
+
65
+ is_valid, errors = check_commit_message(
66
+ msg, max_header_len=args.max_header_len, config=config
67
+ )
68
+
69
+ if is_valid:
70
+ if not args.quiet:
71
+ print("[commit-guard] Commit message valid. [OK]")
72
+ return EXIT_OK
73
+
74
+ print("\n[commit-guard] Commit message validation failed:", file=sys.stderr)
75
+ for err in errors:
76
+ print(" - {0}".format(err), file=sys.stderr)
77
+ print(
78
+ "\nGuidelines: Format must be '<type>(<optional-scope>): <description>'",
79
+ file=sys.stderr,
80
+ )
81
+ print("Example: feat(auth): add google oauth2 login provider\n", file=sys.stderr)
82
+ return EXIT_FAIL
83
+
84
+
85
+ def cmd_check_files(args: argparse.Namespace) -> int:
86
+ """Validate files for size and sensitive patterns."""
87
+ try:
88
+ config = _resolve_config(args)
89
+ except ConfigError as exc:
90
+ return _fail(str(exc))
91
+
92
+ from_index = False
93
+ if args.files:
94
+ files_to_check: List[str] = list(args.files)
95
+ else:
96
+ # No paths given, so ask git what is staged. A git failure here must
97
+ # not be mistaken for an empty changeset.
98
+ try:
99
+ files_to_check = staged_files()
100
+ from_index = True
101
+ except GitError as exc:
102
+ return _fail("{0}\nRun with explicit file paths to skip git.".format(exc))
103
+
104
+ if not files_to_check:
105
+ if not args.quiet:
106
+ print("[commit-guard] No staged files to check.")
107
+ return EXIT_OK
108
+
109
+ has_issues = False
110
+ for path in files_to_check:
111
+ # Prefer the staged blob size: the working tree may have moved on.
112
+ size_bytes = staged_blob_size(path) if from_index else None
113
+ is_valid, issues = check_file_path(
114
+ path,
115
+ max_size_mb=args.max_size_mb,
116
+ config=config,
117
+ size_bytes=size_bytes,
118
+ )
119
+ if not is_valid:
120
+ has_issues = True
121
+ for issue in issues:
122
+ print("[commit-guard] Warning: {0}".format(issue), file=sys.stderr)
123
+
124
+ if has_issues and args.strict:
125
+ print(
126
+ "\n[commit-guard] Aborting commit due to sensitive or oversized "
127
+ "files (strict mode).",
128
+ file=sys.stderr,
129
+ )
130
+ return EXIT_FAIL
131
+
132
+ if not has_issues and not args.quiet:
133
+ print(
134
+ "[commit-guard] Checked {0} file(s). All clean! [OK]".format(
135
+ len(files_to_check)
136
+ )
137
+ )
138
+
139
+ return EXIT_OK
140
+
141
+
142
+ def _hook_script(command: str) -> str:
143
+ """Build a hook body that calls the interpreter commit-guard runs under."""
144
+ # sys.executable, not a bare "python": the hook must reach the interpreter
145
+ # commit-guard is installed into, which may not be first on PATH (or may
146
+ # not be called "python" at all).
147
+ interpreter = sys.executable.replace("\\", "/") or "python"
148
+ return (
149
+ "#!/usr/bin/env sh\n" "{marker}\n" '"{python}" -m commit_guard.cli {command}\n'
150
+ ).format(marker=HOOK_MARKER, python=interpreter, command=command)
151
+
152
+
153
+ def _is_our_hook(path: str) -> bool:
154
+ try:
155
+ with open(path, encoding="utf-8", errors="replace") as handle:
156
+ return HOOK_MARKER in handle.read()
157
+ except OSError:
158
+ return False
159
+
160
+
161
+ def _describe_foreign_hook(path: str) -> str:
162
+ """Name the framework owning an existing hook, for a clearer message."""
163
+ try:
164
+ with open(path, encoding="utf-8", errors="replace") as handle:
165
+ content = handle.read()
166
+ except OSError:
167
+ return "an existing hook"
168
+ lowered = content.lower()
169
+ if "pre-commit" in lowered and "generated by pre-commit" in lowered:
170
+ return "a pre-commit framework hook"
171
+ if "husky" in lowered:
172
+ return "a husky hook"
173
+ if "lefthook" in lowered:
174
+ return "a lefthook hook"
175
+ return "an existing hook"
176
+
177
+
178
+ def _install_one(
179
+ hooks_dir: str, name: str, command: str, force: bool, quiet: bool
180
+ ) -> int:
181
+ """Install a single hook, refusing to destroy anything already there."""
182
+ target = os.path.join(hooks_dir, name)
183
+ script = _hook_script(command)
184
+
185
+ if os.path.exists(target) and not _is_our_hook(target):
186
+ if not force:
187
+ print(
188
+ "[commit-guard] Refusing to overwrite {0} at {1}.\n"
189
+ " Re-run with --force to replace it (the original is kept as "
190
+ "{2}.bak),\n"
191
+ " or use the pre-commit framework instead.".format(
192
+ _describe_foreign_hook(target), target, name
193
+ ),
194
+ file=sys.stderr,
195
+ )
196
+ return EXIT_ERROR
197
+
198
+ backup = target + ".bak"
199
+ try:
200
+ # Replace an older backup rather than failing on Windows, where
201
+ # os.replace onto an existing path is the only atomic option.
202
+ os.replace(target, backup)
203
+ except OSError as exc:
204
+ print(
205
+ "[commit-guard] Error: cannot back up {0}: {1}".format(target, exc),
206
+ file=sys.stderr,
207
+ )
208
+ return EXIT_ERROR
209
+ if not quiet:
210
+ print("[commit-guard] Existing hook backed up to {0}".format(backup))
211
+
212
+ try:
213
+ with open(target, "w", encoding="utf-8", newline="\n") as handle:
214
+ handle.write(script)
215
+ # Preserve any existing permission bits and add the execute bits.
216
+ mode = os.stat(target).st_mode
217
+ os.chmod(target, mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
218
+ except OSError as exc:
219
+ print(
220
+ "[commit-guard] Error: cannot write {0}: {1}".format(target, exc),
221
+ file=sys.stderr,
222
+ )
223
+ return EXIT_ERROR
224
+
225
+ if not quiet:
226
+ print("[commit-guard] Installed {0} hook: {1}".format(name, target))
227
+ return EXIT_OK
228
+
229
+
230
+ def cmd_install(args: argparse.Namespace) -> int:
231
+ """Install git hooks directly into .git/hooks/."""
232
+ if not is_git_repo():
233
+ return _fail("not inside a git working tree")
234
+
235
+ try:
236
+ hooks_dir = os.path.join(git_dir(), "hooks")
237
+ except GitError as exc:
238
+ return _fail(str(exc))
239
+
240
+ try:
241
+ os.makedirs(hooks_dir, exist_ok=True)
242
+ except OSError as exc:
243
+ return _fail("cannot create {0}: {1}".format(hooks_dir, exc))
244
+
245
+ # commit-msg validates the message; pre-commit guards the staged files.
246
+ planned = [("commit-msg", 'check-msg "$1"')]
247
+ if not args.msg_only:
248
+ planned.append(("pre-commit", "check-files --strict"))
249
+
250
+ status = EXIT_OK
251
+ for name, command in planned:
252
+ result = _install_one(hooks_dir, name, command, args.force, args.quiet)
253
+ if result != EXIT_OK:
254
+ status = result
255
+
256
+ if status == EXIT_OK and not args.quiet:
257
+ print("[commit-guard] Done. Run 'commit-guard uninstall' to remove.")
258
+ return status
259
+
260
+
261
+ def cmd_uninstall(args: argparse.Namespace) -> int:
262
+ """Remove commit-guard hooks, restoring any backup we made."""
263
+ if not is_git_repo():
264
+ return _fail("not inside a git working tree")
265
+
266
+ try:
267
+ hooks_dir = os.path.join(git_dir(), "hooks")
268
+ except GitError as exc:
269
+ return _fail(str(exc))
270
+
271
+ removed = 0
272
+ for name in ("commit-msg", "pre-commit"):
273
+ target = os.path.join(hooks_dir, name)
274
+ if not os.path.exists(target):
275
+ continue
276
+ if not _is_our_hook(target):
277
+ if not args.quiet:
278
+ print("[commit-guard] Leaving {0} alone (not ours).".format(target))
279
+ continue
280
+
281
+ backup = target + ".bak"
282
+ try:
283
+ if os.path.exists(backup):
284
+ os.replace(backup, target)
285
+ if not args.quiet:
286
+ print("[commit-guard] Restored previous hook: {0}".format(target))
287
+ else:
288
+ os.remove(target)
289
+ if not args.quiet:
290
+ print("[commit-guard] Removed hook: {0}".format(target))
291
+ removed += 1
292
+ except OSError as exc:
293
+ return _fail("cannot remove {0}: {1}".format(target, exc))
294
+
295
+ if removed == 0 and not args.quiet:
296
+ print("[commit-guard] No commit-guard hooks were installed.")
297
+ return EXIT_OK
298
+
299
+
300
+ def _add_config_flags(parser: argparse.ArgumentParser) -> None:
301
+ """Flags shared by every validating subcommand."""
302
+ parser.add_argument(
303
+ "--config", help="Path to a configuration file (disables discovery)"
304
+ )
305
+ parser.add_argument(
306
+ "--no-config",
307
+ action="store_true",
308
+ help="Ignore configuration files and use built-in defaults",
309
+ )
310
+ parser.add_argument(
311
+ "-q", "--quiet", action="store_true", help="Suppress success messages"
312
+ )
313
+
314
+
315
+ def build_parser() -> argparse.ArgumentParser:
316
+ parser = argparse.ArgumentParser(
317
+ prog="commit-guard",
318
+ description="Fast, zero-dependency Git commit message and staged file linter.",
319
+ )
320
+ parser.add_argument(
321
+ "--version", action="version", version="%(prog)s {0}".format(__version__)
322
+ )
323
+
324
+ subparsers = parser.add_subparsers(dest="subcommand", help="Available subcommands")
325
+
326
+ p_msg = subparsers.add_parser("check-msg", help="Validate commit message format")
327
+ p_msg.add_argument(
328
+ "file", nargs="?", help="Path to commit message file (e.g. .git/COMMIT_EDITMSG)"
329
+ )
330
+ p_msg.add_argument("-m", "--message", help="Commit message string directly")
331
+ p_msg.add_argument(
332
+ "--max-header-len",
333
+ type=int,
334
+ default=None,
335
+ help="Max header length (default: 72, or the configured value)",
336
+ )
337
+ _add_config_flags(p_msg)
338
+ p_msg.set_defaults(func=cmd_check_msg)
339
+
340
+ p_files = subparsers.add_parser(
341
+ "check-files", help="Validate staged files for size and secrets"
342
+ )
343
+ p_files.add_argument(
344
+ "files", nargs="*", help="File paths to inspect (defaults to git staged files)"
345
+ )
346
+ p_files.add_argument(
347
+ "--max-size-mb",
348
+ type=float,
349
+ default=None,
350
+ help="Max allowed file size in MB (default: 10, or the configured value)",
351
+ )
352
+ p_files.add_argument(
353
+ "--strict",
354
+ action="store_true",
355
+ help="Fail with exit 1 if sensitive files detected",
356
+ )
357
+ _add_config_flags(p_files)
358
+ p_files.set_defaults(func=cmd_check_files)
359
+
360
+ p_inst = subparsers.add_parser(
361
+ "install", help="Install hook scripts into local .git/hooks/"
362
+ )
363
+ p_inst.add_argument(
364
+ "--force",
365
+ action="store_true",
366
+ help="Replace a foreign hook, keeping a .bak copy",
367
+ )
368
+ p_inst.add_argument(
369
+ "--msg-only",
370
+ action="store_true",
371
+ help="Install only the commit-msg hook, not pre-commit",
372
+ )
373
+ p_inst.add_argument(
374
+ "-q", "--quiet", action="store_true", help="Suppress success messages"
375
+ )
376
+ p_inst.set_defaults(func=cmd_install)
377
+
378
+ p_uninst = subparsers.add_parser(
379
+ "uninstall", help="Remove commit-guard hooks from local .git/hooks/"
380
+ )
381
+ p_uninst.add_argument(
382
+ "-q", "--quiet", action="store_true", help="Suppress success messages"
383
+ )
384
+ p_uninst.set_defaults(func=cmd_uninstall)
385
+
386
+ return parser
387
+
388
+
389
+ def main(argv: Optional[List[str]] = None) -> int:
390
+ parser = build_parser()
391
+ args = parser.parse_args(argv)
392
+
393
+ if not args.subcommand:
394
+ parser.print_help()
395
+ return EXIT_OK
396
+
397
+ return args.func(args)
398
+
399
+
400
+ if __name__ == "__main__":
401
+ sys.exit(main())