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.
@@ -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()