claude-dev-env 1.85.0 → 1.86.0
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.
- package/_shared/pr-loop/scripts/code_rules_gate.py +18 -1
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/terminology_sweep_constants.py +142 -7
- package/_shared/pr-loop/scripts/terminology_sweep.py +288 -92
- package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +89 -0
- package/_shared/pr-loop/scripts/tests/test_terminology_sweep.py +180 -91
- package/hooks/blocking/CLAUDE.md +1 -0
- package/hooks/blocking/code_rules_dead_module_constant.py +7 -4
- package/hooks/blocking/session_edit_stage_gate.py +654 -0
- package/hooks/blocking/test_session_edit_stage_gate.py +537 -0
- package/hooks/hooks.json +30 -0
- package/hooks/hooks_constants/CLAUDE.md +2 -0
- package/hooks/hooks_constants/session_edit_stage_gate_constants.py +92 -0
- package/hooks/hooks_constants/task_list_loop_starter_constants.py +18 -0
- package/hooks/lifecycle/CLAUDE.md +1 -1
- package/hooks/observability/CLAUDE.md +2 -0
- package/hooks/observability/session_file_edit_tracker.py +224 -0
- package/hooks/observability/test_session_file_edit_tracker.py +174 -0
- package/hooks/session/CLAUDE.md +6 -2
- package/hooks/session/session_edit_tracker_cleanup.py +120 -0
- package/hooks/session/task_list_loop_starter.py +36 -0
- package/hooks/session/test_session_edit_tracker_cleanup.py +157 -0
- package/hooks/session/test_task_list_loop_starter.py +53 -0
- package/package.json +1 -1
- package/rules/CLAUDE.md +1 -0
- package/rules/re-stage-before-commit.md +31 -0
|
@@ -0,0 +1,654 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""PreToolUse hook: gate a git commit on staged session-edited files.
|
|
3
|
+
|
|
4
|
+
You edit three files, stage two, and run `git commit` — the third was tracked
|
|
5
|
+
but never staged, so the commit leaves it behind and you notice only later. This
|
|
6
|
+
hook catches that at commit time. It reads the per-session tracker written by
|
|
7
|
+
``session_file_edit_tracker`` and denies the commit when a file this session
|
|
8
|
+
edited is tracked yet still unstaged, naming each file and the exact way to
|
|
9
|
+
include it.
|
|
10
|
+
|
|
11
|
+
Deliberate partial commits still pass: a ``-a``/``--all`` commit, a pathspec
|
|
12
|
+
commit, and a ``# partial-commit`` marker each pass through untouched. A
|
|
13
|
+
``--amend`` does not — an amend drops unstaged edits just as a plain commit does.
|
|
14
|
+
A missing tracker file or any git failure allows the commit, so the gate never
|
|
15
|
+
blocks on infrastructure trouble.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import json
|
|
21
|
+
import shlex
|
|
22
|
+
import subprocess
|
|
23
|
+
import sys
|
|
24
|
+
import tempfile
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
|
|
27
|
+
_blocking_dir = str(Path(__file__).resolve().parent)
|
|
28
|
+
if _blocking_dir not in sys.path:
|
|
29
|
+
sys.path.insert(0, _blocking_dir)
|
|
30
|
+
_hooks_dir = str(Path(__file__).resolve().parent.parent)
|
|
31
|
+
if _hooks_dir not in sys.path:
|
|
32
|
+
sys.path.insert(0, _hooks_dir)
|
|
33
|
+
|
|
34
|
+
from block_main_commit import ( # noqa: E402
|
|
35
|
+
extract_git_working_directory,
|
|
36
|
+
resolve_directory,
|
|
37
|
+
)
|
|
38
|
+
from precommit_code_rules_gate import ( # noqa: E402
|
|
39
|
+
resolve_repository_root,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
from hooks_constants.hook_block_logger import log_hook_block # noqa: E402
|
|
43
|
+
from hooks_constants.pre_tool_use_stdin import ( # noqa: E402
|
|
44
|
+
read_hook_input_dictionary_from_stdin,
|
|
45
|
+
)
|
|
46
|
+
from hooks_constants.session_edit_stage_gate_constants import ( # noqa: E402
|
|
47
|
+
ALL_COMMAND_SEPARATOR_TOKENS,
|
|
48
|
+
ALL_COMMIT_ALL_FLAGS,
|
|
49
|
+
ALL_COMMIT_VALUE_OPTION_SHORT_LETTERS,
|
|
50
|
+
ALL_COMMIT_VALUE_OPTION_TOKENS,
|
|
51
|
+
ALL_EDITED_FILE_PATHS_KEY,
|
|
52
|
+
ALL_GIT_GLOBAL_VALUE_OPTION_TOKENS,
|
|
53
|
+
ALL_STAGE_ALL_ADD_FLAG_TOKENS,
|
|
54
|
+
ALL_STAGE_ALL_ADD_PATHSPEC_TOKENS,
|
|
55
|
+
ALL_STAGING_SUBCOMMAND_TOKENS,
|
|
56
|
+
ALL_TRACKED_UNSTAGED_FILES_COMMAND,
|
|
57
|
+
COMMIT_ALL_SHORT_FLAG_LETTER,
|
|
58
|
+
COMMIT_SUBCOMMAND_TOKEN,
|
|
59
|
+
DENY_FILE_BULLET_LINE_SEPARATOR,
|
|
60
|
+
DENY_FILE_BULLET_PREFIX,
|
|
61
|
+
DENY_PATHSPEC_SEPARATOR,
|
|
62
|
+
ENV_ASSIGNMENT_PREFIX_PATTERN,
|
|
63
|
+
GIT_DIFF_OUTPUT_ENCODING,
|
|
64
|
+
GIT_DIFF_TIMEOUT_SECONDS,
|
|
65
|
+
GIT_EXECUTABLE_TOKEN,
|
|
66
|
+
LONG_FLAG_PREFIX,
|
|
67
|
+
PARTIAL_COMMIT_BYPASS_MARKER,
|
|
68
|
+
SESSION_EDIT_DENY_TEMPLATE,
|
|
69
|
+
SESSION_EDIT_FILE_PREFIX,
|
|
70
|
+
SESSION_EDIT_FILE_SUFFIX,
|
|
71
|
+
SESSION_ID_UNSAFE_CHARACTERS_PATTERN,
|
|
72
|
+
SHORT_FLAG_PREFIX,
|
|
73
|
+
STATE_FILE_DEFAULT_SESSION_ID,
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _commit_argument_tokens(bash_command: str) -> list[str]:
|
|
78
|
+
"""Return the tokens that follow the ``commit`` subcommand of its segment.
|
|
79
|
+
|
|
80
|
+
The command is split into shell segments. The segment whose git subcommand
|
|
81
|
+
is ``commit`` supplies its post-subcommand tokens, so a bare ``commit``
|
|
82
|
+
token in an earlier segment (``echo commit && git commit -a``) never stands
|
|
83
|
+
in for the real commit's arguments.
|
|
84
|
+
|
|
85
|
+
Args:
|
|
86
|
+
bash_command: The Bash tool command string.
|
|
87
|
+
|
|
88
|
+
Returns:
|
|
89
|
+
The argument tokens of the git commit invocation. Empty when the
|
|
90
|
+
command cannot be tokenized or runs no ``git commit`` segment.
|
|
91
|
+
"""
|
|
92
|
+
try:
|
|
93
|
+
all_tokens = shlex.split(bash_command, posix=True)
|
|
94
|
+
except ValueError:
|
|
95
|
+
return []
|
|
96
|
+
for each_segment in _split_into_command_segments(all_tokens):
|
|
97
|
+
subcommand_index = _git_subcommand_index(each_segment)
|
|
98
|
+
if subcommand_index is None:
|
|
99
|
+
continue
|
|
100
|
+
if each_segment[subcommand_index] != COMMIT_SUBCOMMAND_TOKEN:
|
|
101
|
+
continue
|
|
102
|
+
return each_segment[subcommand_index + 1 :]
|
|
103
|
+
return []
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _is_all_flag_token(token: str) -> bool:
|
|
107
|
+
"""Return whether *token* is a ``-a``/``--all`` flag or a cluster holding ``a``.
|
|
108
|
+
|
|
109
|
+
A short-flag cluster is read left to right. The scan stops at the first
|
|
110
|
+
value-taking option letter (``m``/``F``/``C``/``c``), whose remainder is
|
|
111
|
+
that option's argument rather than more clustered flags — so
|
|
112
|
+
``git commit -m"add feature"`` tokenizes to ``-madd feature`` and does not
|
|
113
|
+
count as an ``-a`` cluster even though its message carries an ``a``.
|
|
114
|
+
|
|
115
|
+
Args:
|
|
116
|
+
token: One argument token of a git commit invocation.
|
|
117
|
+
|
|
118
|
+
Returns:
|
|
119
|
+
True for ``-a``, ``--all``, or a combined short cluster such as
|
|
120
|
+
``-am`` that carries the ``a`` letter before any value-taking option;
|
|
121
|
+
False otherwise.
|
|
122
|
+
"""
|
|
123
|
+
if token in ALL_COMMIT_ALL_FLAGS:
|
|
124
|
+
return True
|
|
125
|
+
if token.startswith(LONG_FLAG_PREFIX):
|
|
126
|
+
return False
|
|
127
|
+
if not token.startswith(SHORT_FLAG_PREFIX):
|
|
128
|
+
return False
|
|
129
|
+
for each_letter in token[1:]:
|
|
130
|
+
if each_letter == COMMIT_ALL_SHORT_FLAG_LETTER:
|
|
131
|
+
return True
|
|
132
|
+
if each_letter in ALL_COMMIT_VALUE_OPTION_SHORT_LETTERS:
|
|
133
|
+
return False
|
|
134
|
+
return False
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _has_trailing_bypass_comment(bash_command: str) -> bool:
|
|
138
|
+
"""Return whether the command carries the bypass marker as a real comment.
|
|
139
|
+
|
|
140
|
+
The ``# partial-commit`` marker opts out only as a trailing shell comment
|
|
141
|
+
the author adds on purpose, so its ``#`` and ``partial-commit`` parts are
|
|
142
|
+
the final tokens of the command. The same text earlier in the command — a
|
|
143
|
+
quoted commit message (``git commit -m "fix # partial-commit"``) or an
|
|
144
|
+
earlier ``echo # partial-commit`` segment — does not match, so the gate
|
|
145
|
+
still runs.
|
|
146
|
+
|
|
147
|
+
Args:
|
|
148
|
+
bash_command: The Bash tool command string.
|
|
149
|
+
|
|
150
|
+
Returns:
|
|
151
|
+
True when the marker is the final tokens of the command; False when it
|
|
152
|
+
appears earlier or the command will not tokenize.
|
|
153
|
+
"""
|
|
154
|
+
marker_tokens = PARTIAL_COMMIT_BYPASS_MARKER.split()
|
|
155
|
+
try:
|
|
156
|
+
all_tokens = shlex.split(bash_command, posix=True)
|
|
157
|
+
except ValueError:
|
|
158
|
+
return False
|
|
159
|
+
marker_length = len(marker_tokens)
|
|
160
|
+
if marker_length > len(all_tokens):
|
|
161
|
+
return False
|
|
162
|
+
return all_tokens[-marker_length:] == marker_tokens
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _split_into_command_segments(all_tokens: list[str]) -> list[list[str]]:
|
|
166
|
+
"""Split a token list into segments at each shell command separator.
|
|
167
|
+
|
|
168
|
+
Args:
|
|
169
|
+
all_tokens: The tokens of the whole Bash command.
|
|
170
|
+
|
|
171
|
+
Returns:
|
|
172
|
+
One token list per command segment, in command order. A ``&&``, ``||``,
|
|
173
|
+
``;``, ``|``, or ``&`` separator ends the current segment and starts the
|
|
174
|
+
next.
|
|
175
|
+
"""
|
|
176
|
+
all_segments: list[list[str]] = []
|
|
177
|
+
current_segment: list[str] = []
|
|
178
|
+
for each_token in all_tokens:
|
|
179
|
+
if each_token in ALL_COMMAND_SEPARATOR_TOKENS:
|
|
180
|
+
all_segments.append(current_segment)
|
|
181
|
+
current_segment = []
|
|
182
|
+
continue
|
|
183
|
+
current_segment.append(each_token)
|
|
184
|
+
all_segments.append(current_segment)
|
|
185
|
+
return all_segments
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _command_token_index(all_segment_tokens: list[str]) -> int | None:
|
|
189
|
+
"""Return the index of a segment's command word, past any env assignments.
|
|
190
|
+
|
|
191
|
+
A segment may open with ``NAME=value`` assignments before the command it
|
|
192
|
+
runs, as in ``GIT_DIR=x git commit``. This skips those leading assignments
|
|
193
|
+
and returns the first real command token, so ``git`` is read as the command
|
|
194
|
+
only when it holds that position — not when it rides as an argument to
|
|
195
|
+
another command (``echo git commit``).
|
|
196
|
+
|
|
197
|
+
Args:
|
|
198
|
+
all_segment_tokens: One command segment's tokens.
|
|
199
|
+
|
|
200
|
+
Returns:
|
|
201
|
+
The index of the first non-assignment token, or None for an empty
|
|
202
|
+
segment or one that is only assignments.
|
|
203
|
+
"""
|
|
204
|
+
for each_offset, each_token in enumerate(all_segment_tokens):
|
|
205
|
+
if ENV_ASSIGNMENT_PREFIX_PATTERN.match(each_token):
|
|
206
|
+
continue
|
|
207
|
+
return each_offset
|
|
208
|
+
return None
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _git_subcommand_index(all_segment_tokens: list[str]) -> int | None:
|
|
212
|
+
"""Return the index of a segment's git subcommand, skipping global options.
|
|
213
|
+
|
|
214
|
+
The segment runs ``git`` as its command word — past any leading
|
|
215
|
+
``NAME=value`` assignments — and ``git`` may carry global options before its
|
|
216
|
+
subcommand: ``-C``/``-c``/``--git-dir`` and their siblings each take a
|
|
217
|
+
value, so ``git -C path add file`` resolves the ``add`` token, not ``path``.
|
|
218
|
+
|
|
219
|
+
Args:
|
|
220
|
+
all_segment_tokens: One command segment's tokens.
|
|
221
|
+
|
|
222
|
+
Returns:
|
|
223
|
+
The index of the subcommand token following ``git`` and its global
|
|
224
|
+
options, or None when the segment does not run ``git`` as its command.
|
|
225
|
+
"""
|
|
226
|
+
command_index = _command_token_index(all_segment_tokens)
|
|
227
|
+
if command_index is None or all_segment_tokens[command_index] != GIT_EXECUTABLE_TOKEN:
|
|
228
|
+
return None
|
|
229
|
+
should_skip_next_value = False
|
|
230
|
+
for each_offset in range(command_index + 1, len(all_segment_tokens)):
|
|
231
|
+
each_token = all_segment_tokens[each_offset]
|
|
232
|
+
if should_skip_next_value:
|
|
233
|
+
should_skip_next_value = False
|
|
234
|
+
continue
|
|
235
|
+
if each_token in ALL_GIT_GLOBAL_VALUE_OPTION_TOKENS:
|
|
236
|
+
should_skip_next_value = True
|
|
237
|
+
continue
|
|
238
|
+
if each_token.startswith(SHORT_FLAG_PREFIX):
|
|
239
|
+
continue
|
|
240
|
+
return each_offset
|
|
241
|
+
return None
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _git_subcommand(all_segment_tokens: list[str]) -> str | None:
|
|
245
|
+
"""Return the git subcommand of a segment, skipping git's global options.
|
|
246
|
+
|
|
247
|
+
A leading ``git`` may carry global options before its subcommand, and
|
|
248
|
+
``-C``/``-c``/``--git-dir`` and their siblings each take a value — so
|
|
249
|
+
``git -C path add file`` resolves to the ``add`` subcommand, not ``path``.
|
|
250
|
+
|
|
251
|
+
Args:
|
|
252
|
+
all_segment_tokens: One command segment's tokens.
|
|
253
|
+
|
|
254
|
+
Returns:
|
|
255
|
+
The subcommand token following ``git`` and its global options, or None
|
|
256
|
+
when the segment invokes no ``git`` subcommand.
|
|
257
|
+
"""
|
|
258
|
+
subcommand_index = _git_subcommand_index(all_segment_tokens)
|
|
259
|
+
if subcommand_index is None:
|
|
260
|
+
return None
|
|
261
|
+
return all_segment_tokens[subcommand_index]
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def _preceding_staging_segments(bash_command: str) -> list[list[str]]:
|
|
265
|
+
"""Return the ``git add``/``git stage`` segments that run before the commit.
|
|
266
|
+
|
|
267
|
+
The common ``git add widget.py && git commit -m update`` idiom stages a file
|
|
268
|
+
in its own segment right before the commit, so the file is staged by the
|
|
269
|
+
time the commit runs even though it reads as unstaged when the gate inspects
|
|
270
|
+
the index. A staging segment that runs after the commit segment stages
|
|
271
|
+
nothing for that commit and is left out.
|
|
272
|
+
|
|
273
|
+
Args:
|
|
274
|
+
bash_command: The Bash tool command string.
|
|
275
|
+
|
|
276
|
+
Returns:
|
|
277
|
+
The staging segments (each a token list) that run before the commit
|
|
278
|
+
segment, in command order. Empty when the command will not tokenize,
|
|
279
|
+
runs no commit, or has no such preceding segment.
|
|
280
|
+
"""
|
|
281
|
+
try:
|
|
282
|
+
all_tokens = shlex.split(bash_command, posix=True)
|
|
283
|
+
except ValueError:
|
|
284
|
+
return []
|
|
285
|
+
all_segments = _split_into_command_segments(all_tokens)
|
|
286
|
+
commit_segment_index = next(
|
|
287
|
+
(
|
|
288
|
+
each_index
|
|
289
|
+
for each_index, each_segment in enumerate(all_segments)
|
|
290
|
+
if _git_subcommand(each_segment) == COMMIT_SUBCOMMAND_TOKEN
|
|
291
|
+
),
|
|
292
|
+
None,
|
|
293
|
+
)
|
|
294
|
+
if commit_segment_index is None:
|
|
295
|
+
return []
|
|
296
|
+
return [
|
|
297
|
+
each_segment
|
|
298
|
+
for each_segment in all_segments[:commit_segment_index]
|
|
299
|
+
if _git_subcommand(each_segment) in ALL_STAGING_SUBCOMMAND_TOKENS
|
|
300
|
+
]
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def _staging_segment_covers_all_paths(all_segment_tokens: list[str]) -> bool:
|
|
304
|
+
"""Return whether one staging segment stages every path (a stage-all form).
|
|
305
|
+
|
|
306
|
+
``git add -A``/``--all``/``-u``/``--update`` and ``git add .``/``git add :/``
|
|
307
|
+
each stage the whole working tree, so any file the session edited is staged
|
|
308
|
+
by the time the commit runs. A ``--`` ends option parsing: every token after
|
|
309
|
+
it is a path, so ``git add -- -A`` stages a file named ``-A`` and is not a
|
|
310
|
+
stage-all form.
|
|
311
|
+
|
|
312
|
+
Args:
|
|
313
|
+
all_segment_tokens: One ``git add``/``git stage`` segment's tokens.
|
|
314
|
+
|
|
315
|
+
Returns:
|
|
316
|
+
True when the segment carries a stage-all flag before any ``--`` or a
|
|
317
|
+
stage-all pathspec; False otherwise.
|
|
318
|
+
"""
|
|
319
|
+
subcommand_index = _git_subcommand_index(all_segment_tokens)
|
|
320
|
+
if subcommand_index is None:
|
|
321
|
+
return False
|
|
322
|
+
has_seen_end_of_options = False
|
|
323
|
+
for each_token in all_segment_tokens[subcommand_index + 1 :]:
|
|
324
|
+
if not has_seen_end_of_options and each_token == LONG_FLAG_PREFIX:
|
|
325
|
+
has_seen_end_of_options = True
|
|
326
|
+
continue
|
|
327
|
+
if not has_seen_end_of_options and each_token in ALL_STAGE_ALL_ADD_FLAG_TOKENS:
|
|
328
|
+
return True
|
|
329
|
+
if each_token in ALL_STAGE_ALL_ADD_PATHSPEC_TOKENS:
|
|
330
|
+
return True
|
|
331
|
+
return False
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def _staging_covers_all_paths(all_staging_segments: list[list[str]]) -> bool:
|
|
335
|
+
"""Return whether any preceding staging segment stages every path.
|
|
336
|
+
|
|
337
|
+
Args:
|
|
338
|
+
all_staging_segments: The staging segments that run before the commit.
|
|
339
|
+
|
|
340
|
+
Returns:
|
|
341
|
+
True when one of the segments is a stage-all form; False otherwise.
|
|
342
|
+
"""
|
|
343
|
+
return any(
|
|
344
|
+
_staging_segment_covers_all_paths(each_segment)
|
|
345
|
+
for each_segment in all_staging_segments
|
|
346
|
+
)
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def _staged_positional_path_tokens(all_staging_segments: list[list[str]]) -> list[str]:
|
|
350
|
+
"""Return the positional path tokens of the specific-path staging segments.
|
|
351
|
+
|
|
352
|
+
A specific-path ``git add README.md`` names the paths it stages as
|
|
353
|
+
positional tokens after the subcommand, so only those paths are staged by
|
|
354
|
+
the time the commit runs. A ``--`` ends option parsing: before it, a
|
|
355
|
+
``-``-prefixed token is a flag; after it, every token is a path, so
|
|
356
|
+
``git add -- -weird.py`` stages the file ``-weird.py``.
|
|
357
|
+
|
|
358
|
+
Args:
|
|
359
|
+
all_staging_segments: The staging segments that run before the commit.
|
|
360
|
+
|
|
361
|
+
Returns:
|
|
362
|
+
Every path token following the staging subcommand, across all segments,
|
|
363
|
+
in command order.
|
|
364
|
+
"""
|
|
365
|
+
all_path_tokens: list[str] = []
|
|
366
|
+
for each_segment in all_staging_segments:
|
|
367
|
+
subcommand_index = _git_subcommand_index(each_segment)
|
|
368
|
+
if subcommand_index is None:
|
|
369
|
+
continue
|
|
370
|
+
has_seen_end_of_options = False
|
|
371
|
+
for each_token in each_segment[subcommand_index + 1 :]:
|
|
372
|
+
if not has_seen_end_of_options and each_token == LONG_FLAG_PREFIX:
|
|
373
|
+
has_seen_end_of_options = True
|
|
374
|
+
continue
|
|
375
|
+
if not has_seen_end_of_options and each_token.startswith(SHORT_FLAG_PREFIX):
|
|
376
|
+
continue
|
|
377
|
+
all_path_tokens.append(each_token)
|
|
378
|
+
return all_path_tokens
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def _staged_path_keys(
|
|
382
|
+
all_staging_segments: list[list[str]],
|
|
383
|
+
working_directory: str | None,
|
|
384
|
+
repository_root: Path,
|
|
385
|
+
) -> set[str]:
|
|
386
|
+
"""Return the case-folded resolved keys the specific-path staging segments stage.
|
|
387
|
+
|
|
388
|
+
A ``git add`` path token is relative to the command's working directory, so
|
|
389
|
+
it resolves against ``working_directory`` when that is known and against the
|
|
390
|
+
repository root otherwise.
|
|
391
|
+
|
|
392
|
+
Args:
|
|
393
|
+
all_staging_segments: The staging segments that run before the commit.
|
|
394
|
+
working_directory: The command's resolved git working directory, or None
|
|
395
|
+
when the command runs in the hook's own working directory.
|
|
396
|
+
repository_root: Repository root used as the base when no working
|
|
397
|
+
directory is known.
|
|
398
|
+
|
|
399
|
+
Returns:
|
|
400
|
+
The case-folded resolved absolute paths of every specific staged path
|
|
401
|
+
token, matching the key shape used for session-edited and unstaged paths.
|
|
402
|
+
"""
|
|
403
|
+
base_directory = Path(working_directory) if working_directory is not None else repository_root
|
|
404
|
+
staged_keys: set[str] = set()
|
|
405
|
+
for each_token in _staged_positional_path_tokens(all_staging_segments):
|
|
406
|
+
try:
|
|
407
|
+
resolved_key = str((base_directory / each_token).resolve()).casefold()
|
|
408
|
+
except OSError:
|
|
409
|
+
continue
|
|
410
|
+
staged_keys.add(resolved_key)
|
|
411
|
+
return staged_keys
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def _invokes_git_commit(bash_command: str) -> bool:
|
|
415
|
+
"""Return whether the command runs a real ``git commit`` subcommand.
|
|
416
|
+
|
|
417
|
+
A benign command can name the phrase ``git commit`` as plain text, as in
|
|
418
|
+
``grep -rn "git commit" .``. This check tokenizes the command, splits it at
|
|
419
|
+
each shell separator, and confirms one segment invokes ``git`` with a
|
|
420
|
+
``commit`` subcommand.
|
|
421
|
+
|
|
422
|
+
Args:
|
|
423
|
+
bash_command: The Bash tool command string.
|
|
424
|
+
|
|
425
|
+
Returns:
|
|
426
|
+
True when a command segment runs ``git commit``; False when the command
|
|
427
|
+
will not tokenize or names ``commit`` only as text.
|
|
428
|
+
"""
|
|
429
|
+
try:
|
|
430
|
+
all_tokens = shlex.split(bash_command, posix=True)
|
|
431
|
+
except ValueError:
|
|
432
|
+
return False
|
|
433
|
+
return any(
|
|
434
|
+
_git_subcommand(each_segment) == COMMIT_SUBCOMMAND_TOKEN
|
|
435
|
+
for each_segment in _split_into_command_segments(all_tokens)
|
|
436
|
+
)
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
def _commit_bypasses_stage_check(bash_command: str) -> bool:
|
|
440
|
+
"""Return whether the commit intentionally opts out of the stage check.
|
|
441
|
+
|
|
442
|
+
A ``# partial-commit`` trailing comment, a ``-a``/``--all`` flag, and a
|
|
443
|
+
pathspec argument (a ``--`` separator or a bare positional path) each mark a
|
|
444
|
+
deliberate partial commit that the gate leaves alone.
|
|
445
|
+
|
|
446
|
+
Args:
|
|
447
|
+
bash_command: The Bash tool command string.
|
|
448
|
+
|
|
449
|
+
Returns:
|
|
450
|
+
True when the commit opts out of the stage check; False otherwise.
|
|
451
|
+
"""
|
|
452
|
+
if _has_trailing_bypass_comment(bash_command):
|
|
453
|
+
return True
|
|
454
|
+
should_skip_next_value = False
|
|
455
|
+
for each_token in _commit_argument_tokens(bash_command):
|
|
456
|
+
if should_skip_next_value:
|
|
457
|
+
should_skip_next_value = False
|
|
458
|
+
continue
|
|
459
|
+
if each_token == LONG_FLAG_PREFIX:
|
|
460
|
+
return True
|
|
461
|
+
if _is_all_flag_token(each_token):
|
|
462
|
+
return True
|
|
463
|
+
if each_token in ALL_COMMIT_VALUE_OPTION_TOKENS:
|
|
464
|
+
should_skip_next_value = True
|
|
465
|
+
continue
|
|
466
|
+
if each_token.startswith(SHORT_FLAG_PREFIX):
|
|
467
|
+
continue
|
|
468
|
+
return True
|
|
469
|
+
return False
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
def _session_edited_keys(session_id: str) -> set[str]:
|
|
473
|
+
"""Return the case-folded resolved paths this session recorded as edited.
|
|
474
|
+
|
|
475
|
+
Args:
|
|
476
|
+
session_id: Raw ``session_id`` from the hook payload.
|
|
477
|
+
|
|
478
|
+
Returns:
|
|
479
|
+
The case-folded resolved absolute paths from the tracker file, or an
|
|
480
|
+
empty set when the file is absent, unreadable, or malformed.
|
|
481
|
+
"""
|
|
482
|
+
sanitized_session_id = SESSION_ID_UNSAFE_CHARACTERS_PATTERN.sub("", session_id)
|
|
483
|
+
effective_session_id = sanitized_session_id or STATE_FILE_DEFAULT_SESSION_ID
|
|
484
|
+
file_name = f"{SESSION_EDIT_FILE_PREFIX}{effective_session_id}{SESSION_EDIT_FILE_SUFFIX}"
|
|
485
|
+
edit_file = Path(tempfile.gettempdir()) / file_name
|
|
486
|
+
try:
|
|
487
|
+
raw_contents = edit_file.read_text(encoding="utf-8")
|
|
488
|
+
except (FileNotFoundError, OSError):
|
|
489
|
+
return set()
|
|
490
|
+
try:
|
|
491
|
+
parsed_payload = json.loads(raw_contents)
|
|
492
|
+
except json.JSONDecodeError:
|
|
493
|
+
return set()
|
|
494
|
+
if not isinstance(parsed_payload, dict):
|
|
495
|
+
return set()
|
|
496
|
+
recorded_paths = parsed_payload.get(ALL_EDITED_FILE_PATHS_KEY, [])
|
|
497
|
+
if not isinstance(recorded_paths, list):
|
|
498
|
+
return set()
|
|
499
|
+
edited_keys: set[str] = set()
|
|
500
|
+
for each_path in recorded_paths:
|
|
501
|
+
if not isinstance(each_path, str) or not each_path:
|
|
502
|
+
continue
|
|
503
|
+
try:
|
|
504
|
+
resolved_key = str(Path(each_path).resolve()).casefold()
|
|
505
|
+
except OSError:
|
|
506
|
+
continue
|
|
507
|
+
edited_keys.add(resolved_key)
|
|
508
|
+
return edited_keys
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
def _tracked_unstaged_paths(repository_root: Path) -> dict[str, str] | None:
|
|
512
|
+
"""Return tracked-but-unstaged files keyed by their case-folded resolved path.
|
|
513
|
+
|
|
514
|
+
Args:
|
|
515
|
+
repository_root: Repository root used as the git working directory.
|
|
516
|
+
|
|
517
|
+
Returns:
|
|
518
|
+
A mapping of case-folded resolved absolute path to repository-relative
|
|
519
|
+
path for each tracked file with unstaged changes. None when the git
|
|
520
|
+
command fails, so the caller can allow the commit rather than block on
|
|
521
|
+
an infrastructure failure.
|
|
522
|
+
"""
|
|
523
|
+
try:
|
|
524
|
+
completed_process = subprocess.run(
|
|
525
|
+
list(ALL_TRACKED_UNSTAGED_FILES_COMMAND),
|
|
526
|
+
check=False, capture_output=True,
|
|
527
|
+
text=True,
|
|
528
|
+
encoding=GIT_DIFF_OUTPUT_ENCODING,
|
|
529
|
+
timeout=GIT_DIFF_TIMEOUT_SECONDS,
|
|
530
|
+
cwd=str(repository_root),
|
|
531
|
+
)
|
|
532
|
+
except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
|
|
533
|
+
return None
|
|
534
|
+
if completed_process.returncode != 0:
|
|
535
|
+
return None
|
|
536
|
+
repository_relative_by_key: dict[str, str] = {}
|
|
537
|
+
for each_line in completed_process.stdout.splitlines():
|
|
538
|
+
repository_relative_path = each_line.strip()
|
|
539
|
+
if not repository_relative_path:
|
|
540
|
+
continue
|
|
541
|
+
absolute_key = str((repository_root / repository_relative_path).resolve()).casefold()
|
|
542
|
+
repository_relative_by_key[absolute_key] = repository_relative_path
|
|
543
|
+
return repository_relative_by_key
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
def _offending_repository_relative_paths(
|
|
547
|
+
all_session_edited_keys: set[str],
|
|
548
|
+
all_unstaged_paths_by_key: dict[str, str],
|
|
549
|
+
all_staged_path_keys: set[str],
|
|
550
|
+
) -> list[str]:
|
|
551
|
+
"""Return the tracked-unstaged files this session edited, repository-relative.
|
|
552
|
+
|
|
553
|
+
Args:
|
|
554
|
+
all_session_edited_keys: Case-folded resolved paths this session edited.
|
|
555
|
+
all_unstaged_paths_by_key: Tracked-unstaged files keyed by their
|
|
556
|
+
case-folded resolved path.
|
|
557
|
+
all_staged_path_keys: Case-folded resolved paths a preceding specific
|
|
558
|
+
``git add`` already staged, excluded from the result.
|
|
559
|
+
|
|
560
|
+
Returns:
|
|
561
|
+
The sorted repository-relative paths this session edited that remain
|
|
562
|
+
unstaged and were not staged by a preceding specific ``git add``.
|
|
563
|
+
"""
|
|
564
|
+
all_offending_paths = [
|
|
565
|
+
repository_relative_path
|
|
566
|
+
for absolute_key, repository_relative_path in all_unstaged_paths_by_key.items()
|
|
567
|
+
if absolute_key in all_session_edited_keys
|
|
568
|
+
and absolute_key not in all_staged_path_keys
|
|
569
|
+
]
|
|
570
|
+
return sorted(all_offending_paths)
|
|
571
|
+
|
|
572
|
+
|
|
573
|
+
def _build_denial(all_offending_paths: list[str]) -> dict:
|
|
574
|
+
"""Build the PreToolUse deny payload listing the dropped files.
|
|
575
|
+
|
|
576
|
+
Args:
|
|
577
|
+
all_offending_paths: The tracked-unstaged files this session edited,
|
|
578
|
+
repository-relative.
|
|
579
|
+
|
|
580
|
+
Returns:
|
|
581
|
+
The hookSpecificOutput deny mapping for the PreToolUse protocol.
|
|
582
|
+
"""
|
|
583
|
+
file_list = DENY_FILE_BULLET_LINE_SEPARATOR.join(
|
|
584
|
+
f"{DENY_FILE_BULLET_PREFIX}{each_path}"
|
|
585
|
+
for each_path in all_offending_paths
|
|
586
|
+
)
|
|
587
|
+
space_joined_paths = DENY_PATHSPEC_SEPARATOR.join(
|
|
588
|
+
shlex.quote(each_path) for each_path in all_offending_paths
|
|
589
|
+
)
|
|
590
|
+
denial_reason = SESSION_EDIT_DENY_TEMPLATE.format(
|
|
591
|
+
file_list=file_list,
|
|
592
|
+
space_joined_paths=space_joined_paths,
|
|
593
|
+
bypass_marker=PARTIAL_COMMIT_BYPASS_MARKER,
|
|
594
|
+
)
|
|
595
|
+
return {
|
|
596
|
+
"hookSpecificOutput": {
|
|
597
|
+
"hookEventName": "PreToolUse",
|
|
598
|
+
"permissionDecision": "deny",
|
|
599
|
+
"permissionDecisionReason": denial_reason,
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
|
|
604
|
+
def main() -> None:
|
|
605
|
+
"""Deny a git commit that would drop a tracked file that was edited yet left unstaged."""
|
|
606
|
+
hook_payload = read_hook_input_dictionary_from_stdin()
|
|
607
|
+
if hook_payload is None:
|
|
608
|
+
sys.exit(0)
|
|
609
|
+
tool_input = hook_payload.get("tool_input", {})
|
|
610
|
+
if not isinstance(tool_input, dict):
|
|
611
|
+
sys.exit(0)
|
|
612
|
+
bash_command = tool_input.get("command", "")
|
|
613
|
+
if not isinstance(bash_command, str) or not bash_command:
|
|
614
|
+
sys.exit(0)
|
|
615
|
+
if not _invokes_git_commit(bash_command):
|
|
616
|
+
sys.exit(0)
|
|
617
|
+
if _commit_bypasses_stage_check(bash_command):
|
|
618
|
+
sys.exit(0)
|
|
619
|
+
session_id = str(hook_payload.get("session_id") or "")
|
|
620
|
+
session_edited_keys = _session_edited_keys(session_id)
|
|
621
|
+
if not session_edited_keys:
|
|
622
|
+
sys.exit(0)
|
|
623
|
+
working_directory = resolve_directory(extract_git_working_directory(bash_command))
|
|
624
|
+
repository_root = resolve_repository_root(working_directory)
|
|
625
|
+
if repository_root is None:
|
|
626
|
+
sys.exit(0)
|
|
627
|
+
preceding_staging_segments = _preceding_staging_segments(bash_command)
|
|
628
|
+
if _staging_covers_all_paths(preceding_staging_segments):
|
|
629
|
+
sys.exit(0)
|
|
630
|
+
repository_relative_by_key = _tracked_unstaged_paths(repository_root)
|
|
631
|
+
if repository_relative_by_key is None:
|
|
632
|
+
sys.exit(0)
|
|
633
|
+
staged_path_keys = _staged_path_keys(
|
|
634
|
+
preceding_staging_segments, working_directory, repository_root
|
|
635
|
+
)
|
|
636
|
+
offending_paths = _offending_repository_relative_paths(
|
|
637
|
+
session_edited_keys, repository_relative_by_key, staged_path_keys
|
|
638
|
+
)
|
|
639
|
+
if not offending_paths:
|
|
640
|
+
sys.exit(0)
|
|
641
|
+
denial = _build_denial(offending_paths)
|
|
642
|
+
log_hook_block(
|
|
643
|
+
calling_hook_name="session_edit_stage_gate.py",
|
|
644
|
+
hook_event="PreToolUse",
|
|
645
|
+
block_reason=denial["hookSpecificOutput"]["permissionDecisionReason"],
|
|
646
|
+
tool_name="Bash",
|
|
647
|
+
offending_input_preview=bash_command,
|
|
648
|
+
)
|
|
649
|
+
print(json.dumps(denial))
|
|
650
|
+
sys.exit(0)
|
|
651
|
+
|
|
652
|
+
|
|
653
|
+
if __name__ == "__main__":
|
|
654
|
+
main()
|