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 +1 -0
- git_a_grip/audit.py +288 -0
- git_a_grip/commitizen_early.py +153 -0
- git_a_grip/cz.py +23 -0
- git_a_grip/pytest_hook.py +92 -0
- git_a_grip/release.py +148 -0
- git_a_grip/restage.py +55 -0
- git_a_grip/ruff_hooks.py +57 -0
- git_a_grip-0.4.0.dist-info/METADATA +233 -0
- git_a_grip-0.4.0.dist-info/RECORD +13 -0
- git_a_grip-0.4.0.dist-info/WHEEL +4 -0
- git_a_grip-0.4.0.dist-info/entry_points.txt +7 -0
- git_a_grip-0.4.0.dist-info/licenses/LICENSE +21 -0
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
|
git_a_grip/ruff_hooks.py
ADDED
|
@@ -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,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.
|