Repository navigation
Expand file tree
/
Copy pathprepare-commit-msg
More file actions
executable file
·786 lines (695 loc) · 40.1 KB
/
Copy pathprepare-commit-msg
File metadata and controls
executable file
·786 lines (695 loc) · 40.1 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
#!/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는 애초에 줄바꿈 대상이 아니다.
#
# Tokens-Used/Tool-Calls는 GF-146에서 등록 트레일러에서 빠졌다(decision-31). 그래서
# 사람이나 에이전트가 직접 쓴 그 줄은 평범한 본문 줄이고 72자 제한을 받는다 — 측정 줄만을
# 위한 예외를 남기면 그 자체가 측정과의 결합이다. 지우거나 값을 검증하지는 않는다.
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, 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·30·31) ─────────────────────────────
#
# GF-128 전에는 post-commit이 커밋을 만든 **뒤에** `git commit --amend`로 트레일러를
# 붙였다. 그 구조가 낳은 문제가 셋이다 — amend가 훅을 재발동시켜 재귀 가드가 필요했고,
# rebase·cherry-pick 도중에는 amend 자체가 실패해 트레이스백을 냈고(archive DRAFT-18),
# 커밋이 한 번 만들어졌다가 다시 쓰였다. 지금은 커밋 객체가 만들어지기 전에 메시지
# 파일($1)에 `git interpret-trailers --in-place`로 직접 쓴다. 커밋은 처음부터 최종
# 메시지로 만들어진다.
#
# 붙이는 트레일러와 조건(decision-19, decision-30):
# Task-Id 브랜치명에 <prefix>-<번호>가 있을 때
# AI-Agent AI 도구가 감지되면 항상. <도구>/<버전> (<모델>)
# Co-Authored-By 도구가 claude-code일 때
# 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는 GF-146에서 없앴다 — 토큰·툴콜 측정은 이 도구의 범위 밖이고
# 별도 컴포넌트로 옮긴다(decision-31). 그때 귀속 기록 파일
# (.git/.gitformat-token-attributed, .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의 문서화되지 않은 내부 구현이라
# 우리가 지킬 수 있는 약속이 아니다 - 상대가 규칙을 바꾸면 이 함수는 예외 없이
# "없는 경로"를 반환하고, 모델 판정은 조용히 어긋난다. 그래서 실패를 숨기지 않는
# 쪽으로 설계했다: 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'})"
# Hooks-Commit(decision-5): 이 커밋을 검증한 훅 코드 자체의 커밋 해시. 수동 버전
# 문자열 대신 훅 스크립트가 위치한 저장소의 HEAD를 그대로 읽으므로 항상 정확하다.
# hooks/가 클론 안에 있지 않으면(예: 훅만 복사해 배포한 경우) 조회가 실패하는데,
# 그때는 커밋을 막지 않고 이 트레일러만 생략하는 게 의도된 동작이다 - 판단이
# rev-parse 성공 여부에 달려 있으므로 예외가 아니라 종료 코드로 좁게 확인한다.
def trailer_hooks_commit():
result = subprocess.run(
["git", "-C", HOOK_DIR, "rev-parse", "--short", "HEAD"],
capture_output=True,
encoding="utf-8",
check=False,
)
if result.returncode != 0:
return ""
return result.stdout.rstrip("\n")
# Signed-off-by(decision-30): AI 도구가 감지되지 않은 커밋에만 커미터 정보로 붙인다.
# DCO에서 이 트레일러는 사람의 법적 인증이라, 에이전트 커밋에 훅이 자동으로 붙이면
# 사람이 인증하지 않은 커밋이 인증된 것처럼 보인다. 사람이 메시지에 직접 쓴
# Signed-off-by는 키 단위 중복 판정에 따라 그대로 남는다.
#
# 값은 `git commit -s`와 같은 출처인 커미터 ident다. 커밋 객체가 아직 없으므로
# GF-128 전처럼 `git log -1 --format=%cn <%ce>`로 읽을 수 없다 — `git var
# GIT_COMMITTER_IDENT`가 "이름 <메일> <epoch> <tz>"를 주므로 뒤의 시각 두 필드를 뗀다.
# ident를 못 만들면(user.email 미설정 등) git var가 실패하는데, 그때는 git commit
# 자체도 같은 이유로 실패하므로 여기서는 트레일러만 생략한다.
def trailer_signed_off_by():
result = subprocess.run(
["git", "var", "GIT_COMMITTER_IDENT"],
capture_output=True,
encoding="utf-8",
errors="surrogateescape",
check=False,
)
if result.returncode != 0:
return ""
return result.stdout.rstrip("\n").rsplit(" ", 2)[0]
# 트레일러를 모아 메시지 파일에 쓴다. 키마다 메시지에 이미 그 키가 있으면 값을 계산하지도
# 않는다.
#
# Tokens-Used/Tool-Calls는 붙이지 않는다(GF-146, decision-31). 이 도구는 커밋 포맷만
# 맞추고, 토큰·툴콜 측정은 깃 훅과 결합하지 않도록 별도 컴포넌트의 몫이다. 메시지에 그
# 키의 줄이 이미 있으면(사람이나 에이전트가 직접 썼거나 옛 커밋을 --amend로 들고 온
# 경우) 다른 본문 줄처럼 그대로 둔다 — 지우지도 값을 검증하지도 않는다.
#
# 쓰기는 `git interpret-trailers --in-place`다. --where end/--if-exists add/
# --if-missing add를 명시하는 이유: 사용자의 trailer.* 설정이 위치나 중복 처리를
# 바꾸지 못하게 하고, git의 --if-exists 판정은 키를 접두어로 매칭해(GF-33) 우리
# 판정과 어긋나므로 중복 판정은 위 message_has_key()에만 맡긴다.
def insert_trailers():
raw_lines = raw_message_lines()
tool = ai_tool_id()
trailers = []
def add(key, value_fn):
if message_has_key(raw_lines, key):
return
value = value_fn()
if value:
trailers.append(f"{key}: {value}")
add(conf_get("gitformat.trailer.taskId"), trailer_task_id)
if tool:
add(conf_get("gitformat.trailer.aiAgent"), lambda: trailer_ai_agent(tool))
# Co-Authored-By는 실제로 Claude가 작업했다고 확신할 수 있는 경우(claude-code)에만
# 붙인다. 다른 AI 도구까지 "Claude"로 공동저자 표기하면 잘못된 귀속이 된다.
if tool == conf_get("gitformat.aiToolClaudeCode"):
add(
conf_get("gitformat.trailer.coAuthoredBy"),
lambda: conf_get("gitformat.coAuthoredBy"),
)
add(conf_get("gitformat.trailer.hooksCommit"), trailer_hooks_commit)
if not tool:
add(conf_get("gitformat.trailer.signedOffBy"), trailer_signed_off_by)
if trailers:
args = []
for line in trailers:
args += ["--trailer", line]
result = subprocess.run(
[
"git",
"interpret-trailers",
"--in-place",
"--where",
"end",
"--if-exists",
"add",
"--if-missing",
"add",
*args,
MSG_FILE,
],
capture_output=True,
encoding="utf-8",
errors="surrogateescape",
check=False,
)
if result.returncode != 0:
fail(
"prepare-commit-msg: 트레일러를 메시지 파일에 쓰지 못했습니다.",
f" {result.stderr.strip()}",
)
# 실행 순서(decision-18): 재생·병합 커밋 면제 → 에디터 경로 거부 → 메시지 검증 →
# 트레일러 삽입.
#
# GF-128 전에는 이 사이에 검증마커($GIT_DIR/.gitformat-verified) 무효화와 기록이
# 있었다. 그 마커의 유일한 소비자는 post-commit의 Verify-Bypassed 판정이었고,
# post-commit과 그 트레일러가 함께 사라져 마커도 지웠다. 맨 위에 있던 amend 재귀
# 가드(_GITFORMAT_AMEND_GUARD)도 post-commit의 --amend가 없어져 함께 지웠다.
#
# 면제가 맨 앞인 것은 바꿀 수 없다. 에디터로 여는 병합/revert 커밋은 source가 template이
# 아니라 merge로 오지만(실측), 면제를 뒤로 미루면 사람이 메시지를 고를 수조차 없는
# 재생 경로(cherry-pick이 옮겨오는 옛 형식 제목, 병합 메시지 등)가 거부된다.
#
# 에디터 거부가 검증보다 앞인 것은 검증할 메시지가 아직 없기 때문이다 — 순서를
# 바꾸면 에디터 커밋이 "-m을 쓰라"는 안내 대신 형식 오류로 거부돼 원인이 가려진다.
#
# 검증은 트레일러 삽입보다 먼저 돈다(GF-127 AC #8) — 사람이 쓴 부분만 검증 대상이다.
# 순서를 바꾸면 훅이 붙인 트레일러가 본문 길이 검증 등을 받고, 사람이 쓴 메시지가
# 아니라 훅 자신의 출력 때문에 커밋이 거부될 수 있다. 검증에서 거부되면 삽입까지 가지
# 않는다.
#
# 재생·병합 커밋은 면제로 먼저 빠져나가므로 트레일러도 붙지 않는다(GF-128 AC #8).
# 재생 커밋은 원래 커밋의 트레일러를 그대로 들고 온다.
if is_replay_commit():
sys.exit(0)
reject_editor_path()
validate_message()
insert_trailers()
sys.exit(0)