Repository navigation
Expand file tree
/
Copy pathcommit-msg
More file actions
executable file
·331 lines (287 loc) · 14.6 KB
/
Copy pathcommit-msg
File metadata and controls
executable file
·331 lines (287 loc) · 14.6 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
#!/usr/bin/env python3
# commit-msg 훅: Conventional Commits 형식 검증(decision-1) + Task-Id 브랜치 강제(decision-4)
#
# 병합 진행 여부는 .git/MERGE_HEAD 존재로 판단한다. commit-msg 훅은 메시지 파일
# 경로 인자 1개만 받는다(source/sha1은 prepare-commit-msg의 인자다 — 예전엔 이걸
# 착각해 $2로 "merge"를 확인했는데 그 값이 절대 채워지지 않아 병합 예외가 전혀
# 작동하지 않았다, GF-30).
#
# 각 검증 단계는 이름 있는 함수로 분해돼 있고(GF-87), 실행 순서는 파일 맨 아래에
# 나열돼 있다. 다른 훅과 겹치는 블록(자기 위치 해석, conf 읽기 가드,
# TASK_PREFIX/BRANCH 계산)은 공유 모듈로 빼지 않고 파일마다 독립적으로 중복을
# 유지한다 — 파일 하나만 읽으면 그 훅의 동작을 전부 파악할 수 있어야 한다는
# 감사 가능성 요구사항이다(decision-16).
import fnmatch
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
MSG_FILE = sys.argv[1]
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)
MARKER = os.path.join(GIT_DIR, conf_get("gitformat.markerFile"))
def message_lines():
# 커밋 메시지 원문은 유효하지 않은 UTF-8 바이트를 포함할 수 있다(GF-80, 실제
# CI에서 재현). errors="replace"로 읽어 깨진 바이트 때문에 훅이 예외로 죽거나
# 형식이 정상인 커밋을 잘못 거부하는 일이 없게 한다.
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()
# grep -v '^#'과 동일하게 열 0의 '#'로 시작하는 줄만 제외한다.
return [line for line in lines if not line.startswith("#")]
# 커밋 제목이 [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). 조용히 진행하지 않고 원인을 밝히며 멈춘다.
if not types:
fail(f"commit-msg: gitformat.conf에서 커밋 type 목록을 읽을 수 없습니다: {CONF}")
subject_line = content[0] if content else ""
alternation = "|".join(re.escape(t) for t in types)
if not re.match(rf"\[({alternation})\](\[[a-zA-Z0-9_.-]+\])? .+", subject_line):
fail(
"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(
"commit-msg: 본문/트레일러가 있으면 제목과의 사이에 빈 줄이 필요합니다.",
" 형식: [type][subsystem] <description>",
" (빈 줄)",
" <본문 또는 트레일러>",
)
# subject 글자수(50자 이내) / 본문 줄 길이(72자 이내) 검증(GF-83). 위반하면 이 훅의
# 다른 형식 위반과 동일하게 거부한다(경고가 아님 - 근거는 GF-83 태스크 노트 참고).
#
# 글자 수는 바이트가 아니라 유니코드 코드포인트 단위로 센다. 이 저장소의 커밋
# 이력은 한글 위주라(한글 1자는 UTF-8로 3바이트) 바이트 기준으로 세면 "50자"
# 안내와 실제 강제 기준이 크게 어긋난다 - len(str)이 곧 코드포인트 수다.
#
# 본문 중 트레일러로 등록된 토큰(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:
fail(
f"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"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"commit-msg: {trailer_fixes}: 트레일러가 가리키는 커밋({fixes_hash})을 찾을 수 없습니다."
)
# Task-Id 브랜치 강제: <prefix>-<번호> 패턴이 브랜치명에 없으면 커밋을 거부한다.
# main/master/develop/release/* 등 예외 브랜치와 detached HEAD는 면제한다.
# 여기서 계산하는 TASK_PREFIX/BRANCH 블록은 post-commit의 trailer_task_id가
# 트레일러로 남길 때 재파싱하므로 두 파일에서 동일하게 유지한다.
def enforce_task_id_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"
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
# 접두어 앞에 글자/숫자가 아닌 문자(또는 문자열 시작)가 오도록 앵커링한다
# (GF-34) — 안 그러면 예: taskPrefix=ID일 때 "valid-42-fix"의 "id-4"
# 부분 문자열이 잘못 매치된다.
if not re.search(
rf"(^|[^a-zA-Z0-9]){re.escape(task_prefix)}-[0-9]+", branch, re.IGNORECASE
):
fail(
f"commit-msg: 브랜치명에 {task_prefix}-<번호> 패턴이 없습니다 (현재 브랜치: {branch}).",
f" 예: {task_prefix}-12-install-script",
" Task-Id 없이 커밋하려면 예외 브랜치(main/master/develop/release/*)에서 작업하세요.",
)
# AI-Model 존재/화이트리스트 강제(decision-5). Claude Code는 post-commit이 세션
# 트랜스크립트에서 자동으로 모델을 얻으므로 이 게이트에서 제외한다.
def enforce_ai_model_gate():
ai_tool_claude_code = conf_get("gitformat.aiToolClaudeCode")
ai_tool_gate = os.environ.get("AI_AGENT", "").split("_", 1)[0]
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"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"commit-msg: gitformat.aiModel 값 '{configured_model}'이 알려진 모델 목록에 없습니다.",
" hooks/gitformat.conf의 gitformat.knownModel 항목에 추가하세요.",
)
def run():
if os.path.isfile(os.path.join(GIT_DIR, "MERGE_HEAD")):
return 0
content = message_lines()
validate_format(content)
validate_length(content)
validate_fixes_trailer(content)
enforce_task_id_branch()
enforce_ai_model_gate()
return 0
STATUS = 1
try:
STATUS = run()
finally:
# prepare-commit-msg가 검증마커를 남긴 뒤 commit-msg가 거부하면 커밋 자체가 안
# 생성돼 post-commit이 안 돌고 마커가 안 지워진다. 이후 무관한 --no-verify
# 커밋이 그 스테일 마커를 "검증됨"으로 잘못 소비할 수 있다(GF-31). 0이 아닌
# 상태로 끝나면(거부든 예기치 못한 예외든) 항상 마커를 지운다 — 정상 통과
# (exit 0)할 때는 post-commit이 마커를 써야 하므로 지우지 않는다.
if STATUS != 0:
try:
os.remove(MARKER)
except OSError:
pass
sys.exit(STATUS)