git-prism 0.8.0

Agent-optimized git data MCP server — structured change manifests and full file snapshots for LLM agents
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
"""Unit tests for hooks/bash_redirect_hook.py.

These tests exercise the public API of the hook module directly —
no subprocesses, no filesystem. The BDD scenarios cover the end-to-end
subprocess contract; these tests cover the internal logic with fast,
hermetic unit assertions.

Run with:
    python3 -m unittest test_bash_redirect_hook   (stdlib, no install)
    python3 -m pytest   test_bash_redirect_hook.py  (if pytest available)
"""

from __future__ import annotations

import sys
import unittest
from pathlib import Path

# Allow running from any cwd: add the hooks/ directory to the import path.
sys.path.insert(0, str(Path(__file__).parent))

from bash_redirect_hook import (
    BLOCK_GH_PR_DIFF,
    BLOCK_MCP_GITHUB_GET_COMMIT,
    _advice_with_echo,
    _matches_gh_api_contents,
    _classify_git_command,
    _drop_heredoc_bodies,
    _has_pickaxe_flag,
    _has_ref_range,
    _is_functionally_empty,
    _matches_gh_pr_diff,
    _emit_advice,
    _read_payload,
    decide_redirect,
    main,
    tokenize_command,
)


# ---------------------------------------------------------------------------
# _is_functionally_empty
# ---------------------------------------------------------------------------


class TestIsFunctionallyEmpty(unittest.TestCase):
    def test_empty_string_is_empty(self) -> None:
        self.assertTrue(_is_functionally_empty(""))

    def test_whitespace_only_is_empty(self) -> None:
        self.assertTrue(_is_functionally_empty("   \t  "))

    def test_newline_only_is_empty(self) -> None:
        self.assertTrue(_is_functionally_empty("\n"))

    def test_escaped_newline_sequence_is_empty(self) -> None:
        # The BDD scenario pipes the literal four-character string "\\n  \\n".
        # _is_functionally_empty must translate escape sequences before checking.
        self.assertTrue(_is_functionally_empty("\\n  \\n"))

    def test_mixed_escape_sequences_are_empty(self) -> None:
        self.assertTrue(_is_functionally_empty("\\n\\t\\r"))

    def test_non_whitespace_is_not_empty(self) -> None:
        self.assertFalse(_is_functionally_empty("git diff"))

    def test_json_is_not_empty(self) -> None:
        self.assertFalse(_is_functionally_empty('{"tool_name": "Bash"}'))


# ---------------------------------------------------------------------------
# _has_ref_range
# ---------------------------------------------------------------------------


class TestHasRefRange(unittest.TestCase):
    def test_double_dot_range_is_detected(self) -> None:
        self.assertTrue(_has_ref_range(["main..HEAD"]))

    def test_triple_dot_range_is_detected(self) -> None:
        self.assertTrue(_has_ref_range(["main...HEAD"]))

    def test_bare_double_dot_is_excluded(self) -> None:
        # ".." is the parent-directory shorthand, not a ref range.
        self.assertFalse(_has_ref_range([".."]))

    def test_bare_triple_dot_is_excluded(self) -> None:
        self.assertFalse(_has_ref_range(["..."]))

    def test_no_range_returns_false(self) -> None:
        self.assertFalse(_has_ref_range(["git", "diff", "--stat"]))

    def test_range_anywhere_in_list_is_detected(self) -> None:
        self.assertTrue(_has_ref_range(["git", "diff", "feature..main"]))


# ---------------------------------------------------------------------------
# _has_pickaxe_flag
# ---------------------------------------------------------------------------


class TestHasPickaxeFlag(unittest.TestCase):
    def test_standalone_dash_s(self) -> None:
        self.assertTrue(_has_pickaxe_flag(["-S"]))

    def test_standalone_dash_g(self) -> None:
        self.assertTrue(_has_pickaxe_flag(["-G"]))

    def test_concatenated_dash_s_term(self) -> None:
        # -Sfoo is a single token when the user writes it without a space.
        self.assertTrue(_has_pickaxe_flag(["-Sfoo"]))

    def test_concatenated_dash_g_term(self) -> None:
        self.assertTrue(_has_pickaxe_flag(["-Gbar"]))

    def test_other_flags_not_matched(self) -> None:
        self.assertFalse(_has_pickaxe_flag(["-p", "--oneline", "-n"]))

    def test_empty_list_returns_false(self) -> None:
        self.assertFalse(_has_pickaxe_flag([]))


# ---------------------------------------------------------------------------
# _classify_git_command
# ---------------------------------------------------------------------------


class TestClassifyGitCommand(unittest.TestCase):
    def test_git_diff_with_ref_range_returns_change_manifest(self) -> None:
        self.assertEqual(
            _classify_git_command(["git", "diff", "main..HEAD"]),
            "get_change_manifest",
        )

    def test_git_log_with_ref_range_returns_commit_history(self) -> None:
        self.assertEqual(
            _classify_git_command(["git", "log", "main..HEAD"]),
            "get_commit_history",
        )

    def test_git_log_with_pickaxe_returns_function_context(self) -> None:
        # Pickaxe check must take priority over the ref-range check for git log.
        self.assertEqual(
            _classify_git_command(["git", "log", "-S", "foo"]),
            "get_function_context",
        )

    def test_git_log_pickaxe_priority_over_range(self) -> None:
        # Both pickaxe AND range present — pickaxe wins.
        self.assertEqual(
            _classify_git_command(["git", "log", "-S", "foo", "main..HEAD"]),
            "get_function_context",
        )

    def test_git_blame_returns_file_snapshots(self) -> None:
        self.assertEqual(
            _classify_git_command(["git", "blame", "src/main.rs"]),
            "get_file_snapshots",
        )

    def test_git_show_returns_file_snapshots(self) -> None:
        self.assertEqual(
            _classify_git_command(["git", "show", "abc123:src/main.rs"]),
            "get_file_snapshots",
        )

    def test_git_status_returns_none(self) -> None:
        self.assertIsNone(_classify_git_command(["git", "status"]))

    def test_git_add_returns_none(self) -> None:
        self.assertIsNone(_classify_git_command(["git", "add", "file.txt"]))

    def test_git_commit_returns_none(self) -> None:
        self.assertIsNone(_classify_git_command(["git", "commit", "-m", "msg"]))

    def test_git_push_returns_none(self) -> None:
        self.assertIsNone(_classify_git_command(["git", "push", "origin"]))

    def test_git_fetch_returns_none(self) -> None:
        self.assertIsNone(_classify_git_command(["git", "fetch", "origin"]))

    def test_non_git_command_returns_none(self) -> None:
        self.assertIsNone(_classify_git_command(["ls", "-la"]))

    def test_empty_list_returns_none(self) -> None:
        self.assertIsNone(_classify_git_command([]))

    def test_git_diff_without_range_returns_none(self) -> None:
        # Plain "git diff" (working-tree diff) — no range token, no redirect.
        self.assertIsNone(_classify_git_command(["git", "diff"]))


# ---------------------------------------------------------------------------
# tokenize_command
# ---------------------------------------------------------------------------


class TestTokenizeCommand(unittest.TestCase):
    def test_simple_git_diff(self) -> None:
        result = tokenize_command("git diff main..HEAD")
        self.assertEqual(result, [["git", "diff", "main..HEAD"]])

    def test_compound_and_command(self) -> None:
        result = tokenize_command("cd /tmp && git diff main..HEAD")
        self.assertIn(["git", "diff", "main..HEAD"], result)

    def test_subshell_parentheses(self) -> None:
        result = tokenize_command("(git log main..HEAD)")
        self.assertIn(["git", "log", "main..HEAD"], result)

    def test_pipeline(self) -> None:
        result = tokenize_command("git diff main..HEAD | grep foo")
        self.assertIn(["git", "diff", "main..HEAD"], result)

    def test_backtick_normalization(self) -> None:
        # Backticks are converted to spaces; outer git diff is still found.
        result = tokenize_command(
            "cd `git rev-parse --show-toplevel` && git diff main..HEAD"
        )
        git_diff = [
            c for c in result if c and c[0] == "git" and len(c) > 1 and c[1] == "diff"
        ]
        self.assertTrue(git_diff, f"git diff candidate missing from {result}")

    def test_variable_not_expanded(self) -> None:
        # $BASE must appear verbatim — never as the env-var's value.
        result = tokenize_command("git diff $BASE..HEAD")
        flat_tokens = [tok for cand in result for tok in cand]
        self.assertTrue(
            any("$BASE" in tok for tok in flat_tokens),
            f"Expected literal '$BASE' in tokens, got: {flat_tokens}",
        )

    def test_empty_command_returns_empty_list(self) -> None:
        self.assertEqual(tokenize_command(""), [])

    def test_heredoc_body_is_skipped(self) -> None:
        # git log inside a heredoc body must NOT produce a candidate.
        command = "cat <<EOF\ngit log a..b\nEOF\n"
        result = tokenize_command(command)
        git_log = [c for c in result if c and c[0] == "git"]
        self.assertFalse(git_log, f"git command inside heredoc body leaked: {result}")

    def test_heredoc_indented_delimiter_in_body_is_not_terminated(self) -> None:
        """Indented EOF inside a heredoc body must NOT terminate the heredoc."""
        command = "cat <<EOF\n  EOF\ngit diff main..HEAD\nEOF\n"
        result = tokenize_command(command)
        git_diff_candidates = [
            c for c in result if c and c[0] == "git" and len(c) > 1 and c[1] == "diff"
        ]
        self.assertFalse(
            git_diff_candidates,
            f"git diff inside heredoc body leaked after indented faux-tag: {result}",
        )

    def test_tokenizer_resumes_after_heredoc_terminator(self) -> None:
        # After the closing tag, git diff on the next line must be detected.
        command = "cat <<EOF\ngit log a..b\nEOF\ngit diff main..HEAD"
        result = tokenize_command(command)
        git_diff = [
            c for c in result if c and c[0] == "git" and len(c) > 1 and c[1] == "diff"
        ]
        self.assertTrue(git_diff, f"git diff after heredoc not found in: {result}")
        git_log = [
            c for c in result if c and c[0] == "git" and len(c) > 1 and c[1] == "log"
        ]
        self.assertFalse(
            git_log,
            f"git log inside heredoc body leaked into candidates: {result}",
        )

    def test_digit_starting_heredoc_tag_body_is_skipped(self) -> None:
        """Heredoc tag starting with a digit (e.g., ``<<'1'``) inside a
        multi-line quoted string must still be recognized by the _strip_heredocs
        pre-pass, and its body skipped, preventing ``gh pr diff`` (or any git
        command) from leaking into candidates."""
        command = "echo \"$(cat <<'1'\ngh pr diff 123 --repo owner/repo\n1\n) done\"\n"
        result = tokenize_command(command)
        gh_pr_diff = [
            c for c in result if c and len(c) >= 2 and c[0] == "gh" and c[1] == "pr"
        ]
        self.assertFalse(
            gh_pr_diff,
            f"gh pr diff inside digit-tag quoted-heredoc body leaked: {result}",
        )

    def test_two_heredocs_same_line_second_body_leaks(self) -> None:
        """Two ``<<TAG`` operators on the same line: only the first is found by
        the pre-pass. The second heredoc's body and closing tag leak into the
        token stream, causing false-positive hard blocks."""
        command = (
            "cat <<EOF1 <<EOF2\nEOF1 body\nEOF1\ngh pr diff 123\nEOF2\necho done\n"
        )
        result = tokenize_command(command)
        gh_pr_candidates = [
            c for c in result if c and len(c) >= 2 and c[0] == "gh" and c[1] == "pr"
        ]
        self.assertFalse(
            gh_pr_candidates,
            f"gh pr diff inside second heredoc body on same-line << leaked: {result}",
        )

    def test_two_heredocs_same_line_git_diff_leaks(self) -> None:
        """Triangulates same-line bug: git diff in second heredoc body also
        leaks. Tests a different watch-list command to confirm the bug isn't
        specific to ``gh pr diff``."""
        command = (
            "cat <<EOF1 <<EOF2\nEOF1 body\nEOF1\ngit diff main..HEAD\nEOF2\necho done\n"
        )
        result = tokenize_command(command)
        git_diff_candidates = [
            c for c in result if c and len(c) >= 2 and c[0] == "git" and c[1] == "diff"
        ]
        self.assertFalse(
            git_diff_candidates,
            f"git diff inside second heredoc body on same-line << leaked: {result}",
        )

    def test_two_heredocs_same_line_gh_api_contents_leaks(self) -> None:
        """Triangulates same-line bug for the gh api contents?ref= matcher."""
        from bash_redirect_hook import _matches_gh_api_contents

        command = (
            "cat <<EOF1 <<EOF2\n"
            "EOF1 body\n"
            "EOF1\n"
            "gh api repos/owner/repo/contents/path?ref=abc123\n"
            "EOF2\n"
            "echo done\n"
        )
        result = _matches_gh_api_contents(command)
        self.assertFalse(
            result,
            f"gh api contents?ref= inside second heredoc body on same-line << "
            f"triggered false match: {result}",
        )


# ---------------------------------------------------------------------------
# _drop_heredoc_bodies
# ---------------------------------------------------------------------------


class TestDropHeredocBodies(unittest.TestCase):
    def test_simple_heredoc_body_is_dropped(self) -> None:
        tokens = ["cat", "<<", "EOF", "\n", "git", "\n", "EOF", "\n", "echo", "done"]
        result = _drop_heredoc_bodies(tokens)
        self.assertNotIn("git", result)
        self.assertIn("echo", result)
        self.assertIn("done", result)

    def test_dash_form_heredoc_body_is_dropped(self) -> None:
        # shlex glues the "-" onto the tag word: << then -EOF
        tokens = ["cat", "<<", "-EOF", "\n", "git", "\n", "EOF", "\n", "echo", "done"]
        result = _drop_heredoc_bodies(tokens)
        self.assertNotIn("git", result)
        self.assertIn("echo", result)

    def test_content_before_heredoc_is_preserved(self) -> None:
        tokens = ["echo", "hi", "<<", "EOF", "\n", "body", "\n", "EOF"]
        result = _drop_heredoc_bodies(tokens)
        self.assertIn("echo", result)
        self.assertIn("hi", result)
        self.assertNotIn("body", result)

    def test_empty_token_list_returns_empty(self) -> None:
        self.assertEqual(_drop_heredoc_bodies([]), [])

    def test_no_heredoc_passes_through_unchanged(self) -> None:
        tokens = ["git", "diff", "main..HEAD"]
        self.assertEqual(_drop_heredoc_bodies(tokens), tokens)

    def test_invalid_heredoc_tag_drops_all_remaining_tokens(self) -> None:
        """``<<`` followed by an empty/invalid tag token (e.g., ``''``
        or ``-`` alone) causes ``_heredoc_tag`` to return ``None``,
        which triggers a ``break`` that drops all remaining tokens
        instead of just skipping the malformed ``<<``."""
        tokens = ["echo", "hi", "<<", "''", "\n", "git", "diff", "main..HEAD"]
        result = _drop_heredoc_bodies(tokens)
        self.assertIn(
            "git", result, f"git diff after malformed << was dropped: {result}"
        )
        self.assertNotIn(
            "<<", result, f"<< operator was preserved after malformed tag: {result}"
        )


# ---------------------------------------------------------------------------
# _advice_with_echo
# ---------------------------------------------------------------------------


class TestAdviceWithEcho(unittest.TestCase):
    def test_echo_appends_verbatim_tokens(self) -> None:
        tokens = ["git", "diff", "main..HEAD"]
        result = _advice_with_echo("base advice", tokens)
        self.assertIn("You ran: git diff main..HEAD", result)

    def test_variable_not_expanded_in_echo(self) -> None:
        # The token list contains the literal string "$BASE". The echo must
        # reproduce it verbatim — proving no os.path.expandvars or shell
        # expansion happened anywhere in the call chain.
        tokens = ["git", "diff", "$BASE..HEAD"]
        result = _advice_with_echo("base advice", tokens)
        self.assertIn(
            "$BASE..HEAD",
            result,
            f"Expected literal '$BASE..HEAD' in advice, got: {result!r}",
        )

    def test_base_advice_is_included(self) -> None:
        tokens = ["git", "log", "main..HEAD"]
        result = _advice_with_echo("USE GET_COMMIT_HISTORY", tokens)
        self.assertIn("USE GET_COMMIT_HISTORY", result)


# ---------------------------------------------------------------------------
# _matches_gh_pr_diff
# ---------------------------------------------------------------------------


class TestMatchesGhPrDiff(unittest.TestCase):
    def test_plain_gh_pr_diff_is_matched(self) -> None:
        self.assertTrue(_matches_gh_pr_diff("gh pr diff 123"))

    def test_compound_gh_pr_diff_is_matched(self) -> None:
        self.assertTrue(_matches_gh_pr_diff("cd /tmp && gh pr diff 123"))

    def test_gh_pr_view_is_not_matched(self) -> None:
        self.assertFalse(_matches_gh_pr_diff("gh pr view 123"))

    def test_git_diff_is_not_matched(self) -> None:
        self.assertFalse(_matches_gh_pr_diff("git diff main..HEAD"))

    def test_empty_command_is_not_matched(self) -> None:
        self.assertFalse(_matches_gh_pr_diff(""))


# ---------------------------------------------------------------------------
# _matches_gh_api_contents
# ---------------------------------------------------------------------------


class TestMatchesGhApiContents(unittest.TestCase):
    def test_gh_api_contents_with_ref_is_matched(self) -> None:
        self.assertTrue(
            _matches_gh_api_contents("gh api repos/owner/repo/contents/path?ref=abc123")
        )

    def test_gh_api_contents_with_multiple_params_is_matched(self) -> None:
        self.assertTrue(
            _matches_gh_api_contents(
                "gh api repos/owner/repo/contents/path?foo=bar&ref=abc123"
            )
        )

    def test_gh_api_contents_without_ref_is_not_matched(self) -> None:
        self.assertFalse(
            _matches_gh_api_contents("gh api repos/owner/repo/contents/path")
        )

    def test_gh_api_other_endpoint_is_not_matched(self) -> None:
        self.assertFalse(_matches_gh_api_contents("gh api repos/owner/repo/issues"))

    def test_gh_api_contents_no_path_before_ref_is_matched(self) -> None:
        """``contents/?ref=sha`` with no path segment before the query must
        still match — the old ``.+`` regex required at least one char between
        ``contents/`` and ``?ref=``."""
        self.assertTrue(
            _matches_gh_api_contents("gh api repos/owner/repo/contents/?ref=abc123")
        )

    def test_empty_command_is_not_matched(self) -> None:
        self.assertFalse(_matches_gh_api_contents(""))


# ---------------------------------------------------------------------------
# decide_redirect
# ---------------------------------------------------------------------------


class TestDecideRedirect(unittest.TestCase):
    def _bash_payload(self, command: str) -> dict[str, str | dict[str, str]]:
        return {
            "tool_name": "Bash",
            "tool_input": {"command": command},
            "hook_event_name": "PreToolUse",
        }

    def test_git_diff_with_range_returns_advise(self) -> None:
        decision = decide_redirect(self._bash_payload("git diff main..HEAD"))
        self.assertEqual(decision.mode, "advise")
        self.assertIn("get_change_manifest", decision.advice)

    def test_git_log_with_range_returns_advise(self) -> None:
        decision = decide_redirect(self._bash_payload("git log main..HEAD"))
        self.assertEqual(decision.mode, "advise")
        self.assertIn("get_commit_history", decision.advice)

    def test_git_log_pickaxe_returns_advise_for_function_context(self) -> None:
        decision = decide_redirect(self._bash_payload("git log -S foo"))
        self.assertEqual(decision.mode, "advise")
        self.assertIn("get_function_context", decision.advice)

    def test_git_blame_returns_advise_for_file_snapshots(self) -> None:
        decision = decide_redirect(self._bash_payload("git blame src/main.rs"))
        self.assertEqual(decision.mode, "advise")
        self.assertIn("get_file_snapshots", decision.advice)

    def test_git_status_returns_silent(self) -> None:
        decision = decide_redirect(self._bash_payload("git status"))
        self.assertEqual(decision.mode, "silent")

    def test_gh_pr_diff_returns_block(self) -> None:
        decision = decide_redirect(self._bash_payload("gh pr diff 123"))
        self.assertEqual(decision.mode, "block")
        self.assertIn("get_change_manifest", decision.message)

    def test_mcp_github_get_commit_tool_name_returns_block(self) -> None:
        payload = {
            "tool_name": "mcp__github__get_commit",
            "tool_input": {},
            "hook_event_name": "PreToolUse",
        }
        decision = decide_redirect(payload)
        self.assertEqual(decision.mode, "block")
        self.assertIn("git-prism", decision.message)

    def test_mcp_github_get_commit_as_bash_command_returns_block(self) -> None:
        decision = decide_redirect(
            self._bash_payload("mcp__github__get_commit owner=foo repo=bar sha=abc")
        )
        self.assertEqual(decision.mode, "block")

    def test_mcp_github_list_commits_tool_name_returns_block(self) -> None:
        payload = {
            "tool_name": "mcp__github__list_commits",
            "tool_input": {},
            "hook_event_name": "PreToolUse",
        }
        decision = decide_redirect(payload)
        self.assertEqual(decision.mode, "block")

    def test_mcp_github_list_commits_as_bash_command_returns_block(self) -> None:
        decision = decide_redirect(
            self._bash_payload("mcp__github__list_commits owner=foo repo=bar")
        )
        self.assertEqual(decision.mode, "block")

    def test_non_bash_tool_is_silent(self) -> None:
        payload = {
            "tool_name": "Read",
            "tool_input": {"file_path": "/tmp/file"},
            "hook_event_name": "PreToolUse",
        }
        decision = decide_redirect(payload)
        self.assertEqual(decision.mode, "silent")

    def test_empty_command_is_silent(self) -> None:
        decision = decide_redirect(self._bash_payload(""))
        self.assertEqual(decision.mode, "silent")

    def test_missing_tool_name_is_silent(self) -> None:
        decision = decide_redirect({})
        self.assertEqual(decision.mode, "silent")

    def test_gh_api_contents_with_ref_returns_advise(self) -> None:
        decision = decide_redirect(
            self._bash_payload("gh api repos/owner/repo/contents/path?ref=abc123")
        )
        self.assertEqual(decision.mode, "advise")
        self.assertIn("get_file_snapshots", decision.advice)

    def test_gh_api_contents_without_ref_returns_silent(self) -> None:
        decision = decide_redirect(
            self._bash_payload("gh api repos/owner/repo/contents/path")
        )
        self.assertEqual(decision.mode, "silent")

    def test_variable_in_command_does_not_expand(self) -> None:
        # The advice text must contain the literal "$BASE", not an expanded value.
        decision = decide_redirect(self._bash_payload("git diff $BASE..HEAD"))
        self.assertEqual(decision.mode, "advise")
        self.assertIn(
            "$BASE",
            decision.advice,
            f"Expected literal '$BASE' in advice, got: {decision.advice!r}",
        )

    def test_heredoc_body_git_command_is_not_advised(self) -> None:
        # git log inside a heredoc body must NOT trigger advice.
        command = "cat <<EOF\ngit log a..b\nEOF\n"
        decision = decide_redirect(self._bash_payload(command))
        self.assertEqual(
            decision.mode,
            "silent",
            f"Expected silent for heredoc-body git, got mode={decision.mode!r}",
        )

    def test_git_diff_after_heredoc_is_advised(self) -> None:
        # After the heredoc closes, git diff must still be detected.
        command = "cat <<EOF\ngit log a..b\nEOF\ngit diff main..HEAD"
        decision = decide_redirect(self._bash_payload(command))
        self.assertEqual(decision.mode, "advise")
        self.assertIn("get_change_manifest", decision.advice)


# ---------------------------------------------------------------------------
# Unicode encoding edge cases
# ---------------------------------------------------------------------------


class TestUnicodeEncoding(unittest.TestCase):
    """The hook messages added in #265 contain the Unicode arrow '←'.

    The advisory path JSON-encodes the text (json.dumps defaults to
    ensure_ascii=True), so Unicode is escaped to ← and is safe.
    The block path writes the message directly to sys.stderr, which
    raises UnicodeEncodeError in ASCII-only locales and silently
    downgrades a hard block to a silent allow.
    """

    def test_advisory_path_is_ascii_safe(self):
        """Advisory output must be ASCII-safe after json.dumps."""
        import io
        fake_stdout = io.StringIO()
        old_stdout = sys.stdout
        sys.stdout = fake_stdout
        try:
            _emit_advice(BLOCK_GH_PR_DIFF)
        finally:
            sys.stdout = old_stdout
        output = fake_stdout.getvalue()
        # No literal Unicode arrows; json.dumps would escape them anyway.
        self.assertNotIn("", output)
        # Verify the message was emitted and contains ASCII-only arrows.
        self.assertIn("-->", output)

    def test_block_path_stderr_fails_on_ascii_locale(self):
        """Block path must survive ASCII-only stderr without crashing."""
        import io
        # Create a TextIOWrapper that enforces ASCII encoding.
        raw = io.BytesIO()
        ascii_stderr = io.TextIOWrapper(raw, encoding="ascii")
        old_stderr = sys.stderr
        sys.stderr = ascii_stderr
        try:
            # This is exactly what main() does for a block decision.
            sys.stderr.write(BLOCK_GH_PR_DIFF)
            sys.stderr.write("\n")
            ascii_stderr.flush()
        except UnicodeEncodeError:
            # This exception is the bug: the block is lost and the
            # outer except in main() returns 0 (silent allow).
            self.fail(
                "BLOCK_GH_PR_DIFF raised UnicodeEncodeError on ASCII stderr; "
                "main() would silently downgrade a hard block to allow"
            )
        finally:
            sys.stderr = old_stderr

    def test_block_mcp_github_get_commit_is_ascii_safe(self):
        """BLOCK_MCP_GITHUB_GET_COMMIT must contain only ASCII characters."""
        for i, ch in enumerate(BLOCK_MCP_GITHUB_GET_COMMIT):
            self.assertLess(
                ord(ch),
                128,
                f"Non-ASCII char {ch!r} (U+{ord(ch):04X}) at index {i} in "
                f"BLOCK_MCP_GITHUB_GET_COMMIT",
            )

    def test_block_mcp_github_get_commit_stderr_survives_ascii_locale(self):
        """Block path for mcp__github__get_commit must survive ASCII-only stderr."""
        import io
        raw = io.BytesIO()
        ascii_stderr = io.TextIOWrapper(raw, encoding="ascii")
        old_stderr = sys.stderr
        sys.stderr = ascii_stderr
        try:
            sys.stderr.write(BLOCK_MCP_GITHUB_GET_COMMIT)
            sys.stderr.write("\n")
            ascii_stderr.flush()
        except UnicodeEncodeError:
            self.fail(
                "BLOCK_MCP_GITHUB_GET_COMMIT raised UnicodeEncodeError on ASCII stderr; "
                "main() would silently downgrade a hard block to allow"
            )
        finally:
            sys.stderr = old_stderr

    def test_malformed_json_warning_survives_ascii_locale(self):
        """Malformed JSON warning must survive ASCII-only stderr without crashing."""
        import io
        raw = io.BytesIO()
        ascii_stderr = io.TextIOWrapper(raw, encoding="ascii")
        old_stderr = sys.stderr
        sys.stderr = ascii_stderr
        fake_stdin = io.StringIO("this is not json {")
        try:
            result = _read_payload(fake_stdin)
            self.assertIsNone(result)
        except UnicodeEncodeError:
            self.fail(
                "_read_payload raised UnicodeEncodeError on ASCII stderr when "
                "emitting malformed JSON warning; main() would crash instead of "
                "returning fail-open exit 0"
            )
        finally:
            sys.stderr = old_stderr

    def test_main_unexpected_error_handler_survives_ascii_locale(self):
        """main()'s outer exception handler must survive ASCII-only stderr."""
        import io
        raw = io.BytesIO()
        ascii_stderr = io.TextIOWrapper(raw, encoding="ascii")
        old_stderr = sys.stderr
        old_stdin = sys.stdin
        sys.stderr = ascii_stderr

        class BrokenStdin:
            def read(self):
                raise RuntimeError("boom")

        sys.stdin = BrokenStdin()
        try:
            result = main()
            # main() should return 0 (fail-open) even when stdin read fails.
            self.assertEqual(result, 0)
        except UnicodeEncodeError:
            self.fail(
                "main() raised UnicodeEncodeError on ASCII stderr when handling "
                "unexpected error; hook would crash instead of fail-open"
            )
        finally:
            sys.stderr = old_stderr
            sys.stdin = old_stdin


if __name__ == "__main__":
    unittest.main()