git-a-grip 0.4.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.
git_a_grip/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """Personal pre-commit hooks."""
git_a_grip/audit.py ADDED
@@ -0,0 +1,288 @@
1
+ """Inventory the pre-commit configs of every repo under a directory tree.
2
+
3
+ Answers the question a single `.pre-commit-config.yaml` cannot: across all
4
+ the repos on this machine, which of *this* repo's hooks are actually in use
5
+ and at what rev, what other third-party hooks have accumulated, and which
6
+ `repo: local` hooks exist only in one project and nowhere else.
7
+
8
+ pre-commit-audit audit the sibling repos of this one
9
+ pre-commit-audit ~/projects ~/wk audit those trees instead
10
+ pre-commit-audit --json the same data, machine-readable
11
+
12
+ Repos with no config at all are listed too -- an unhooked repo is a finding,
13
+ not an absence of one.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import json
19
+ import subprocess
20
+ import sys
21
+ from collections import defaultdict
22
+ from dataclasses import dataclass, field
23
+ from pathlib import Path
24
+ from typing import Any
25
+
26
+ import yaml
27
+
28
+ CONFIG_NAME = '.pre-commit-config.yaml'
29
+ SELF_MARKER = 'git-a-grip'
30
+ # Directories that never contain a repo we care about but do contain
31
+ # thousands of files, including vendored copies of other repos' configs.
32
+ _PRUNED = {
33
+ '.git',
34
+ '.venv',
35
+ 'node_modules',
36
+ '__pycache__',
37
+ '.tox',
38
+ '.mypy_cache',
39
+ }
40
+
41
+ USAGE = """\
42
+ pre-commit-audit -- audit the pre-commit hooks of every local repo.
43
+
44
+ pre-commit-audit [PATH ...] trees to scan (default: this repo's parent)
45
+ pre-commit-audit --json emit JSON instead of a report
46
+ pre-commit-audit --help show this
47
+ """
48
+
49
+
50
+ @dataclass
51
+ class HookUse:
52
+ """One hook id, as used by one repo, from one source repo at one rev."""
53
+
54
+ repo: str
55
+ rev: str
56
+ hook_id: str
57
+ entry: str = ''
58
+
59
+
60
+ @dataclass
61
+ class RepoAudit:
62
+ """Everything one local repo declares."""
63
+
64
+ path: Path
65
+ has_config: bool = True
66
+ error: str = ''
67
+ uses: list[HookUse] = field(default_factory=list)
68
+
69
+ @property
70
+ def name(self) -> str:
71
+ """The repo's directory name."""
72
+ return self.path.name
73
+
74
+
75
+ def find_repos(roots: list[Path]) -> list[Path]:
76
+ """Return every git working tree under `roots`, not recursing into one."""
77
+ found: list[Path] = []
78
+ stack = [r.resolve() for r in roots]
79
+ while stack:
80
+ current = stack.pop()
81
+ if not current.is_dir() or current.name in _PRUNED:
82
+ continue
83
+ if (current / '.git').exists():
84
+ found.append(current)
85
+ continue # a repo's subdirectories are part of that repo
86
+ stack.extend(child for child in current.iterdir() if child.is_dir())
87
+ return sorted(found)
88
+
89
+
90
+ def _hook_uses(config: dict[str, Any]) -> list[HookUse]:
91
+ uses = []
92
+ for entry in config.get('repos') or []:
93
+ if not isinstance(entry, dict):
94
+ continue
95
+ source = str(entry.get('repo', '?'))
96
+ rev = str(entry.get('rev', '')) if entry.get('rev') else ''
97
+ for hook in entry.get('hooks') or []:
98
+ if not isinstance(hook, dict):
99
+ continue
100
+ uses.append(
101
+ HookUse(
102
+ repo=source,
103
+ rev=rev,
104
+ hook_id=str(hook.get('id', '?')),
105
+ entry=str(hook.get('entry', '')),
106
+ ),
107
+ )
108
+ return uses
109
+
110
+
111
+ def audit_repo(path: Path) -> RepoAudit:
112
+ """Read one repo's pre-commit config, tolerating a broken or absent one."""
113
+ config_path = path / CONFIG_NAME
114
+ if not config_path.is_file():
115
+ return RepoAudit(path=path, has_config=False)
116
+ try:
117
+ loaded = yaml.safe_load(config_path.read_text(encoding='utf-8'))
118
+ except (yaml.YAMLError, OSError, UnicodeDecodeError) as exc:
119
+ return RepoAudit(path=path, error=str(exc).splitlines()[0])
120
+ if not isinstance(loaded, dict):
121
+ return RepoAudit(path=path, error='config is not a mapping')
122
+ return RepoAudit(path=path, uses=_hook_uses(loaded))
123
+
124
+
125
+ def is_self(use: HookUse) -> bool:
126
+ """Whether this use comes from this project's hooks."""
127
+ return SELF_MARKER in use.repo
128
+
129
+
130
+ def is_local(use: HookUse) -> bool:
131
+ """Whether this use is a `repo: local` (or `meta`) hook."""
132
+ return use.repo in {'local', 'meta'}
133
+
134
+
135
+ def _group(
136
+ audits: list[RepoAudit],
137
+ predicate: Any, # noqa: ANN401 -- Callable[[HookUse], bool]
138
+ ) -> dict[str, list[tuple[RepoAudit, HookUse]]]:
139
+ grouped: dict[str, list[tuple[RepoAudit, HookUse]]] = defaultdict(list)
140
+ for audit in audits:
141
+ for use in audit.uses:
142
+ if predicate(use):
143
+ grouped[use.hook_id].append((audit, use))
144
+ return dict(sorted(grouped.items()))
145
+
146
+
147
+ def _section(title: str, lines: list[str]) -> list[str]:
148
+ return [title, '=' * len(title), *(lines or [' (none)']), '']
149
+
150
+
151
+ def _self_section(audits: list[RepoAudit]) -> list[str]:
152
+ lines = []
153
+ for hook_id, pairs in _group(audits, is_self).items():
154
+ lines.append(f' {hook_id}')
155
+ for audit, use in sorted(pairs, key=lambda p: p[0].name):
156
+ rev = use.rev or 'unpinned'
157
+ lines.append(f' {audit.name:<28} {rev}')
158
+ return _section(f'{SELF_MARKER} hooks in use', lines)
159
+
160
+
161
+ def _third_party_section(audits: list[RepoAudit]) -> list[str]:
162
+ by_source: dict[str, dict[str, set[str]]] = defaultdict(
163
+ lambda: defaultdict(set),
164
+ )
165
+ for audit in audits:
166
+ for use in audit.uses:
167
+ if is_self(use) or is_local(use):
168
+ continue
169
+ label = f'{use.hook_id} @ {use.rev or "unpinned"}'
170
+ by_source[use.repo][label].add(audit.name)
171
+ lines = []
172
+ for source, hooks in sorted(by_source.items()):
173
+ lines.append(f' {source}')
174
+ for label, users in sorted(hooks.items()):
175
+ lines.append(f' {label:<40} {", ".join(sorted(users))}')
176
+ return _section('Third-party hooks', lines)
177
+
178
+
179
+ def _local_section(audits: list[RepoAudit]) -> list[str]:
180
+ lines = []
181
+ for audit in audits:
182
+ local = [use for use in audit.uses if is_local(use)]
183
+ if not local:
184
+ continue
185
+ lines.append(f' {audit.name}')
186
+ for use in local:
187
+ entry = use.entry or '(no entry)'
188
+ lines.append(f' {use.hook_id:<24} {entry}')
189
+ return _section('Local hooks (defined in one repo only)', lines)
190
+
191
+
192
+ def _gaps_section(audits: list[RepoAudit]) -> list[str]:
193
+ lines = [
194
+ f' {audit.name:<28} {audit.error or "no " + CONFIG_NAME}'
195
+ for audit in audits
196
+ if not audit.has_config or audit.error
197
+ ]
198
+ return _section('Repos with no usable config', lines)
199
+
200
+
201
+ def render(audits: list[RepoAudit]) -> str:
202
+ """Render the whole audit as a plain-text report."""
203
+ hooked = sum(1 for a in audits if a.uses)
204
+ header = [
205
+ f'{len(audits)} repos scanned, {hooked} with pre-commit hooks.',
206
+ '',
207
+ ]
208
+ return '\n'.join(
209
+ [
210
+ *header,
211
+ *_self_section(audits),
212
+ *_third_party_section(audits),
213
+ *_local_section(audits),
214
+ *_gaps_section(audits),
215
+ ],
216
+ )
217
+
218
+
219
+ def as_json(audits: list[RepoAudit]) -> str:
220
+ """Render the whole audit as JSON."""
221
+ return json.dumps(
222
+ [
223
+ {
224
+ 'path': str(a.path),
225
+ 'name': a.name,
226
+ 'has_config': a.has_config,
227
+ 'error': a.error,
228
+ 'hooks': [
229
+ {
230
+ 'repo': u.repo,
231
+ 'rev': u.rev,
232
+ 'id': u.hook_id,
233
+ 'entry': u.entry,
234
+ 'source': (
235
+ 'self'
236
+ if is_self(u)
237
+ else 'local'
238
+ if is_local(u)
239
+ else 'third-party'
240
+ ),
241
+ }
242
+ for u in a.uses
243
+ ],
244
+ }
245
+ for a in audits
246
+ ],
247
+ indent=2,
248
+ )
249
+
250
+
251
+ def default_roots() -> list[Path]:
252
+ """The directory holding this repo -- i.e. its sibling projects."""
253
+ top = subprocess.run(
254
+ ['git', 'rev-parse', '--show-toplevel'], # noqa: S607
255
+ capture_output=True,
256
+ text=True,
257
+ check=False,
258
+ ).stdout.strip()
259
+ return [Path(top).parent if top else Path.cwd()]
260
+
261
+
262
+ def main(argv: list[str] | None = None) -> int:
263
+ """Scan, report, and return 0 unless the arguments make no sense."""
264
+ args = sys.argv[1:] if argv is None else argv
265
+ if '--help' in args or '-h' in args:
266
+ sys.stdout.write(USAGE)
267
+ return 0
268
+ want_json = '--json' in args
269
+ paths = [Path(a) for a in args if not a.startswith('-')]
270
+ missing = [p for p in paths if not p.is_dir()]
271
+ if missing:
272
+ sys.stderr.write(
273
+ f'audit: not a directory: {", ".join(str(p) for p in missing)}\n',
274
+ )
275
+ return 2
276
+
277
+ audits = [audit_repo(p) for p in find_repos(paths or default_roots())]
278
+ sys.stdout.write((as_json if want_json else render)(audits) + '\n')
279
+ return 0
280
+
281
+
282
+ def main_cli() -> None:
283
+ """Console-script entry point."""
284
+ raise SystemExit(main())
285
+
286
+
287
+ if __name__ == '__main__':
288
+ main_cli()
@@ -0,0 +1,153 @@
1
+ """Validate the commit message during the *pre-commit* stage, not commit-msg.
2
+
3
+ Git runs hooks in the order pre-commit -> prepare-commit-msg -> editor ->
4
+ commit-msg, so a `stages: [commit-msg]` commitizen hook can only ever reject
5
+ a bad message *after* the slow pre-commit hooks (tests, ruff, ...) have run.
6
+ No amount of hook ordering or `fail_fast` in .pre-commit-config.yaml changes
7
+ that -- the stages are separate git invocations.
8
+
9
+ When the message came from `git commit -m ...` (or `-F file`) it is already
10
+ sitting in git's argv while pre-commit runs, so we can fish it out of the
11
+ process tree and hand it to `cz check` immediately. Bad message => fail in
12
+ about a second instead of after the full suite.
13
+
14
+ If no message can be recovered (interactive editor, merge, rebase, ...) this
15
+ exits 0 and the real commit-msg hook does the checking as before.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import os
21
+ import sys
22
+ from pathlib import Path
23
+
24
+ from git_a_grip import cz
25
+
26
+ MAX_PARENT_DEPTH = 12
27
+ _SHORT_FLAG_MIN_LEN = 2
28
+
29
+
30
+ def _cmdline(pid: int) -> list[str] | None:
31
+ try:
32
+ raw = Path(f'/proc/{pid}/cmdline').read_bytes()
33
+ except OSError:
34
+ return None
35
+ return [a for a in raw.decode('utf-8', 'replace').split('\0') if a]
36
+
37
+
38
+ def _ppid(pid: int) -> int | None:
39
+ try:
40
+ stat = Path(f'/proc/{pid}/stat').read_text()
41
+ except OSError:
42
+ return None
43
+ # comm can contain spaces/parens, so parse after the final ')'.
44
+ try:
45
+ return int(stat[stat.rindex(')') + 1 :].split()[1])
46
+ except (ValueError, IndexError):
47
+ return None
48
+
49
+
50
+ def find_git_commit_argv() -> list[str] | None:
51
+ """Walk up process tree looking for the `git commit` that invoked us."""
52
+ pid: int | None = os.getppid()
53
+ for _ in range(MAX_PARENT_DEPTH):
54
+ if pid is None or pid <= 1:
55
+ return None
56
+ argv = _cmdline(pid)
57
+ if (
58
+ argv
59
+ and Path(argv[0]).name in {'git', 'git.exe'}
60
+ and 'commit' in argv
61
+ ):
62
+ return argv
63
+ pid = _ppid(pid)
64
+ return None
65
+
66
+
67
+ def _parse_flag_value(
68
+ arg: str,
69
+ args: list[str],
70
+ i: int,
71
+ ) -> tuple[str | None, bool, int]:
72
+ """Parse a flag/value pair. Returns (value, is_file, next_index)."""
73
+ value: str | None = None
74
+ is_file = False
75
+ if arg in {'-m', '--message'}:
76
+ value = args[i + 1] if i + 1 < len(args) else None
77
+ i += 1
78
+ elif arg.startswith('--message='):
79
+ value = arg.split('=', 1)[1]
80
+ elif (
81
+ arg.startswith('-m')
82
+ and len(arg) > _SHORT_FLAG_MIN_LEN
83
+ and not arg.startswith('--')
84
+ ):
85
+ value = arg[2:]
86
+ elif arg in {'-F', '--file'}:
87
+ value = args[i + 1] if i + 1 < len(args) else None
88
+ is_file = True
89
+ i += 1
90
+ elif arg.startswith('--file='):
91
+ value, is_file = arg.split('=', 1)[1], True
92
+ elif (
93
+ arg.startswith('-') and not arg.startswith('--') and arg.endswith('m')
94
+ ):
95
+ # Clustered short flags, e.g. `git commit -am "msg"`.
96
+ value = args[i + 1] if i + 1 < len(args) else None
97
+ i += 1
98
+ return value, is_file, i
99
+
100
+
101
+ def message_from_argv(argv: list[str]) -> str | None:
102
+ """Extract the -m/-F message from a `git commit` argv, if there is one."""
103
+ args = argv[argv.index('commit') + 1 :]
104
+ parts: list[str] = []
105
+ i = 0
106
+ while i < len(args):
107
+ arg = args[i]
108
+ if arg == '--':
109
+ break
110
+ if arg in {'-e', '--edit'}:
111
+ # Message will be edited after we run; let commit-msg judge it.
112
+ return None
113
+ value, is_file, i = _parse_flag_value(arg, args, i)
114
+ if value is not None:
115
+ if is_file:
116
+ if value == '-':
117
+ return None
118
+ try:
119
+ value = Path(value).read_text()
120
+ except OSError:
121
+ return None
122
+ parts.append(value)
123
+ i += 1
124
+ return '\n\n'.join(parts) if parts else None
125
+
126
+
127
+ def main() -> int:
128
+ """Check a recoverable commit message, or defer to the commit-msg hook."""
129
+ argv = find_git_commit_argv()
130
+ if argv is None:
131
+ return 0
132
+ message = message_from_argv(argv)
133
+ if message is None:
134
+ return 0
135
+ result = cz.run('check', '--message', message)
136
+ if result.returncode == 0:
137
+ return 0
138
+ sys.stdout.write(result.stdout)
139
+ sys.stderr.write(result.stderr)
140
+ sys.stderr.write(
141
+ '\nRejected before running the slow hooks. '
142
+ 'Fix the message and re-commit.\n',
143
+ )
144
+ return 1
145
+
146
+
147
+ def main_cli() -> None:
148
+ """Console-script entry point."""
149
+ raise SystemExit(main())
150
+
151
+
152
+ if __name__ == '__main__':
153
+ main_cli()
git_a_grip/cz.py ADDED
@@ -0,0 +1,23 @@
1
+ """Run commitizen out of this hook's own interpreter.
2
+
3
+ pre-commit installs this package into an isolated env with commitizen as a
4
+ declared dependency, so `sys.executable -m commitizen` always resolves. That
5
+ deliberately replaces per-repo invocations like `uv run cz` or
6
+ `uvx --from commitizen cz`, which differ by consumer and are the thing most
7
+ likely to break when a hook is copied between projects.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import subprocess
13
+ import sys
14
+
15
+
16
+ def run(*args: str) -> subprocess.CompletedProcess[str]:
17
+ """Invoke commitizen with `args`, capturing output."""
18
+ return subprocess.run( # noqa: S603
19
+ [sys.executable, '-m', 'commitizen', *args],
20
+ capture_output=True,
21
+ text=True,
22
+ check=False,
23
+ )
@@ -0,0 +1,92 @@
1
+ """Run the consuming repo's test suite as a hook, from the repo root.
2
+
3
+ Unlike the other hooks here, this one deliberately does *not* run inside the
4
+ env pre-commit built for this package: a test suite needs the consuming
5
+ project's own dependencies, which that isolated env will never have. So it
6
+ shells out to a runner that resolves the project's environment -- `uv run
7
+ pytest` by default -- and everything after the runner flags is handed
8
+ straight to pytest:
9
+
10
+ - id: pytest
11
+ args: [tests/, -q]
12
+
13
+ - id: pytest
14
+ args: ['--runner=uv run --extra api pytest', tests/, -q]
15
+
16
+ Two details that a hand-rolled `entry:` usually gets wrong: it runs from the
17
+ repo root rather than wherever git was invoked, and it drops the VIRTUAL_ENV
18
+ that pre-commit exports for its own hook env, which would otherwise point the
19
+ runner at an environment holding none of the project's dependencies.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import os
25
+ import subprocess
26
+ import sys
27
+
28
+ DEFAULT_RUNNER = 'uv run pytest'
29
+ _RUNNER_FLAG = '--runner='
30
+ # pre-commit exports these for the isolated env it built for *this* package.
31
+ # Leaking them into the runner would shadow the project's own environment.
32
+ _INHERITED_ENV_VARS = ('VIRTUAL_ENV', 'PYTHONHOME', 'PYTHONPATH')
33
+
34
+
35
+ def split_args(argv: list[str]) -> tuple[list[str], list[str]]:
36
+ """Split argv into the runner command and the arguments for pytest."""
37
+ runner = DEFAULT_RUNNER
38
+ rest: list[str] = []
39
+ for arg in argv:
40
+ if arg.startswith(_RUNNER_FLAG):
41
+ runner = arg[len(_RUNNER_FLAG) :]
42
+ else:
43
+ rest.append(arg)
44
+ return runner.split(), rest
45
+
46
+
47
+ def repo_root() -> str:
48
+ """Return the top level of the working tree."""
49
+ return subprocess.run(
50
+ ['git', 'rev-parse', '--show-toplevel'], # noqa: S607
51
+ capture_output=True,
52
+ text=True,
53
+ check=False,
54
+ ).stdout.strip()
55
+
56
+
57
+ def clean_env() -> dict[str, str]:
58
+ """Return the environment minus pre-commit's own venv pointers."""
59
+ env = dict(os.environ)
60
+ for name in _INHERITED_ENV_VARS:
61
+ env.pop(name, None)
62
+ return env
63
+
64
+
65
+ def main(argv: list[str]) -> int:
66
+ """Run the project's test suite, returning the runner's exit code."""
67
+ runner, pytest_args = split_args(argv)
68
+ if not runner:
69
+ sys.stderr.write('pytest: --runner is empty, nothing to run.\n')
70
+ return 1
71
+ try:
72
+ return subprocess.run( # noqa: S603
73
+ [*runner, *pytest_args],
74
+ cwd=repo_root() or None,
75
+ env=clean_env(),
76
+ check=False,
77
+ ).returncode
78
+ except FileNotFoundError:
79
+ sys.stderr.write(
80
+ f'pytest: cannot run {runner[0]!r} -- it is not on PATH. '
81
+ f'Install it, or set args: ["--runner=<command>", ...].\n',
82
+ )
83
+ return 1
84
+
85
+
86
+ def main_cli() -> None:
87
+ """Console-script entry point."""
88
+ raise SystemExit(main(sys.argv[1:]))
89
+
90
+
91
+ if __name__ == '__main__':
92
+ main_cli()
git_a_grip/release.py ADDED
@@ -0,0 +1,148 @@
1
+ """Bump, tag and push as one command -- the ordering pre-push cannot have.
2
+
3
+ This replaces a pre-push hook that did the same work (removed in v0.3.0).
4
+ There, git had already chosen which sha to push before any hook ran, so a
5
+ commit created afterwards could only end two ways: cancel the push, or let
6
+ git push the superseded sha and watch it rejected as a non-fast-forward. Both
7
+ print `error: failed to push some refs` over a release that succeeded.
8
+
9
+ Run as a command instead, the order is simply right: bump first, push second,
10
+ exit 0. Nothing to cancel, nothing to explain away. Point whatever alias you
11
+ use for pushing at this, and a release stays one gesture.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import subprocess
17
+ import sys
18
+
19
+ from git_a_grip import cz
20
+
21
+ # `cz bump` exit codes that mean "nothing to release", not "something broke".
22
+ NO_COMMITS_FOUND = 21
23
+ NONE_INCREMENT = 3
24
+ PROTECTED_DEFAULT = 'main'
25
+
26
+
27
+ def git(*args: str) -> subprocess.CompletedProcess[str]:
28
+ """Run git with `args`, capturing output."""
29
+ return subprocess.run( # noqa: S603
30
+ ['git', *args], # noqa: S607
31
+ capture_output=True,
32
+ text=True,
33
+ check=False,
34
+ )
35
+
36
+
37
+ def current_branch() -> str:
38
+ """Return the checked-out branch."""
39
+ return git('rev-parse', '--abbrev-ref', 'HEAD').stdout.strip()
40
+
41
+
42
+ def is_dirty() -> bool:
43
+ """Whether the working tree has uncommitted changes."""
44
+ return bool(git('status', '--porcelain').stdout.strip())
45
+
46
+
47
+ def _push(branch: str, *extra: str) -> int:
48
+ # --no-verify so any pre-push hook the repo still has wired cannot
49
+ # re-enter this.
50
+ pushed = git(
51
+ 'push',
52
+ '--no-verify',
53
+ '--follow-tags',
54
+ *extra,
55
+ 'origin',
56
+ branch,
57
+ )
58
+ if pushed.returncode != 0:
59
+ sys.stderr.write(pushed.stderr)
60
+ return pushed.returncode
61
+
62
+
63
+ USAGE = """\
64
+ git-release -- bump the version, tag it, and push, in that order.
65
+
66
+ git-release on main: bump, tag and push; elsewhere: just push
67
+ git-release --any-branch release from the current branch, whatever it is
68
+ git-release --help show this
69
+
70
+ Configure what the bump rewrites via [tool.commitizen] in the repo.
71
+ """
72
+
73
+
74
+ def check_args(args: list[str]) -> int | None:
75
+ """Return an exit code if the args mean "do not release", else None."""
76
+ if '--help' in args or '-h' in args:
77
+ sys.stdout.write(USAGE)
78
+ return 0
79
+ # This command publishes. An argument it does not understand may well be
80
+ # someone asking for something other than "release now", so refuse rather
81
+ # than ignore it and push.
82
+ unknown = [a for a in args if a != '--any-branch']
83
+ if unknown:
84
+ sys.stderr.write(
85
+ f'release: unrecognised argument(s): {" ".join(unknown)}\n\n',
86
+ )
87
+ sys.stderr.write(USAGE)
88
+ return 2
89
+ return None
90
+
91
+
92
+ def main(argv: list[str] | None = None) -> int:
93
+ """Bump if there is anything to release, then push. 0 means done."""
94
+ args = sys.argv[1:] if argv is None else argv
95
+
96
+ refused = check_args(args)
97
+ if refused is not None:
98
+ return refused
99
+
100
+ branch = current_branch()
101
+ if branch != PROTECTED_DEFAULT and '--any-branch' not in args:
102
+ # Not the release branch: just push, so this can stand in for
103
+ # `git push` everywhere without surprising anyone on a feature branch.
104
+ return _push(branch)
105
+
106
+ if is_dirty():
107
+ sys.stderr.write(
108
+ 'release: working tree is dirty, refusing to bump. '
109
+ 'Commit or stash first.\n',
110
+ )
111
+ return 1
112
+
113
+ return _bump_and_push(branch)
114
+
115
+
116
+ def _bump_and_push(branch: str) -> int:
117
+ result = cz.run('bump', '--yes', '--no-verify')
118
+ if result.returncode in {NO_COMMITS_FOUND, NONE_INCREMENT}:
119
+ sys.stdout.write('No version-bumping commits since the last tag.\n')
120
+ return _push(branch)
121
+ if result.returncode != 0:
122
+ sys.stderr.write(result.stdout)
123
+ sys.stderr.write(result.stderr)
124
+ sys.stderr.write(
125
+ f'release: cz bump failed (exit {result.returncode}).\n',
126
+ )
127
+ return result.returncode
128
+
129
+ version = cz.run('version', '-p').stdout.strip()
130
+ code = _push(branch)
131
+ if code != 0:
132
+ sys.stderr.write(
133
+ f'release: bumped to v{version} locally but the push failed. '
134
+ 'Fix the remote and run `git push --follow-tags`.\n',
135
+ )
136
+ return code
137
+
138
+ sys.stdout.write(f'Released v{version} (commit + tag pushed).\n')
139
+ return 0
140
+
141
+
142
+ def main_cli() -> None:
143
+ """Console-script entry point."""
144
+ raise SystemExit(main())
145
+
146
+
147
+ if __name__ == '__main__':
148
+ main_cli()
git_a_grip/restage.py ADDED
@@ -0,0 +1,55 @@
1
+ """Re-stage the files a hook rewrote, so its fixes land in the commit.
2
+
3
+ A formatter that edits the working tree mid-commit leaves the fix *unstaged*:
4
+ the commit still records the unformatted content and the next `git status` is
5
+ dirty for no reason the human asked for. Every repo that hit this solved it
6
+ the same way, with a `bash -c '... && git add "$@"'` wrapper around the entry.
7
+
8
+ Doing it here instead means the `git add` is narrowed to the files that
9
+ actually changed, compared by digest rather than assumed. pre-commit stashes
10
+ unstaged changes for the duration of a hook run, so the working tree holds
11
+ staged content only and re-adding a changed file cannot smuggle in an edit
12
+ the author meant to keep back.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import hashlib
18
+ import subprocess
19
+ from pathlib import Path
20
+
21
+
22
+ def target_paths(args: list[str]) -> list[str]:
23
+ """Pick the existing file paths out of a hook's argv.
24
+
25
+ Flag *values* that name a file (`--config .ruff.toml`) are picked up too,
26
+ which is harmless: a path is only ever re-staged after its digest changes,
27
+ and a config file the tool merely read has not changed.
28
+ """
29
+ return [a for a in args if not a.startswith('-') and Path(a).is_file()]
30
+
31
+
32
+ def digests(paths: list[str]) -> dict[str, str]:
33
+ """Map each readable path to a digest of its current contents."""
34
+ out: dict[str, str] = {}
35
+ for path in paths:
36
+ try:
37
+ out[path] = hashlib.sha256(Path(path).read_bytes()).hexdigest()
38
+ except OSError:
39
+ continue
40
+ return out
41
+
42
+
43
+ def changed(before: dict[str, str], after: dict[str, str]) -> list[str]:
44
+ """Return the paths whose digest moved between the two snapshots."""
45
+ return sorted(p for p, d in after.items() if before.get(p) != d)
46
+
47
+
48
+ def add(paths: list[str]) -> int:
49
+ """Stage `paths`, returning git's exit code (0 when there is nothing)."""
50
+ if not paths:
51
+ return 0
52
+ return subprocess.run( # noqa: S603
53
+ ['git', 'add', '--', *paths], # noqa: S607
54
+ check=False,
55
+ ).returncode
@@ -0,0 +1,57 @@
1
+ """Run ruff out of this hook's own interpreter, and keep its fixes staged.
2
+
3
+ Same bargain as the commitizen hooks: ruff is a declared dependency of this
4
+ package, so `sys.executable -m ruff` resolves inside the env pre-commit built
5
+ here and a consuming repo needs no ruff on PATH, no venv, no `uv`. Pin a
6
+ different ruff per repo with `additional_dependencies: [ruff==x.y.z]`.
7
+
8
+ Both hooks rewrite files, and both re-stage what they rewrote -- including
9
+ `ruff check --fix`, whose fixes otherwise sit unstaged while the commit
10
+ records the unfixed code. A fixed file is committed as fixed; only the
11
+ violations ruff *could not* fix stop the commit, via ruff's own exit code.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import subprocess
17
+ import sys
18
+
19
+ from git_a_grip import restage
20
+
21
+
22
+ def _ruff(*args: str) -> int:
23
+ # Output is left to stream: ruff's diagnostics are the point of failing,
24
+ # and pre-commit already buffers a hook's output until it finishes.
25
+ return subprocess.run( # noqa: S603
26
+ [sys.executable, '-m', 'ruff', *args],
27
+ check=False,
28
+ ).returncode
29
+
30
+
31
+ def run(subcommand: str, args: list[str]) -> int:
32
+ """Run a rewriting ruff subcommand over `args` and re-stage its edits."""
33
+ paths = restage.target_paths(args)
34
+ before = restage.digests(paths)
35
+ # --force-exclude so the excludes in the repo's ruff config still apply
36
+ # to the paths pre-commit passes explicitly.
37
+ code = _ruff(subcommand, '--force-exclude', *args)
38
+ fixed = restage.changed(before, restage.digests(paths))
39
+ if fixed:
40
+ sys.stderr.write(
41
+ f'ruff {subcommand}: rewrote and re-staged {len(fixed)} file(s):\n'
42
+ + ''.join(f' {p}\n' for p in fixed),
43
+ )
44
+ if restage.add(fixed) != 0:
45
+ sys.stderr.write('ruff: failed to re-stage the rewritten files.\n')
46
+ return 1
47
+ return code
48
+
49
+
50
+ def check_cli() -> None:
51
+ """Console-script entry point for `ruff check --fix`."""
52
+ raise SystemExit(run('check', ['--fix', *sys.argv[1:]]))
53
+
54
+
55
+ def format_cli() -> None:
56
+ """Console-script entry point for `ruff format`."""
57
+ raise SystemExit(run('format', sys.argv[1:]))
@@ -0,0 +1,233 @@
1
+ Metadata-Version: 2.4
2
+ Name: git-a-grip
3
+ Version: 0.4.0
4
+ Summary: Pre-commit hooks that fail fast on bad commit messages, re-stage what they fix, and run your tests -- plus a release command and a cross-repo hook audit.
5
+ Project-URL: Homepage, https://github.com/dannybrown37/git-a-grip
6
+ Project-URL: Source, https://github.com/dannybrown37/git-a-grip
7
+ Project-URL: Issues, https://github.com/dannybrown37/git-a-grip/issues
8
+ Project-URL: Changelog, https://github.com/dannybrown37/git-a-grip/blob/main/CHANGELOG.md
9
+ Author-email: Danny Brown <dannybrown37@gmail.com>
10
+ License-Expression: MIT
11
+ License-File: LICENSE
12
+ Keywords: commitizen,git,hooks,pre-commit,ruff
13
+ Classifier: Development Status :: 4 - Beta
14
+ Classifier: Environment :: Console
15
+ Classifier: Intended Audience :: Developers
16
+ Classifier: Programming Language :: Python :: 3 :: Only
17
+ Classifier: Topic :: Software Development :: Quality Assurance
18
+ Classifier: Topic :: Software Development :: Version Control :: Git
19
+ Requires-Python: >=3.11
20
+ Requires-Dist: commitizen>=4.0
21
+ Requires-Dist: pyyaml>=6.0
22
+ Provides-Extra: hooks
23
+ Requires-Dist: ruff>=0.6; extra == 'hooks'
24
+ Description-Content-Type: text/markdown
25
+
26
+ # git-a-grip
27
+
28
+ Personal [pre-commit](https://pre-commit.com) hooks.
29
+
30
+ ```yaml
31
+ repos:
32
+ - repo: https://github.com/dannybrown37/git-a-grip
33
+ rev: v0.3.1
34
+ hooks:
35
+ - id: commitizen-early
36
+ - id: ruff-check
37
+ - id: ruff-format
38
+ - id: pytest
39
+ args: [tests/, -q]
40
+ ```
41
+
42
+ The commitizen and ruff hooks reach their tool through
43
+ `sys.executable -m <tool>` inside the env pre-commit builds for this repo, so
44
+ a consuming project needs no `cz` or `ruff` on PATH, no venv and no
45
+ `uv`/`uvx` of its own. (`pytest` is the exception — see below.)
46
+
47
+ Each hook pays only for what it uses: commitizen is a dependency of the
48
+ package, while ruff is declared by the two ruff hooks themselves, through
49
+ `additional_dependencies` in `.pre-commit-hooks.yaml`. You still pass
50
+ nothing. Pin your own ruff by setting `additional_dependencies:
51
+ [ruff==x.y.z]` on the hook.
52
+
53
+ ## `commitizen-early` (pre-commit stage)
54
+
55
+ Rejects a non-conventional commit message in about a third of a second,
56
+ instead of after the whole slow hook suite has run.
57
+
58
+ Git runs `pre-commit` -> `prepare-commit-msg` -> editor -> `commit-msg` as
59
+ separate invocations, so a `stages: [commit-msg]` commitizen hook can only
60
+ ever fail *after* your tests. Nothing in `.pre-commit-config.yaml` reorders
61
+ that. This hook instead recovers the message from the `git commit` process's
62
+ own argv while the pre-commit stage is still running, and checks it first.
63
+
64
+ Pair it with the upstream `commitizen` hook, which still catches the cases
65
+ argv cannot reach (interactive editor, merge, rebase) — this one exits 0 and
66
+ defers whenever it finds no message:
67
+
68
+ ```yaml
69
+ - repo: https://github.com/dannybrown37/git-a-grip
70
+ rev: v0.3.1
71
+ hooks:
72
+ - id: commitizen-early
73
+
74
+ - repo: https://github.com/commitizen-tools/commitizen
75
+ rev: v4.17.0
76
+ hooks:
77
+ - id: commitizen
78
+ stages: [commit-msg]
79
+ ```
80
+
81
+ Put it first and give it `fail_fast: true` if you want it to short-circuit
82
+ the rest of the stage.
83
+
84
+ ## `git-release` (command, not a hook)
85
+
86
+ Bump, tag and push, in that order, exiting 0. For repos that release from a
87
+ laptop rather than from CI. Give it an alias that says what it does — not
88
+ `gp`, which reads as `git push` right up until it publishes something:
89
+
90
+ ```bash
91
+ alias release='uvx --from git-a-grip git-release'
92
+ ```
93
+
94
+ This repo itself no longer uses it: releases here are cut by CI once the
95
+ checks on `main` pass (see below). The command remains for projects with no
96
+ such pipeline, where the alternative is remembering the four commands by
97
+ hand.
98
+
99
+ On `main` it bumps and pushes; on any other branch it just pushes, so it can
100
+ replace `git push` outright. Refuses to run against a dirty tree, and pushes
101
+ anyway when there are no bumpable commits.
102
+
103
+ This exists because a pre-push hook *cannot* do this cleanly. Git chooses
104
+ which sha to push before hooks run, so a commit created afterwards leaves two
105
+ options: cancel the push, or let git push the now-superseded sha and have it
106
+ rejected as a non-fast-forward. Both end in `error: failed to push some refs`
107
+ on top of a release that worked. Running as a command puts the bump before
108
+ the push and the problem disappears.
109
+
110
+ Configure what the bump rewrites via `[tool.commitizen]` in the consuming
111
+ repo (`version_provider`, `version_files`).
112
+
113
+ > A `bump-on-push` pre-push hook did this up to v0.2.1 and was removed in
114
+ > v0.3.0 for the reason above. If you pin an older rev, that hook still
115
+ > exists there; on upgrading, drop `- id: bump-on-push` and use this command.
116
+
117
+ ## `ruff-check` and `ruff-format` (pre-commit stage)
118
+
119
+ `ruff check --fix` and `ruff format`, with the fixes **re-staged** so they are
120
+ part of the commit you just made rather than a dirty working tree you have to
121
+ `git add` and amend. Only the violations ruff could not fix stop the commit,
122
+ via ruff's own exit code.
123
+
124
+ Pass ruff's flags through `args`:
125
+
126
+ ```yaml
127
+ - id: ruff-check
128
+ args: [--config, .ruff.toml]
129
+ ```
130
+
131
+ `--force-exclude` is always passed, so the `exclude` in your ruff config still
132
+ applies to the paths pre-commit hands over explicitly. The re-staged set is
133
+ narrowed by content digest — a file ruff did not change is never touched, and
134
+ because pre-commit stashes unstaged changes while a hook runs, re-adding a
135
+ file cannot sweep in an edit you deliberately left unstaged.
136
+
137
+ The ruff version is this repo's pinned dependency. To hold a repo at a
138
+ different one:
139
+
140
+ ```yaml
141
+ - id: ruff-format
142
+ additional_dependencies: [ruff==0.16.1]
143
+ ```
144
+
145
+ ## `pytest` (pre-commit stage)
146
+
147
+ Runs the test suite from the repo root. This hook can't use the isolated env
148
+ pre-commit builds here — a test suite needs the *consuming* project's
149
+ dependencies — so it shells out to a runner that resolves that environment,
150
+ `uv run pytest` by default. Everything else in `args` goes to pytest:
151
+
152
+ ```yaml
153
+ - id: pytest
154
+ args: [tests/, -q]
155
+
156
+ - id: pytest
157
+ args: ['--runner=uv run --extra api pytest', tests/, -q]
158
+ ```
159
+
160
+ It runs from the repo root regardless of where git was invoked, and drops the
161
+ `VIRTUAL_ENV`/`PYTHONPATH` that pre-commit exports for its own hook env —
162
+ which would otherwise point the runner at an environment holding none of your
163
+ project's dependencies. Narrow when it runs with `files:` (default
164
+ `^(src/|tests/).*`).
165
+
166
+ ## `pre-commit-audit` (command, not a hook)
167
+
168
+ Audit every local repo's pre-commit setup at once, so a hook that drifted or
169
+ never got installed shows up as a line rather than a surprise:
170
+
171
+ ```bash
172
+ uvx --from git-a-grip pre-commit-audit
173
+ ```
174
+
175
+ It walks the given trees (default: this repo's sibling directories), stops at
176
+ each git working tree, and reports four things: which of this repo's hooks
177
+ each project uses and the `rev` it pins, third-party hooks grouped by source
178
+ repo and rev, one-off `repo: local` hooks with their entry, and repos with no
179
+ usable config at all. `--json` emits the same data unformatted.
180
+
181
+ ```bash
182
+ pre-commit-audit ~/projects ~/work
183
+ pre-commit-audit --json | jq '.[] | select(.hooks == [])'
184
+ ```
185
+
186
+ ## Installing the commands
187
+
188
+ The hooks need no installation — pre-commit builds this repo an isolated env
189
+ from the `rev` you pin. The two commands (`git-release`, `pre-commit-audit`)
190
+ are ordinary console scripts, published to PyPI:
191
+
192
+ ```bash
193
+ uvx --from git-a-grip pre-commit-audit # one-off
194
+ uv tool install git-a-grip # both commands, on PATH
195
+ ```
196
+
197
+ That install carries only what the commands import — commitizen and pyyaml —
198
+ not the ruff the hooks use. To run the hook entry points by hand as well, ask
199
+ for the extra:
200
+
201
+ ```bash
202
+ uv tool install 'git-a-grip[hooks]'
203
+ ```
204
+
205
+ Straight from a tag works too, and is the way to run something not yet
206
+ released:
207
+
208
+ ```bash
209
+ uvx --from git+https://github.com/dannybrown37/git-a-grip@v0.3.1 git-release
210
+ ```
211
+
212
+ ## Releasing
213
+
214
+ Merge to `main`. That is the whole gesture.
215
+
216
+ `ci.yml` runs lint, tests and the install proofs on the merged commit; only
217
+ if they all pass does its `bump` job run `cz bump`, which writes the version
218
+ and changelog, commits, and tags. Pushing that tag triggers `publish.yml`,
219
+ which builds and uploads to PyPI via trusted publishing. A push with no
220
+ bumpable commits (docs, chores) ends after the checks and releases nothing.
221
+
222
+ Nothing is tagged before the checks pass, so a red build cannot leave a
223
+ version number stranded on a release that never shipped.
224
+
225
+ ## Development
226
+
227
+ ```bash
228
+ uv sync
229
+ uv run pytest
230
+ ```
231
+
232
+ This repo eats its own dog food: both hooks are wired into its own
233
+ `.pre-commit-config.yaml`.
@@ -0,0 +1,13 @@
1
+ git_a_grip/__init__.py,sha256=zrR82rhbTiePC05PtXOA46A_vuzYgyhU6KjeeablFec,33
2
+ git_a_grip/audit.py,sha256=0fr3DBm5lVBpQjq6piVmIk4NuMM-5JIUPv9-wmINCtU,9032
3
+ git_a_grip/commitizen_early.py,sha256=7NgJ5ouRfhyvYj_qI-g53qJha4WCaRxIoVrfmQENsyQ,4648
4
+ git_a_grip/cz.py,sha256=6QzhZ_U5tXKa68U59TrN5vCk4SHJ0gimxp5GAPd0cjQ,759
5
+ git_a_grip/pytest_hook.py,sha256=u_QH2UumuQyB__OwFAXslXnJy8WGoklyqglDTfUE33o,2934
6
+ git_a_grip/release.py,sha256=wWuqQXNyNa39CCKBPuGob3hLp1PEXwRgbgGLwanExxU,4596
7
+ git_a_grip/restage.py,sha256=qwcQG8PWhXSOPiY1KegUjmSEeV5KJQZG4GXGmB3H20s,2042
8
+ git_a_grip/ruff_hooks.py,sha256=JmFtb8yqdWNPoMMQmdlPkQyDf5eXDraW2S-PipBW4Aw,2138
9
+ git_a_grip-0.4.0.dist-info/METADATA,sha256=gaaeIpkd9tusz6HjezOc3IRaOuYQmJPbHOy5zORijOU,8583
10
+ git_a_grip-0.4.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
11
+ git_a_grip-0.4.0.dist-info/entry_points.txt,sha256=0mpTtvufG8AhPnz2qFCDoXlRqn5rxxLtQne62p0w4wE,309
12
+ git_a_grip-0.4.0.dist-info/licenses/LICENSE,sha256=tcsRhEYzrUgmGGe0RlgYZcUYVb_FCruDUBDFnCmGSrg,1068
13
+ git_a_grip-0.4.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.31.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,7 @@
1
+ [console_scripts]
2
+ commitizen-early = git_a_grip.commitizen_early:main_cli
3
+ git-release = git_a_grip.release:main_cli
4
+ pre-commit-audit = git_a_grip.audit:main_cli
5
+ pytest-hook = git_a_grip.pytest_hook:main_cli
6
+ ruff-check-hook = git_a_grip.ruff_hooks:check_cli
7
+ ruff-format-hook = git_a_grip.ruff_hooks:format_cli
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Danny Brown
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.