Repository navigation
Expand file tree
/
Copy pathprepare-commit-msg
More file actions
executable file
·1196 lines (1072 loc) · 62.2 KB
/
Copy pathprepare-commit-msg
File metadata and controls
executable file
·1196 lines (1072 loc) · 62.2 KB
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
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
#!/usr/bin/env python3
# prepare-commit-msg 훅: 커밋 규칙 강제를 훅 하나로 모으는 재설계의 본체다
# (decision-18). 하는 일은 재생·병합 커밋 면제, 에디터 경로 거부, 커밋 메시지 검증,
# 트레일러 삽입이다.
# 메시지 검증은 GF-127에서 commit-msg로부터 옮겨왔고 commit-msg는 그때 삭제됐다 —
# 둘 다 남기면 같은 검증이 두 번 돈다. 트레일러 삽입은 GF-128에서 post-commit으로부터
# 옮겨왔고 post-commit은 그때 삭제됐다 — 남기면 이 훅이 넣은 트레일러 뒤에
# post-commit이 Signed-off-by와 Verify-Bypassed를 또 붙인다.
#
# GF-126에서 언어별 lint를 pre-commit에서 이 파일로 옮겼다가 GF-135에서 그 lint를
# 통째로 지웠다 — 언어 lint는 coding-agent-git-commit-tool의 범위(형식·출처 기록·이력 불변, decision-25)
# 어디에도 속하지 않는 기능이었다(decision-23).
#
# 이 훅을 본체로 삼는 근거(실측 절차와 결과는 doc-15): prepare-commit-msg는
# --no-verify로도 건너뛸 수 없고, 커밋 객체가 만들어지기 전에 돌며, exit 1로 커밋을
# 막는다. 대가는 에디터 경로를 포기하는 것이다 — 에디터는 이 훅보다 뒤에 열리므로
# 사람이 타이핑한 최종 메시지를 이 훅은 볼 수 없다.
#
# 훅 인자는 $1=메시지 파일 경로, $2=source, $3=ref다. 이 훅은 면제 판정에 $2를,
# 에디터 경로 판정에 $1의 내용을 쓴다 — $2로 에디터를 판정할 수 없는 이유는 아래
# has_author_message()의 주석에 적었다.
#
# template/(GF-9)로 설치된 저장소는 .git/hooks/prepare-commit-msg 자체가 coding-agent-git-commit-tool
# 클론을 가리키는 심볼릭 링크다. 링크가 놓인 .git/hooks/가 아니라 링크가 가리키는
# 실제 위치를 기준으로 gitformat.conf를 찾아야 한다(GF-16) — realpath가 링크 체인을
# 따라간다.
#
# 각 판단 단계는 이름 있는 함수로 분해돼 있고(GF-87), 실행 순서는 파일 맨 아래에
# 나열돼 있다. 다른 훅과 겹치는 블록(자기 위치 해석, conf 읽기 가드)은 공유 모듈로
# 빼지 않고 파일마다 독립적으로 중복을 유지한다 — 파일 하나만 읽으면 그 훅의 동작을
# 전부 파악할 수 있어야 한다는 감사 가능성 요구사항이다(decision-16).
import fnmatch
import json
import os
import re
import subprocess
import sys
from pathlib import Path
# 로케일이 UTF-8을 제공하지 않는 환경에서는 Python의 stdout 인코딩이 ascii로 떨어져,
# 이 파일의 한국어 메시지를 출력하는 순간 UnicodeEncodeError로 훅이 죽는다 — "도구가
# 없어 건너뜀"처럼 무해해야 하는 경로에서도 커밋이 트레이스백과 함께 막힌다(GF-116
# 실측: LC_ALL=C에 C.UTF-8이 없는 조건을 재현해 확인). sh 시절의 LC_ALL=C.UTF-8
# 하드코딩(GF-83)은 Python 전환으로 사라졌지만, 같은 위험이 출력 인코딩으로 옮겨온
# 것이다. 메시지는 UTF-8로 쓰여 있으니 출력 인코딩도 UTF-8로 고정한다 — 터미널이
# UTF-8을 못 읽으면 글자가 깨져 보이지만, 죽어서 커밋을 막는 것보다 낫다.
# errors="replace"는 서로게이트 등 인코딩 불가 문자에서도 죽지 않게 하는 보험이다.
for _stream in (sys.stdout, sys.stderr):
try:
_stream.reconfigure(encoding="utf-8", errors="replace")
except (AttributeError, ValueError, OSError):
pass
# git이 넘기는 인자. $1=메시지 파일, $2=source. 인자가 없는 호출은 git에서 오지
# 않지만, 그때 IndexError로 죽는 대신 빈 값으로 두면 아래 판단이 무해하게 통과한다.
#
# source($2)는 면제 판정에만 쓴다. 에디터 경로 판정에 쓰면 안 된다 — commit.template이
# 설정돼 있지 않으면 git은 source 인자를 **아예 넘기지 않는다**(실측: argc=1,
# source 빈 값). 그래서 source == "template" 검사는 템플릿이 설정된 저장소에서만
# 작동하고, 설정이 없으면 에디터 커밋이 조용히 통과한다. GF-132에서 commit.template
# 설정을 제거할 예정이므로 그 검사는 곧 어디서도 작동하지 않게 된다.
MSG_FILE = sys.argv[1] if len(sys.argv) > 1 else ""
SOURCE = sys.argv[2] if len(sys.argv) > 2 else ""
GIT_DIR = subprocess.run(
["git", "rev-parse", "--git-dir"],
stdout=subprocess.PIPE,
encoding="utf-8",
check=True,
).stdout.rstrip("\n")
HOOK_DIR = os.path.dirname(os.path.realpath(__file__))
CONF = os.path.join(HOOK_DIR, "gitformat.conf")
# gitformat.conf 자체를 못 읽으면 이후 git config --file 읽기가 하나씩 실패하면서
# 원인을 알기 어려운 에러로 이어진다. 여기서 미리 검증해 원인을 명확히 알려준다.
# 이 블록은 CONF를 읽는 다른 파일들에도 byte-identical하게 있다.
if subprocess.run(
["git", "config", "--file", CONF, "--list"],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
encoding="utf-8",
check=False,
).returncode != 0:
print(f"gitformat: gitformat.conf를 읽을 수 없습니다: {CONF}", file=sys.stderr)
sys.exit(1)
def conf_get(key):
# git config --get은 키가 없어도 빈 문자열로 성공할 수 있다(GF-35).
return subprocess.run(
["git", "config", "--file", CONF, "--get", key],
capture_output=True,
encoding="utf-8",
check=False,
).stdout.rstrip("\n")
def conf_get_all(key):
return [
line
for line in subprocess.run(
["git", "config", "--file", CONF, "--get-all", key],
capture_output=True,
encoding="utf-8",
check=False,
).stdout.splitlines()
if line
]
def local_get_all(key):
return [
line
for line in subprocess.run(
["git", "config", "--get-all", key],
capture_output=True,
encoding="utf-8",
check=False,
).stdout.splitlines()
if line
]
def fail(*lines):
for line in lines:
print(line, file=sys.stderr)
sys.exit(1)
# 재생·병합 커밋 면제(decision-18, AC #1). 재생 커밋은 이미 검증된 커밋의 복제다 —
# 다시 도장을 찍는 것도, 토큰을 다시 세는 것도 틀렸다. rebase 중 post-commit이
# cherry-pick 도중 amend를 시도해 트레이스백을 냈던 문제(archive DRAFT-18)를 원인
# 단계에서 없애는 구조적 장치이기도 하다.
#
# source와 진행 상태 파일을 OR로 묶는 이유는 어느 한쪽만으로는 판별이 안 되기
# 때문이다(git 2.54.0 실측, 2026-09-26):
# * cherry-pick과 rebase 재생은 source=message다 — CHERRY_PICK_HEAD로만 잡힌다
# * MERGE_HEAD가 있는 상태에서 git commit -m 하면 source=message다 — 파일로만 잡힌다
# * 충돌로 멈춘 revert를 이어 커밋할 때는 REVERT_HEAD가 있다
# * 반대로 SQUASH_MSG만 있는 커밋(squash 병합 뒤 커밋)은 네 파일이 하나도 없고
# source=squash로만 잡힌다
def is_replay_commit():
if SOURCE in ("merge", "squash"):
return True
return any(
os.path.isfile(os.path.join(GIT_DIR, name))
for name in ("MERGE_HEAD", "CHERRY_PICK_HEAD", "REBASE_HEAD", "REVERT_HEAD")
)
# 메시지 파일에 사람이 쓴 내용이 있는지 본다. git은 상태 안내를 주석(#)으로 채워두므로,
# 주석과 빈 줄을 뺀 나머지가 하나라도 있으면 -m/-F로 메시지가 이미 들어온 것이고,
# 하나도 없으면 이 훅이 끝난 뒤 에디터가 열려 사람이 쓸 차례라는 뜻이다.
#
# 이것이 source($2)보다 신뢰할 수 있는 판별자다(실측, 2026-09-26):
# * commit.template 있음 + 에디터 → source=template, 비주석 내용 0줄
# * commit.template 없음 + 에디터 → **argc=1, source 없음**, 비주석 내용 0줄
# * -m / -F / -F - → source=message, 비주석 내용 1줄 이상
# * --amend --no-edit → source=commit, 직전 메시지가 들어 있어 1줄 이상
# source로 판정하면 두 번째 줄이 빠져나간다 — CI(전역 commit.template이 없는 환경)에서
# 에디터 커밋이 통과하는 것으로 실제로 드러났다.
#
# 주석 문자는 열 0의 '#'로 본다 — 아래 message_lines()의 본문 판정과 같은 규약이다.
def has_author_message():
if not MSG_FILE:
return True
try:
raw = Path(MSG_FILE).read_bytes().decode("utf-8", errors="replace")
except OSError:
# 메시지 파일을 못 읽는 상황은 git에서 오지 않는다. 읽기 실패를 "내용 있음"으로
# 취급해 무해하게 통과시킨다 - 여기서 커밋을 막으면 원인을 알 수 없는 거부가 된다.
return True
return any(
line.strip() and not line.startswith("#") for line in raw.split("\n")
)
# 에디터 경로 거부(decision-18, AC #2). 에디터는 이 훅이 끝난 뒤에 열리므로 훅이 보는
# 메시지 파일에는 아직 사람이 쓸 내용이 없다 — 최종 메시지를 검증할 방법이 원리적으로
# 없다. 거부하면 "통과한 커밋은 모두 검증을 거쳤다"가 성립하고, 그것이 검증을 이 훅으로
# 옮겨온 GF-127의 전제다.
def reject_editor_path():
if has_author_message():
return
fail(
"prepare-commit-msg: 이 저장소는 git commit -m 으로만 커밋할 수 있습니다.",
" 에디터는 이 훅이 끝난 뒤에 열려서, 사람이 타이핑한 최종 메시지를 훅이",
" 볼 수 없습니다 — 그래서 에디터 경로는 검증이 불가능합니다(decision-18).",
' 예: git commit -m "[feat][hooks] 설명"',
" 긴 본문은 파일에 써서 git commit -F <파일>로 넘기세요.",
)
def message_lines():
# 이 훅은 git이 주석을 걷어내기 **전의** 메시지 파일을 본다. -m/-F 경로에서는
# git이 공백 정리만 한 뒤 바로 이 훅을 부르고, 이 뒤로는 메시지를 바꾸는 단계가
# 없으므로(에디터 경로는 위에서 거부했다) 구 commit-msg가 보던 것과 같은 내용이다.
# 그래서 주석 처리 규약도 commit-msg의 것을 그대로 옮긴다 — 열 0의 '#'로 시작하는
# 줄만 뺀다. commit.cleanup이나 core.commentChar 설정은 보지 않는다(commit-msg도
# 보지 않았다).
#
# 커밋 메시지 원문은 유효하지 않은 UTF-8 바이트를 포함할 수 있다(GF-80, 실제
# CI에서 재현). errors="replace"로 읽어 깨진 바이트 때문에 훅이 예외로 죽거나
# 형식이 정상인 커밋을 잘못 거부하는 일이 없게 한다.
if not MSG_FILE:
fail("prepare-commit-msg: 메시지 파일 경로 인자가 없어 메시지를 검증할 수 없습니다.")
text = Path(MSG_FILE).read_bytes().decode("utf-8", errors="replace")
# grep과 동일하게 개행(\n)만 줄 경계로 취급한다 — splitlines()는 \r 등도
# 줄 경계로 보아 길이 계산이 sh 버전과 어긋난다.
lines = text.split("\n")
if lines and lines[-1] == "":
lines.pop()
return [line for line in lines if not line.startswith("#")]
# git revert가 만드는 제목인지 본다(GF-127, 2026-10-04 유저 결정 (b)).
#
# 충돌 없는 `git revert --no-edit`은 REVERT_HEAD를 남기지 않고 MERGE_MSG만 둔 채
# source=message로 이 훅에 온다(doc-15, GF-125 코멘트 실측) — 재생 커밋 면제에 걸리지
# 않고 검증까지 내려온다. 그런데 git이 만드는 제목은 `Revert "<원래 제목>"`이라
# [type][subsystem] 형식에 맞지 않는다. 면제 조건에 MERGE_MSG를 넣으면 cherry-pick까지
# 면제가 넓어지고, revert마다 -m을 요구하면 git 기본 동작을 막으므로 이 제목 형태만
# 형식 규칙의 예외로 인정한다.
#
# 인정하는 형태는 git이 실제로 만드는 두 가지다(git 2.54.0 실측):
# * `Revert "<제목>"` — 일반 커밋을 revert할 때
# * `Reapply "<제목>"` — `Revert "<제목>"` 커밋을 다시 revert할 때. git 2.43부터
# revert의 revert는 `Revert "Revert "...""`가 아니라 이 형태다. 이것을 빼면 revert를
# 되돌리는 revert가 거부된다.
# 중첩(`Revert "Reapply "...""`, 옛 git의 `Revert "Revert "...""`)은 바깥 따옴표만 보므로
# 함께 통과한다. 안쪽 제목은 검증하지 않는다 — 원래 커밋이 이 도구 이전의 것일 수 있고,
# 어차피 git이 원래 제목을 그대로 옮겨 적은 것이다.
#
# 한계: 사람이 `git commit -m 'Revert "..."'`로 직접 써도 같은 예외를 받는다. 이 훅은
# 그 제목을 git이 만든 것인지 사람이 쓴 것인지 구분하지 않는다.
REVERT_SUBJECT_RE = re.compile(r'(Revert|Reapply) ".+"')
def is_revert_subject(subject_line):
return REVERT_SUBJECT_RE.fullmatch(subject_line) is not None
# 커밋 제목이 [type][subsystem] 형식인지, 본문/트레일러가 있으면 제목과의
# 사이에 빈 줄이 있는지 검증한다.
def validate_format(content):
# 커밋 타입 목록은 gitformat.conf(다중값 gitformat.type)에서 읽는다 -
# .gitmessage에 적힌 사람이 읽는 목록과 일치하는지는
# tests/test_config_keys_match_hooks.py가 검증한다(런타임 결합 없이 테스트로만 보장).
types = conf_get_all("gitformat.type")
# 목록이 비면 정규식이 ^()...가 돼 모든 커밋이 알 수 없는 이유로 거부된다
# (GF-76). 조용히 진행하지 않고 원인을 밝히며 멈춘다. revert 제목 예외보다
# 먼저 확인한다 — 설정이 깨진 상태를 revert 커밋이 가려서는 안 된다.
if not types:
fail(
f"prepare-commit-msg: gitformat.conf에서 커밋 type 목록을 읽을 수 없습니다: {CONF}"
)
subject_line = content[0] if content else ""
alternation = "|".join(re.escape(t) for t in types)
if not is_revert_subject(subject_line) and not re.match(
rf"\[({alternation})\](\[[a-zA-Z0-9_.-]+\])? .+", subject_line
):
fail(
"prepare-commit-msg: 커밋 메시지가 [type][subsystem] 형식이 아닙니다.",
" 형식: [type][subsystem] <description> (subsystem 생략 가능: [type] <description>)",
" 허용 type: feat fix docs style refactor perf test build ci chore revert",
" 예: [fix][parser] 빈 입력 처리",
)
# 본문/트레일러가 있으면 제목과의 사이에 빈 줄이 필요하다(리누스 스타일). 주석 줄은
# 제외하고, 두 번째 non-comment 줄이 존재하는데 비어있지 않으면(=제목 바로 다음
# 줄에 내용이 이어지면, 그게 한 줄짜리 본문이든 여러 줄이든) 거부한다.
if len(content) > 1 and content[1]:
fail(
"prepare-commit-msg: 본문/트레일러가 있으면 제목과의 사이에 빈 줄이 필요합니다.",
" 형식: [type][subsystem] <description>",
" (빈 줄)",
" <본문 또는 트레일러>",
)
# subject 글자수(50자 이내) / 본문 줄 길이(72자 이내) 검증(GF-83). 위반하면 이 훅의
# 다른 형식 위반과 동일하게 거부한다(경고가 아님 - 근거는 GF-83 태스크 노트 참고).
#
# 글자 수는 바이트가 아니라 유니코드 코드포인트 단위로 센다. 이 저장소의 커밋
# 이력은 한글 위주라(한글 1자는 UTF-8로 3바이트) 바이트 기준으로 세면 "50자"
# 안내와 실제 강제 기준이 크게 어긋난다 - len(str)이 곧 코드포인트 수다.
#
# revert 제목(is_revert_subject)은 50자 제한에서 뺀다(GF-127). git이 원래 제목을
# 따옴표로 감싸 `Revert "`/`Reapply "` 접두어를 붙이므로, 50자에 꽉 찬 정상 커밋을
# revert하면 git이 만든 제목이 60자를 넘는다. 그 제목은 사람이 줄일 수 있는 값이
# 아니라서 길이를 강제하면 형식 예외를 둔 의미가 없어진다. 본문 줄 72자 제한은
# revert에도 그대로 적용한다 — git이 붙이는 "This reverts commit <해시>." 줄은
# SHA-1 저장소에서 72자 안에 들어간다.
#
# 본문 중 트레일러로 등록된 토큰(gitformat.conf의 [gitformat "trailer"] 값,
# 예: Task-Id/Fixes/BREAKING CHANGE)으로 시작하는 줄은 72자 제한에서 예외로
# 둔다 - 해시/설명이 길어질 수 있는 footer는 애초에 줄바꿈 대상이 아니다.
def validate_length(content):
subject_max = int(conf_get("gitformat.subjectMaxLength"))
body_max = int(conf_get("gitformat.bodyLineMaxLength"))
subject_line = content[0] if content else ""
subject_len = len(subject_line)
if subject_len > subject_max and not is_revert_subject(subject_line):
fail(
f"prepare-commit-msg: 제목이 {subject_max}자를 넘습니다 (현재 {subject_len}자).",
" 형식: [type][subsystem] <description>",
)
# 알려진 트레일러 토큰 목록을 gitformat.conf에서 읽어 "Token: " 예외 패턴을
# 만든다 - 임의의 "단어: "를 전부 예외 처리하면 본문 문장에 콜론이 섞였을 때
# (예: "주의: ...") 검증을 조용히 우회하게 되므로, 등록된 토큰만 인정한다.
raw_trailers = subprocess.run(
["git", "config", "--file", CONF, "--get-regexp", r"^gitformat\.trailer\."],
capture_output=True,
encoding="utf-8",
check=False,
).stdout.splitlines()
tokens = [line.split(" ", 1)[1] for line in raw_trailers if " " in line]
# "BREAKING CHANGE"처럼 공백이 든 토큰도 있으므로 값을 정규식으로 쓰기 전에
# 반드시 이스케이프한다.
trailer_re = (
re.compile(rf"({'|'.join(re.escape(t) for t in tokens)}): ") if tokens else None
)
for line in content[2:]:
if not line:
continue
if trailer_re is not None and trailer_re.match(line):
continue
line_len = len(line)
if line_len > body_max:
fail(
f"prepare-commit-msg: 본문 줄이 {body_max}자를 넘습니다 (현재 {line_len}자): {line}",
f" 본문은 한 줄 {body_max}자 이내로 줄바꿈하세요 (등록된 footer 트레일러 줄은 예외).",
)
# Fixes: <hash> 트레일러는 강제하지 않지만(원인 커밋을 항상 알 수 있는 건 아님),
# 있으면 참조 해시가 저장소에 실재하는 커밋인지 검증한다 - 오타/잘못된 참조를 잡는다.
def validate_fixes_trailer(content):
trailer_fixes = conf_get("gitformat.trailer.fixes")
prefix = f"{trailer_fixes}: "
fixes_line = next((line for line in content if line.startswith(prefix)), "")
if not fixes_line:
return
fixes_hash = fixes_line[len(prefix) :].split(" ")[0]
if subprocess.run(
["git", "rev-parse", "--quiet", "--verify", f"{fixes_hash}^{{commit}}"],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
encoding="utf-8",
check=False,
).returncode != 0:
fail(
f"prepare-commit-msg: {trailer_fixes}: 트레일러가 가리키는 커밋({fixes_hash})을 찾을 수 없습니다."
)
# Task-Id 접두어와 현재 브랜치명. 브랜치 강제(enforce_task_id_branch)와 Task-Id
# 트레일러(trailer_task_id)가 같은 값을 쓴다 — GF-128 전에는 post-commit이 이 블록을
# 사본으로 들고 있었지만, 두 쪽이 이 파일로 모이면서 함수 하나로 합쳤다.
# detached HEAD면 브랜치명 자리에 "HEAD"를 돌려준다.
def task_prefix_and_branch():
task_prefix_default = conf_get("gitformat.taskPrefixDefault")
task_prefix = subprocess.run(
["git", "config", "--get", "gitformat.taskPrefix"],
capture_output=True,
encoding="utf-8",
check=False,
).stdout.rstrip("\n")
# git config --get은 빈 문자열 설정도 성공으로 취급한다(GF-35) — 빈 값이면
# 명시적으로 기본값을 채운다.
if not task_prefix:
task_prefix = task_prefix_default
branch_result = subprocess.run(
["git", "symbolic-ref", "--short", "HEAD"],
capture_output=True,
encoding="utf-8",
check=False,
)
branch = branch_result.stdout.rstrip("\n") if branch_result.returncode == 0 else "HEAD"
return task_prefix, branch
# 브랜치명에서 <prefix>-<번호>를 찾는다. 접두어 앞에 글자/숫자가 아닌 문자(또는
# 문자열 시작)가 오도록 앵커링한다(GF-34) — 안 그러면 예: taskPrefix=ID일 때
# "valid-42-fix"의 "id-4" 부분 문자열이 잘못 매치된다.
def find_task_id(task_prefix, branch):
return re.search(
rf"(^|[^a-zA-Z0-9]){re.escape(task_prefix)}-([0-9]+)", branch, re.IGNORECASE
)
# Task-Id 브랜치 강제: <prefix>-<번호> 패턴이 브랜치명에 없으면 커밋을 거부한다.
# main/master/develop/release/* 등 예외 브랜치와 detached HEAD는 면제한다.
def enforce_task_id_branch():
task_prefix, branch = task_prefix_and_branch()
if branch == "HEAD":
return
# 로컬 오버라이드와 내장 기본값을 합친다(union) - README가 --add로 패턴을
# "추가"하라고 안내하므로, 로컬에 하나라도 추가되면 main/master/develop/
# release/* 기본값이 통째로 사라지면 안 된다(GF-78).
exempt = local_get_all("gitformat.branchExempt") + conf_get_all("gitformat.branchExempt")
# release/* 같은 글롭 패턴이므로 리터럴 비교가 아니라 글롭 매칭을 쓴다.
if any(fnmatch.fnmatchcase(branch, pattern) for pattern in exempt):
return
if find_task_id(task_prefix, branch) is None:
fail(
f"prepare-commit-msg: 브랜치명에 {task_prefix}-<번호> 패턴이 없습니다 (현재 브랜치: {branch}).",
f" 예: {task_prefix}-12-install-script",
" Task-Id 없이 커밋하려면 예외 브랜치(main/master/develop/release/*)에서 작업하세요.",
)
# 감지된 AI 도구 식별자. 빈 문자열이면 "AI 도구 미감지 = 사람 커밋"이다. 이 값 하나가
# 모델 게이트, AI-Agent/Co-Authored-By/Tokens-Used/Tool-Calls, Signed-off-by(decision-30:
# 사람 커밋에만)를 함께 가른다 — 판정 신호가 둘로 갈리면 AI-Agent와 Signed-off-by가 한
# 커밋에 같이 붙거나 둘 다 빠지는 모순이 생긴다.
#
# AI_AGENT는 Claude Code 프로세스가 하위 프로세스에 주입하는 값이라 LLM이 스스로 만든
# 값이 아니다(예: claude-code_2-1-227_agent). 이 신호를 검증 가능한 것으로 바꾸는 일은
# GF-130의 몫이다.
def ai_tool_id():
return os.environ.get("AI_AGENT", "").split("_", 1)[0]
# AI-Model 존재/화이트리스트 강제(decision-5). Claude Code는 세션 트랜스크립트에서
# 모델을 자동으로 얻으므로(trailer_ai_agent()) 이 게이트에서 제외한다.
def enforce_ai_model_gate():
ai_tool_claude_code = conf_get("gitformat.aiToolClaudeCode")
ai_tool_gate = ai_tool_id()
if not ai_tool_gate or ai_tool_gate == ai_tool_claude_code:
return
configured_model = subprocess.run(
["git", "config", "--get", "gitformat.aiModel"],
capture_output=True,
encoding="utf-8",
check=False,
).stdout.rstrip("\n")
if not configured_model:
fail(
f"prepare-commit-msg: AI 도구({ai_tool_gate})가 감지됐지만 gitformat.aiModel이 설정되지 않았습니다.",
" git config gitformat.aiModel <model-id> 로 설정하세요.",
)
known_models = conf_get_all("gitformat.knownModel")
if known_models and configured_model not in known_models:
fail(
f"prepare-commit-msg: gitformat.aiModel 값 '{configured_model}'이 알려진 모델 목록에 없습니다.",
" hooks/gitformat.conf의 gitformat.knownModel 항목에 추가하세요.",
)
# 커밋 메시지 검증(decision-18, GF-127). 구 commit-msg의 검증을 동작 그대로 옮겼다.
# --no-verify는 commit-msg를 건너뛰지만 이 훅은 건너뛰지 못하므로(doc-15), 여기로
# 옮기면서 메시지 검증 우회가 불가능해졌다.
#
# 알려진 한계 — --amend: source=commit은 `--amend --no-edit`(이 파일이 곧 최종
# 메시지)과 `--amend`(이 훅 뒤에 에디터가 열림)를 구분하지 못한다. 둘 다 직전 커밋
# 메시지가 들어 있어 에디터 거부를 통과하고, 이 검증은 **직전 메시지**를 본다. 그래서
# 에디터로 연 --amend에서 사람이 고친 최종 메시지는 검증되지 않는다. GF-127에서
# 해결하지 않기로 했다(2026-10-04 유저 결정).
def validate_message():
content = message_lines()
validate_format(content)
validate_length(content)
validate_fixes_trailer(content)
enforce_task_id_branch()
enforce_ai_model_gate()
# ── 트레일러 삽입(GF-128, decision-18·19·27·30) ─────────────────────────────
#
# GF-128 전에는 post-commit이 커밋을 만든 **뒤에** `git commit --amend`로 트레일러를
# 붙였다. 그 구조가 낳은 문제가 셋이다 — amend가 훅을 재발동시켜 재귀 가드가 필요했고,
# rebase·cherry-pick 도중에는 amend 자체가 실패해 트레이스백을 냈고(archive DRAFT-18),
# 커밋이 한 번 만들어졌다가 다시 쓰였다. 지금은 커밋 객체가 만들어지기 전에 메시지
# 파일($1)에 `git interpret-trailers --in-place`로 직접 쓴다. 커밋은 처음부터 최종
# 메시지로 만들어진다.
#
# 붙이는 트레일러와 조건(decision-19, decision-27, decision-30):
# Task-Id 브랜치명에 <prefix>-<번호>가 있을 때
# AI-Agent AI 도구가 감지되면 항상. <도구>/<버전> (<모델>)
# Co-Authored-By 도구가 claude-code일 때
# Tokens-Used AI 도구가 감지되면 항상(측정 못 하면 unavailable (사유))
# Tool-Calls 위와 같다
# Hooks-Commit 항상(훅 클론의 HEAD를 못 읽으면 생략)
# Signed-off-by AI 도구가 감지되지 않았을 때만(decision-30)
#
# AI-Tool/AI-Tool-Version/AI-Model은 AI-Agent 한 줄로 합쳤고, Verify-Bypassed는
# 없앴다 — 이 훅은 --no-verify로 건너뛸 수 없어 탐지할 우회가 없다(decision-18).
# Tokens-Used/Tool-Calls 귀속 기록(decision-27, GF-129). 커밋 사이에 남는 git-미추적
# 상태 파일이고, 세션 동안 계속 자란다. 첫 줄은 세션 ID, 그다음 줄부터는 이미 어느
# 커밋에 귀속된 응답의 키다(attributed_record_load()/attributed_record_save()). 응답
# 하나가 두 커밋에 들어가지 않게 하려는 것이라, 세션 ID가 다르면(응답 키는 세션 사이에
# 유일하다는 보장이 없다) 기록을 버리고 새로 시작한다.
# 파일명은 gitformat.conf로 빼지 않는다 - 컨슈머가 오버라이드할 이유가 없는 순수
# 내부 구현 세부사항이라 트레일러 키 이름들과 성격이 다르다.
ATTRIBUTED_RECORD = os.path.join(GIT_DIR, ".gitformat-token-attributed")
# GF-139까지 쓰던 델타 커서("<세션 ID> <줄 수>"). 의미가 델타에서 커밋당 귀속으로
# 바뀌어(decision-27) 더는 읽지 않는다. 남겨 두면 "아직 쓰이는 상태 파일"로 오해받으니
# 측정에 성공할 때 지운다 - 읽지 않으므로 지우는 시점은 값에 영향이 없다.
LEGACY_CURSOR = os.path.join(GIT_DIR, ".gitformat-token-cursor")
def local_get(key):
return subprocess.run(
["git", "config", "--get", key],
capture_output=True,
encoding="utf-8",
check=False,
).stdout.rstrip("\n")
# 메시지에 이미 이 키의 줄이 있는가(decision-19의 키 단위 중복 판정, AC #2).
#
# 메시지 원문을 줄 단위로 읽어 "<키>:"로 시작하는 줄이 하나라도 있으면 그 키를 붙이지
# 않는다. 값은 보지 않는다 — 사람이나 에이전트가 직접 쓴 값을 훅이 덮어쓰거나 옆에 또
# 붙이지 않는다(Co-Authored-By: <다른 값>이 있으면 그대로 둔다, AC #4).
#
# `git interpret-trailers --parse`를 쓰지 않는 이유: 그 명령은 메시지 **맨 끝의 연속된
# 트레일러 블록만** 본다. 빈 줄로 분리된 앞 문단의 Task-Id를 못 봐 두 번째 Task-Id를
# 붙였던 것이 GF-128 이전 중복의 원인이었다(AC #3). 콜론까지 비교하므로 AI-Tool이
# AI-Tool-Version을 가리는 접두어 오매치(GF-33)도 없다.
#
# 키는 대소문자를 구분하지 않는다. git이 트레일러 키를 그렇게 다루고, 실제로
# Co-authored-by(GitHub 웹 UI)와 Co-Authored-By가 섞여 쓰인다 — 대소문자만 다른 키를
# 다른 키로 보면 같은 트레일러가 두 줄이 된다.
def message_has_key(raw_lines, key):
prefix = f"{key.lower()}:"
return any(line.lower().startswith(prefix) for line in raw_lines)
def raw_message_lines():
# 메시지는 유효하지 않은 UTF-8 바이트를 포함할 수 있다(GF-80). 여기서는 키를 찾기만
# 하고 파일은 interpret-trailers가 바이트 그대로 다시 쓰므로 replace로 충분하다.
return Path(MSG_FILE).read_bytes().decode("utf-8", errors="replace").split("\n")
# Task-Id(decision-4): enforce_task_id_branch()가 이미 브랜치명의 패턴을 강제했으므로
# (없으면 예외 브랜치가 아닌 한 커밋 자체가 거부됨) 같은 패턴을 다시 찾아 값만 만든다.
def trailer_task_id():
task_prefix, branch = task_prefix_and_branch()
match = find_task_id(task_prefix, branch)
if match is None:
return ""
return f"{task_prefix}-{match.group(2)}"
# sh 판은 $PWD를 썼다. os.getcwd()는 심볼릭 링크를 해석한 물리 경로를 돌려주므로
# (/var -> /private/var 같은 환경) 그대로 쓰면 Claude Code가 실제로 만든 세션
# 디렉터리 슬러그와 어긋난다. POSIX 셸이 시작할 때 하는 것과 동일하게, 환경변수
# PWD가 현재 디렉터리를 가리키는 절대경로일 때만 그 값을 쓴다.
def shell_pwd():
pwd = os.environ.get("PWD", "")
if pwd.startswith("/"):
try:
if os.path.samefile(pwd, "."):
return pwd
except OSError:
pass
return os.getcwd()
# Claude Code가 ~/.claude/projects/ 아래 세션 디렉터리를 만들 때 쓰는 실제 규칙은
# "영숫자가 아닌 모든 문자를 하이픈으로 치환"이다(GF-98 - 슬래시만 치환하면 밑줄/
# 점이 든 경로에서 트랜스크립트를 영영 못 찾는다).
#
# 여기는 이 저장소에서 외부 계약에 의존하는 유일한 지점이다(GF-119). 이 슬러그
# 규칙과 트랜스크립트 경로 레이아웃은 Claude Code의 문서화되지 않은 내부 구현이라
# 우리가 지킬 수 있는 약속이 아니다 - 상대가 규칙을 바꾸면 이 함수는 예외 없이
# "없는 경로"를 반환하고, 측정은 조용히 어긋난다. 그래서 실패를 숨기지 않는 쪽으로
# 설계했다: Tokens-Used/Tool-Calls는 unavailable (transcript-not-found)로 사유를
# 남기고, AI-Agent의 모델 자리는 model-unavailable이 된다 - 커밋 footer만 봐도
# 결합이 깨진 걸 알 수 있다. 진단 절차는 doc-6 "한계"에 적어뒀다.
def claude_transcript_path():
slug = re.sub(r"[^A-Za-z0-9]", "-", shell_pwd())
session_id = os.environ.get("CLAUDE_CODE_SESSION_ID", "")
home = os.environ.get("HOME", "")
return os.path.join(home, ".claude", "projects", slug, f"{session_id}.jsonl")
# 트랜스크립트 마지막 200줄에서 가장 나중의 assistant 턴 message.model을 고른다.
# 한 줄이라도 JSON 파싱에 실패하면 거기서 멈추되 그때까지 찾은 값은 유지한다 —
# 스트리밍 파서가 오류를 만나기 전까지의 출력을 그대로 남기던 동작과 같다.
def read_transcript_model(path):
with open(path, "rb") as f:
data = f.read().decode("utf-8", errors="replace")
lines = data.split("\n")
if lines and lines[-1] == "":
lines.pop()
model = ""
for line in lines[-200:]:
if not line.strip():
continue
try:
entry = json.loads(line)
except ValueError:
break
if not isinstance(entry, dict) or entry.get("type") != "assistant":
continue
message = entry.get("message")
if isinstance(message, dict) and message.get("model"):
model = message["model"]
return model
# AI-Agent(decision-19): AI-Tool/AI-Tool-Version/AI-Model을 `<도구>/<버전> (<모델>)`
# 한 줄로 합친다. 키가 하나라 AI-Tool이 AI-Tool-Version을 가리던 접두어 오매치(GF-33)가
# 구조적으로 사라진다.
#
# 구성요소를 못 구해도 줄은 남긴다(AC #6) — 버전은 version-unavailable, 모델은
# model-unavailable. 조용한 생략은 "AI가 관여하지 않았다"로 읽혀 잘못된 귀속이 된다.
#
# - 도구·버전: AI_AGENT의 첫째·둘째 필드(밑줄 구분). 버전의 하이픈은 점으로 바꾼다
# (2-1-227 → 2.1.227). 구분자가 없는데 둘째 필드를 뽑으면 원문 전체가 돌아와 버전이
# 도구 이름과 같아지므로(GF-33) 밑줄이 있을 때만 버전을 채운다.
# - 모델: Claude Code는 세션 트랜스크립트(~/.claude/projects/<slug>/<session>.jsonl)의
# message.model이다 — Anthropic API 응답을 애플리케이션이 그대로 기록한 값이라
# 자가신고가 아니다. 그 외 도구는 enforce_ai_model_gate()가 이미 검증한
# gitformat.aiModel을 쓴다. CLAUDE_CODE_SESSION_ID는 트랜스크립트 경로를 찾는 데만
# 쓰고 값 자체를 footer에 남기지 않는다(decision-5 amendment).
def trailer_ai_agent(tool):
ai_agent = os.environ.get("AI_AGENT", "")
version = ai_agent.split("_")[1].replace("-", ".") if "_" in ai_agent else ""
model = ""
if tool == conf_get("gitformat.aiToolClaudeCode"):
if os.environ.get("CLAUDE_CODE_SESSION_ID"):
# 트랜스크립트 조회는 어떤 이유로 실패하든(파일 없음/포맷 변경/권한) 커밋을
# 막지 않고 모델 자리만 model-unavailable로 두는 의도된 fail-open이다
# (decision-5). 이 파일에서 예외를 삼키는 곳은 여기와 토큰 측정뿐이다 —
# 나머지 경로는 실패를 조용히 흘려보내지 않는다(GF-76).
try:
model = read_transcript_model(claude_transcript_path())
except (OSError, ValueError, TypeError):
model = ""
else:
model = local_get("gitformat.aiModel")
return f"{tool}/{version or 'version-unavailable'} ({model or 'model-unavailable'})"
# in에 합치는 입력측 usage 필드. 캐시 토큰도 모델이 실제로 읽어 들인 입력이라 빼면
# 값이 무의미해진다(doc-16 실측: 입력측이 전체의 거의 전부다). out은 output_tokens
# 하나다(decision-27, GF-129).
INPUT_USAGE_FIELDS = (
"input_tokens",
"cache_creation_input_tokens",
"cache_read_input_tokens",
)
OUTPUT_USAGE_FIELD = "output_tokens"
# JSONL 트랜스크립트 하나를 읽어 (줄 키, 파싱된 줄) 목록으로 돌려준다. 줄 키는
# "<트랜스크립트 디렉터리 기준 파일 경로>:<1부터 센 줄 번호>"로, requestId/message.id가
# 없는 줄을 응답으로 삼을 때 쓴다(group_responses()). 트랜스크립트는 뒤에 덧붙기만
# 하므로 같은 줄은 다음 커밋에서도 같은 키를 얻는다.
#
# 줄 하나라도 JSON 파싱에 실패하면 ValueError를 그대로 올려 배치 전체를 실패시킨다 —
# 불량 줄만 건너뛰면 "일부만 집계된 값"이 정상값인 척 기록되므로(GF-111/GF-112).
# 파일을 못 읽으면 OSError가 올라간다. 둘 다 호출자가 사유 슬러그로 바꾼다.
def read_transcript_entries(path, key_prefix):
with open(path, "rb") as f:
data = f.read().decode("utf-8", errors="replace")
lines = data.split("\n")
if lines and lines[-1] == "":
lines.pop()
entries = []
for number, line in enumerate(lines, start=1):
if not line.strip():
continue
entries.append((f"{key_prefix}:{number}", json.loads(line)))
return entries
# assistant 줄을 응답 단위로 묶는다. 트랜스크립트는 API 응답 하나를 content 블록마다
# 한 줄씩 기록하고 각 줄이 같은 message.usage를 반복해서 들고 있다 - 줄마다 더하면
# 2~3배 부풀려진다(GF-139 실측: assistant 215줄이 응답 109개). 같은 응답의 줄은
# 최상위 requestId와 message.id를 공유하므로 "<requestId> <message.id>"를 응답 키로
# 삼는다. 이 키는 그대로 귀속 기록 파일의 한 줄이 되므로, 공백·개행이 든 id는(실제로는
# 본 적 없다) 기록을 깨뜨리지 않도록 id가 없는 것과 같이 취급한다.
#
# - usage는 응답의 첫 줄 값만 쓴다 - 나머지 줄은 같은 값의 반복이다.
# - tool_use 블록은 모든 줄에서 모은다 - 한 응답의 도구 호출이 여러 줄에 흩어져 있어
# 첫 줄만 보면 귀속 근거를 놓친다.
# - id가 하나라도 없는 줄은 같은 응답인지 판단할 근거가 없으므로 줄 키로 각자 하나의
# 응답이 된다(GF-139 규칙 유지).
#
# 키가 같으면 파일이 달라도(본 트랜스크립트와 서브에이전트 트랜스크립트) 같은 응답으로
# 본다 - 같은 API 응답을 두 번 더하지 않는다는 원칙이 파일 경계보다 우선이다.
def group_responses(entries):
responses = {}
for line_key, entry in entries:
if not isinstance(entry, dict) or entry.get("type") != "assistant":
continue
message = entry.get("message")
if not isinstance(message, dict):
continue
request_id = entry.get("requestId")
message_id = message.get("id")
if (
isinstance(request_id, str)
and re.fullmatch(r"\S+", request_id)
and isinstance(message_id, str)
and re.fullmatch(r"\S+", message_id)
):
response_key = f"{request_id} {message_id}"
else:
response_key = line_key
response = responses.get(response_key)
if response is None:
response = {"usage": message.get("usage"), "tool_uses": []}
responses[response_key] = response
content = message.get("content")
if isinstance(content, list):
response["tool_uses"].extend(
block
for block in content
if isinstance(block, dict) and block.get("type") == "tool_use"
)
return responses
# 귀속 대상 경로 = 이 커밋이 바꿀 파일 목록(저장소 상대경로).
#
# 이 훅은 커밋 객체가 만들어지기 **전에** 돌므로 HEAD 커밋의 파일 목록(GF-128 전
# post-commit이 쓰던 diff-tree HEAD)이 아니라, 커밋될 인덱스와 HEAD의 차이를 본다.
#
# 인덱스는 반드시 git이 넘겨준 것을 써야 한다. `git commit -a`와 `git commit <경로>`는
# 커밋할 내용을 임시 인덱스에 만들고 GIT_INDEX_FILE로 그 위치를 알려준다(git 2.54.0
# 실측: -a는 .git/index.lock, <경로>는 .git/next-index-<pid>.lock). 여기서 그
# 환경변수를 지우거나 .git/index를 직접 지정하면 -a로 커밋하는 수정 파일, <경로>로
# 고른 파일이 빠지고 스테이징만 돼 있고 커밋되지 않을 파일이 끼어든다. 자식 git은
# 환경을 그대로 물려받으므로 아무것도 하지 않으면 맞는 인덱스를 읽는다.
#
# 비교 기준은 HEAD다. 첫 커밋(HEAD 없음)이면 빈 트리와 비교한다 — 빈 트리 해시는
# 저장소의 해시 알고리즘(SHA-1/SHA-256)에 따라 다르므로 상수로 박지 않고 git에게
# 묻는다.
#
# --amend도 HEAD와 비교한다. 즉 대상은 "amend가 원래 커밋 위에 새로 얹는 변경"이다.
# 원래 커밋의 파일을 건드린 응답은 그 커밋이 만들어질 때 이미 귀속 기록에 들어갔으므로
# 다시 세지 않는다는 doc-16의 규칙과 맞는다. 훅은 --amend 여부를 알 길이 없다 —
# `--amend -m`은 source=message로 와서 일반 커밋과 구분되지 않는다(실측).
#
# diff-index는 plumbing이라 diff.renames 같은 사용자 설정을 읽지 않는다 — 이름 바꾸기가
# 옛 이름 삭제와 새 이름 추가로 나와 둘 다 대상이 된다(어느 쪽을 건드린 응답이든 이
# 커밋의 작업이다). -z는 core.quotePath가 비ASCII 경로를 따옴표·이스케이프로 감싸는
# 것을 막아 원래 경로 그대로 받게 한다.
def commit_target_paths():
head = subprocess.run(
["git", "rev-parse", "--verify", "--quiet", "HEAD"],
capture_output=True,
encoding="utf-8",
check=False,
)
if head.returncode == 0:
base = head.stdout.strip()
else:
base = subprocess.run(
["git", "hash-object", "-t", "tree", "--stdin"],
input="",
capture_output=True,
encoding="utf-8",
check=True,
).stdout.strip()
output = subprocess.run(
["git", "diff-index", "--cached", "--name-only", "-z", base],
stdout=subprocess.PIPE,
encoding="utf-8",
errors="surrogateescape",
check=True,
).stdout
return {path for path in output.split("\0") if path}
# Bash 명령 판정에서 대상 경로 바로 앞에 와도 되는 글자: 공백, 따옴표, 백틱, 셸
# 구두점(= ( < > | ; & , :). 경로 바로 뒤에는 경로를 이어 쓰는 글자(영숫자 _ . - /)가
# 오면 안 된다 - hooks/prepare-commit-msg-old나 a.txt.bak은 다른 파일이다.
BASH_PATH_LEFT_BOUNDARY = "\\s'\"`=(<>|;&,:"
BASH_PATH_RIGHT_FORBIDDEN = "A-Za-z0-9_.\\-/"
# 귀속 판정기. 대상 경로와 저장소 최상위 경로를 한 번만 계산해 두고, tool_use 블록
# 하나가 대상 경로를 실제로 건드렸는지 판정한다(decision-27, GF-129). 판정은 경로
# 문자열 대조뿐이라 완벽하지 않다 - 경로를 쓰지 않고 파일을 바꾸는 명령(글롭, 변수,
# cd 후 파일명만 쓰기)은 놓치고, 경로를 문자열로 담기만 한 명령(git add, 커밋
# 메시지에 경로 언급)은 귀속한다. 놓치는 쪽을 택한 것은 오귀속보다 과소 보고가
# 낫다는 판단이다(doc-16 "판정 규칙").
class AttributionMatcher:
def __init__(self, targets):
self.targets = targets
self.top = subprocess.run(
["git", "rev-parse", "--show-toplevel"],
stdout=subprocess.PIPE,
encoding="utf-8",
errors="surrogateescape",
check=True,
).stdout.rstrip("\n")
# macOS의 /tmp → /private/tmp처럼 같은 디렉터리가 두 이름을 가지면, 도구가
# 기록한 절대경로와 git이 알려준 최상위 경로가 서로 다른 이름을 쓸 수 있다.
# 비교는 양쪽 모두 심볼릭 링크를 푼 물리 경로로 한다.
self.real_top = os.path.realpath(self.top)
self.bash_patterns = [self._bash_pattern(target) for target in sorted(targets)]
# Bash 명령 문자열에서 대상 경로 하나를 찾는 정규식.
#
# - 하위 디렉터리 안의 대상(hooks/gitformat.conf): 저장소 상대경로가 경계로 구분돼
# 들어 있으면 귀속한다. 왼쪽 경계에 '/'도 허용한다 - 그래야 절대경로
# (/…/repo/hooks/gitformat.conf)나 ../repo/hooks/gitformat.conf처럼 상대경로를
# 꼬리로 품은 표기도 잡힌다. myhooks/gitformat.conf는 왼쪽이 영문자라 걸리지
# 않는다. 파일명만 든 명령은 상대경로가 없으니 귀속하지 않는다 - README.md 같은
# 흔한 이름이 무관한 응답을 끌어오는 것을 막는다.
# - 저장소 최상위의 대상(a.txt): 상대경로가 곧 파일명이다. 그래서 왼쪽 경계에
# '/'를 허용하지 않는다 - 허용하면 sub/a.txt 같은 다른 파일의 파일명 매칭이
# 된다. 대신 ./a.txt와 저장소 최상위 절대경로(논리·물리 둘 다)를 붙인 표기는
# 명시적으로 받는다.
def _bash_pattern(self, target):
right = rf"(?![{BASH_PATH_RIGHT_FORBIDDEN}])"
if "/" in target:
left = rf"(?<![^{BASH_PATH_LEFT_BOUNDARY}/])"
return re.compile(left + re.escape(target) + right)
left = rf"(?<![^{BASH_PATH_LEFT_BOUNDARY}])"
needles = [target, f"./{target}"]
needles += [os.path.join(top, target) for top in {self.top, self.real_top}]
alternatives = "|".join(re.escape(needle) for needle in needles)
return re.compile(f"{left}(?:{alternatives}){right}")
# file_path/notebook_path 값을 저장소 상대경로로 정규화한다. 상대경로는 저장소
# 최상위 기준으로 본다(훅도 Claude Code도 최상위에서 돈다). 디렉터리 부분만
# 심볼릭 링크를 풀고 파일명은 그대로 둔다 - 파일 자체가 심볼릭 링크면 커밋에는
# 링크 이름으로 들어가므로, 끝까지 풀면 링크 대상 이름으로 바뀌어 어긋난다. 저장소
# 밖이면 None.
def repo_relative(self, path):
if not os.path.isabs(path):
path = os.path.join(self.top, path)
path = os.path.normpath(path)
physical = os.path.join(
os.path.realpath(os.path.dirname(path)), os.path.basename(path)
)
relative = os.path.relpath(physical, self.real_top)
if (
relative == os.curdir
or relative == os.pardir
or relative.startswith(os.pardir + os.sep)
):
return None
return relative.replace(os.sep, "/")
# tool_use 블록 하나가 대상 경로를 건드렸는가. file_path/notebook_path를 가진
# 도구(Read/Edit/Write/NotebookEdit 등)는 완전 일치, Bash는 명령 문자열 대조,
# 그 밖의 도구(Grep/Glob 등)는 귀속하지 않는다 - 경로 인자가 있어도 "건드림"이
# 아니라 검색 범위라서다.
def touches(self, block):
tool_input = block.get("input")
if not isinstance(tool_input, dict):
return False
for field in ("file_path", "notebook_path"):
value = tool_input.get(field)
if isinstance(value, str) and value:
return self.repo_relative(value) in self.targets
if block.get("name") == "Bash":
command = tool_input.get("command")
if isinstance(command, str):
return any(pattern.search(command) for pattern in self.bash_patterns)
return False
# 귀속 기록 파일(ATTRIBUTED_RECORD)을 읽어 이미 귀속된 응답 키 목록을 돌려준다. 첫
# 줄이 지금 세션 ID와 다르거나, 파일이 없거나(최초 실행), 손상돼 첫 줄을 알아볼 수
# 없으면 빈 목록이다 - 이건 조회 실패가 아니라 "이 세션에서 아직 아무 응답도 귀속한
# 적 없음"과 동치이므로 측정을 실패로 만들지 않는다. 순서를 지키려고 목록으로 돌려준다
# (다시 쓸 때 기존 키 뒤에 새 키를 덧붙인다).
def attributed_record_load(session_id):
try:
with open(ATTRIBUTED_RECORD, "rb") as f:
raw = f.read().decode("utf-8", errors="replace")
except OSError:
return []
lines = raw.split("\n")
if lines[0] != session_id:
return []
return [line for line in lines[1:] if line]
def attributed_record_save(session_id, keys):
with open(ATTRIBUTED_RECORD, "w", encoding="utf-8") as f:
f.write("".join(f"{line}\n" for line in [session_id, *keys]))
# Claude Code 토큰 측정(decision-5 확장, GF-97 amendment, decision-27): 세션
# 트랜스크립트를 읽어 이 커밋에 귀속된 응답의 usage만 더한다. message.usage는
# Anthropic API 응답을 그대로 기록한 값이라 자가신고가 아니다.
#
# 값은 "이 커밋에 든 토큰"이다 - 직전 커밋 이후 구간 전체(델타)는 어디에도 세지
# 않는다(decision-27이 decision-19의 delta 조항을 대체). 그래서 구간이 아니라 세션
# 전체(본 트랜스크립트 + <세션>/subagents/*.jsonl)를 훑어 아직 귀속되지 않은 응답 중
# 이 커밋이 바꿀 파일을 건드린 것을 고른다 - "a 수정 → b 먼저 커밋 → a 커밋" 순서에서도
# a를 고친 응답이 뒤 커밋에 들어간다. 이 커밋의 파일을 건드리지 않은 응답(무관한 파일
# 탐색, 도구 호출 없는 대화)은 어느 커밋에도 들어가지 않는다 - 커밋당 토큰량이라는
# 정의에서 의도된 결과다. Read도 file_path를 가지므로 커밋한 파일을 읽은 응답은
# 귀속된다.
#
# 돌려주는 값은 dict다. 성공하면 {"ok": True, "in", "out", "calls", "session_id",
# "keys"(지금까지의 기록 + 이번에 귀속한 응답 키), "new"(이번에 귀속한 응답 수)}이고,
# 실패하면 {"ok": False, "reason": <실패 지점을 가리키는 짧은 슬러그>}다. 기록 파일은
# 여기서 쓰지 않는다 - 트레일러가 메시지 파일에 실제로 들어간 뒤에 쓴다
# (insert_trailers()). 실패하면 기록을 갱신하지 않으므로 다음 커밋이 같은 응답들을
# 다시 판정할 수 있다.
def measure_claude_code_token_usage():
session_id = os.environ.get("CLAUDE_CODE_SESSION_ID", "")
if not session_id:
return {"ok": False, "reason": "no-session-id"}
transcript = claude_transcript_path()
if not os.path.isfile(transcript):
return {"ok": False, "reason": "transcript-not-found"}
# 서브에이전트 트랜스크립트는 Claude Code가 본 트랜스크립트 옆
# <세션 ID>/subagents/에 같은 JSONL 형식으로 남긴다. 서브에이전트를 쓰지 않은
# 세션에는 이 디렉터리가 없으므로 없는 건 정상이다. 정렬은 줄 키와 응답 순서를
# 실행마다 같게 하려는 것이다. 줄 키의 파일 부분은 트랜스크립트 디렉터리 기준 경로라
# 본 트랜스크립트와 서브에이전트 파일이 서로 겹치지 않는다.
sources = [(transcript, os.path.basename(transcript))]
subagent_dir = os.path.join(os.path.dirname(transcript), session_id, "subagents")
if os.path.isdir(subagent_dir):
try:
names = sorted(os.listdir(subagent_dir))
except OSError:
return {"ok": False, "reason": "transcript-unreadable"}
for name in names:
path = os.path.join(subagent_dir, name)
if name.endswith(".jsonl") and os.path.isfile(path):
sources.append((path, f"{session_id}/subagents/{name}"))
# 한 파일, 한 줄이라도 깨지면 배치 전체가 실패한다 - 일부만 집계한 값을 정상값처럼
# 남기지 않는다(GF-112). 구간이 아니라 세션 전체를 매번 읽으므로, 한 번 깨진 줄이
# 생기면 그 세션의 이후 커밋은 계속 transcript-parse-failed가 된다 - 조용히 틀린
# 값을 남기는 것보다 사유가 계속 보이는 쪽을 택했다.
entries = []
for path, key_prefix in sources:
try:
entries += read_transcript_entries(path, key_prefix)
except OSError:
return {"ok": False, "reason": "transcript-unreadable"}
except (ValueError, TypeError):
return {"ok": False, "reason": "transcript-parse-failed"}
responses = group_responses(entries)
matcher = AttributionMatcher(commit_target_paths())
recorded = attributed_record_load(session_id)
already_attributed = set(recorded)
input_sum = 0
output_sum = 0
calls = 0
new_keys = []
for response_key, response in responses.items():
if response_key in already_attributed:
continue
# Tool-Calls는 귀속된 응답의 tool_use 전부가 아니라 대상 경로를 실제로 건드린
# 블록만 센다 - 같은 응답 안의 ls·grep 같은 호출은 이 커밋의 작업이 아니다.
touching = sum(1 for block in response["tool_uses"] if matcher.touches(block))
if not touching:
continue
new_keys.append(response_key)
calls += touching
usage = response["usage"]
if isinstance(usage, dict):
for field in INPUT_USAGE_FIELDS:
value = usage.get(field)
input_sum += value if isinstance(value, int) else 0
value = usage.get(OUTPUT_USAGE_FIELD)
output_sum += value if isinstance(value, int) else 0
return {
"ok": True,
"in": input_sum,
"out": output_sum,
"calls": calls,
"session_id": session_id,
"keys": recorded + new_keys,
"new": len(new_keys),
}
# Tokens-Used/Tool-Calls 값(GF-97, decision-27). 사람 커밋(도구 미감지)은 호출되지
# 않는다. claude-code는 measure_claude_code_token_usage()의 실측 결과를 쓴다. 그 외
# 감지된 AI 도구는 아직 서버발급 usage를 읽을 로컬 채널이 없으므로(decision-15) 항상