From ef986c7f812d9ddaf1d7f7180cb72445e5658b11 Mon Sep 17 00:00:00 2001 From: coralstay <231006716+coralstay@users.noreply.github.com> Date: Sun, 4 Oct 2026 10:39:13 +0900 Subject: [PATCH 1/8] =?UTF-8?q?[docs][backlog]=20GF-127=20=EC=B0=A9?= =?UTF-8?q?=EC=88=98=EC=99=80=20revert=20=EC=A0=9C=EB=AA=A9=20=EC=98=88?= =?UTF-8?q?=EC=99=B8=20=EA=B2=B0=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Task-Id: GF-127 AI-Tool: claude-code AI-Tool-Version: 2.1.285 Co-Authored-By: Claude AI-Model: claude-opus-5-5 Tokens-Used: in=146189 out=507 Tool-Calls: 2 Hooks-Commit: c44d145 Signed-off-by: coralstay <231006716+coralstay@users.noreply.github.com> --- ...mit-msg\353\241\234-\354\235\264\354\240\204.md" | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git "a/backlog/tasks/gf-127 - \354\273\244\353\260\213-\353\251\224\354\213\234\354\247\200-\352\262\200\354\246\235\354\235\204-prepare-commit-msg\353\241\234-\354\235\264\354\240\204.md" "b/backlog/tasks/gf-127 - \354\273\244\353\260\213-\353\251\224\354\213\234\354\247\200-\352\262\200\354\246\235\354\235\204-prepare-commit-msg\353\241\234-\354\235\264\354\240\204.md" index 9b7276c..6a5bd7c 100644 --- "a/backlog/tasks/gf-127 - \354\273\244\353\260\213-\353\251\224\354\213\234\354\247\200-\352\262\200\354\246\235\354\235\204-prepare-commit-msg\353\241\234-\354\235\264\354\240\204.md" +++ "b/backlog/tasks/gf-127 - \354\273\244\353\260\213-\353\251\224\354\213\234\354\247\200-\352\262\200\354\246\235\354\235\204-prepare-commit-msg\353\241\234-\354\235\264\354\240\204.md" @@ -1,10 +1,11 @@ --- id: GF-127 title: 커밋 메시지 검증을 prepare-commit-msg로 이전 -status: To Do -assignee: [] +status: In Progress +assignee: + - '@claude' created_date: '2026-09-25 19:33' -updated_date: '2026-09-26 02:35' +updated_date: '2026-10-04 01:39' labels: - hooks - validation @@ -84,4 +85,10 @@ prepare-commit-msg로 옮기는 순간 clean revert가 전부 거부된다. 기 함께 결정할 것: 같은 태스크에 걸린 --amend 모호성(GF-125 코멘트 #1) — source=commit이 --amend --no-edit(최종 메시지)과 --amend(뒤에 에디터 열림)를 구분하지 못한다. --- + +author: @claude +created: 2026-10-04 01:39 +--- +2026-10-04 유저 결정: clean revert 문제는 (b)로 간다 — git이 만드는 'Revert "..."' 제목을 제목 규칙의 예외로 인정한다. 면제(a)는 cherry-pick까지 넓어지고, (c)는 git 기본 동작을 막는다. --amend 모호성(source=commit)은 이 태스크에서 해결하지 않고 알려진 한계로 남긴다. +--- From e73b3f4bf91fbf9e4047389eda957b1644e1a321 Mon Sep 17 00:00:00 2001 From: coralstay <231006716+coralstay@users.noreply.github.com> Date: Sun, 4 Oct 2026 10:44:01 +0900 Subject: [PATCH 2/8] =?UTF-8?q?[docs][backlog]=20doc-22=20=EC=9C=A0?= =?UTF-8?q?=EC=82=AC=20=ED=94=84=EB=A1=9C=EC=A0=9D=ED=8A=B8=20=EC=A1=B0?= =?UTF-8?q?=EC=82=AC=20=EA=B8=B0=EB=A1=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Task-Id: GF-127 AI-Tool: claude-code AI-Tool-Version: 2.1.285 Co-Authored-By: Claude AI-Model: claude-opus-5-5 Tokens-Used: in=331273 out=1399 Tool-Calls: 3 Hooks-Commit: a6e7568 Signed-off-by: coralstay <231006716+coralstay@users.noreply.github.com> --- ...4\352\265\254-\353\271\204\352\265\220.md" | 60 +++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 "backlog/docs/doc-22 - \354\234\240\354\202\254-\355\224\204\353\241\234\354\240\235\355\212\270-\354\241\260\354\202\254-\342\200\224-AI-\354\273\244\353\260\213-\354\266\234\354\262\230-\352\270\260\353\241\235-\353\217\204\352\265\254-\353\271\204\352\265\220.md" diff --git "a/backlog/docs/doc-22 - \354\234\240\354\202\254-\355\224\204\353\241\234\354\240\235\355\212\270-\354\241\260\354\202\254-\342\200\224-AI-\354\273\244\353\260\213-\354\266\234\354\262\230-\352\270\260\353\241\235-\353\217\204\352\265\254-\353\271\204\352\265\220.md" "b/backlog/docs/doc-22 - \354\234\240\354\202\254-\355\224\204\353\241\234\354\240\235\355\212\270-\354\241\260\354\202\254-\342\200\224-AI-\354\273\244\353\260\213-\354\266\234\354\262\230-\352\270\260\353\241\235-\353\217\204\352\265\254-\353\271\204\352\265\220.md" new file mode 100644 index 0000000..8dd95a0 --- /dev/null +++ "b/backlog/docs/doc-22 - \354\234\240\354\202\254-\355\224\204\353\241\234\354\240\235\355\212\270-\354\241\260\354\202\254-\342\200\224-AI-\354\273\244\353\260\213-\354\266\234\354\262\230-\352\270\260\353\241\235-\353\217\204\352\265\254-\353\271\204\352\265\220.md" @@ -0,0 +1,60 @@ +--- +id: doc-22 +title: 유사 프로젝트 조사 — AI 커밋 출처 기록 도구 비교 +type: other +created_date: '2026-10-04 01:43' +updated_date: '2026-10-04 01:43' +--- +2026-10-04 조사. AI 에이전트 커밋의 출처를 git에 남기는 도구·관례를 이 프로젝트와 비교한다. + +## 조사 대상 + +| 대상 | 기록 위치 | 기록 시점 | 강제 | 메시지 형식 검증 | +| --- | --- | --- | --- | --- | +| [block/aittributor](https://github.com/block/aittributor) | `Co-authored-by` 트레일러 | `prepare-commit-msg` | 훅 | 없음 | +| [mgoodric/ai-attribution-hooks](https://github.com/mgoodric/ai-attribution-hooks) | `Assisted-by`/`Co-authored-by`/`Generated-by` | `prepare-commit-msg` + `commit-msg` | 훅 + 집계 스크립트 | 없음 | +| [git-ai](https://github.com/git-ai-project/git-ai) | git notes(`refs/notes/ai`), 줄 단위 | 에이전트가 편집할 때마다 `git ai checkpoint` | 에이전트 훅. git 훅·래퍼 없음 | 없음 | +| [Codex CLI `commit_attribution`](https://codex.danielvaughan.com/2026/03/28/codex-cli-commit-attribution/) | 트레일러 | 모델 프롬프트에 지시 주입 | 없음(모델이 따를 뿐) | 없음 | +| [Aider](https://aider.chat/docs/git.html) | `Co-authored-by` 또는 author에 `(aider)` | 도구가 커밋할 때 | 도구 안에서만 | 없음 | +| [Linux 커널 정책](https://docs.kernel.org/process/coding-assistants.html) | `Assisted-by:` 에이전트·모델 + 보조 분석 도구 | 사람이 씀 | 리뷰 | 기존 커널 규칙 | +| [crashoverride 가이드](https://crashoverride.com/resources/knowledge-base/code-ownership/attributing-ai-commits-git) | `Generated-By: / (model: ; operator: )` | — | CI "Agent Trailer Lint" | — | +| commitlint, gitlint | — | `commit-msg`(+ CI에서 커밋 범위 검사) | 훅 + CI | 있음 | + +## 각 대상에서 확인한 사실 + +- **aittributor**: 에이전트 판정을 4단계로 한다 — 환경변수, 자기 프로세스 조상, 같은 저장소의 형제 프로세스, 에이전트 상태 파일(`~/.claude/projects/`, `~/.codex/sessions/`). 같은 이메일의 `Co-authored-by`가 이미 있으면 붙이지 않는다. lefthook 연동 또는 `.git/hooks/`에 심볼릭 링크로 설치. +- **ai-attribution-hooks**: AI 기여 비율에 따라 트레일러를 셋으로 나눈다(~33% / 35–67% / 67%+). AI 트레일러가 있으면 `commit-msg`가 `Signed-off-by`를 요구한다 — 사람이 책임진다는 이중 서명. `ai-attribution-stats.sh`가 커밋 범위에서 AI 커밋 비율과 서명 누락을 집계한다. +- **git-ai**: 커밋 메시지를 건드리지 않는다. 귀속 정보를 notes에 두고, rebase·cherry-pick·squash·reset 뒤에 최종 코드를 분석해 notes를 새 커밋으로 옮긴다(비동기, 결과적 일관성). 프롬프트는 마스킹해 git 밖에 저장. 커밋·PR별 토큰과 비용을 계산한다. +- **Codex**: 훅을 쓰지 않는다. 문서 스스로 "compliance is high but not absolute"라고 하고, 보장이 필요하면 `prepare-commit-msg` 훅을 덧대라고 권한다. +- **Linux 커널**: "AI agents MUST NOT add Signed-off-by tags. Only humans can legally certify the DCO." +- **crashoverride**: `Signed-off-by`를 운영자(사람)의 책임 인정으로 쓰라고 권한다. 과거 커밋은 다시 쓰지 말고 별도 CSV로 감사하라고 한다. + +## 이 프로젝트와 비교 + +### 강점 + +- **형식·출처·이력 불변을 한 도구에서 강제한다.** 출처 도구들은 메시지 형식을 보지 않고, 형식 검사기는 출처를 남기지 않는다. +- **훅으로 강제한다.** Codex·Aider처럼 도구나 모델이 협조해야 붙는 방식이 아니어서, 에이전트가 잊어도, 사람이 커밋해도 남는다. +- **모델명과 토큰이 서버가 발급한 값이다**(Claude Code 한정). 트랜스크립트의 `message.model`과 usage를 읽는다. 다른 도구는 고정 문자열이거나 에이전트 이름까지만 남긴다. +- **`Hooks-Commit`**: 훅 자체의 버전을 커밋마다 남겨, 훅 버그의 영향 범위를 역추적할 수 있다. 다른 도구에는 없다. +- **`Task-Id` 브랜치 강제**로 커밋과 작업을 잇는다. +- **의존성이 git + python3 표준 라이브러리뿐**이고, 트레일러가 메시지에 있어 `git log`만으로 읽히며 clone·push에 그대로 따라간다. notes는 따로 push·fetch해야 한다. + +### 약점 + +- **커밋 단위다.** git-ai는 줄 단위로 `blame`까지 된다. +- **기록이 메시지에 있어 이력을 다시 쓰면 깨진다.** git-ai는 재작성 뒤 notes를 옮겨 붙이는데, 이 프로젝트는 재작성을 금지하는 쪽(decision-24)으로 풀었다 — 사용자에게 워크플로 제약을 지운다. +- **지금은 `post-commit`의 `--amend`로 붙인다.** 해시가 바뀌고, 트레일러가 중복되고, rebase 중 실패한다. 비교한 훅 기반 도구는 전부 `prepare-commit-msg`에서 메시지 파일에 쓴다(GF-128이 같은 방향). +- **에이전트 판정이 `AI_AGENT` 하나다.** aittributor는 신호 4개를 본다(GF-130). +- **토큰 측정이 Claude Code에만 되고, 커밋 시점에 트랜스크립트를 거슬러 재구성한다**(실험 단계, doc-16). git-ai는 편집 시점에 체크포인트를 쌓는다. +- **`Signed-off-by`를 에이전트 커밋에도 자동으로 붙인다.** 커널·crashoverride·ai-attribution-hooks가 쓰는 의미(사람의 DCO 서명)와 충돌한다. +- **에디터 경로를 거부한다.** commitlint·gitlint는 `commit-msg`에서 돌아 에디터 커밋도 검증한다. 우회 차단(decision-18)과 맞바꾼 비용이다. +- **`core.hooksPath`를 점유해 다른 훅과 조합할 수 없다.** aittributor는 lefthook으로 조합된다. +- **집계 도구가 없다.** ai-attribution-hooks의 stats 스크립트, git-ai의 `stats`/`blame` 같은 읽기 도구가 없다. +- **트레일러를 손으로 위조해도 확인하는 곳이 없다.** CI 검사(DRAFT-19)는 아직 드래프트이고 서명·증명(attestation)도 없다. +- **트레일러 이름이 독자적이다.** 업계는 `Assisted-by`/`Generated-By` 쪽으로 모이는 중이다(표준은 아직 없음). GF-128의 `AI-Agent` 이름을 정할 때 고려할 것. + +## 후속 + +- `Signed-off-by` 재검토, 체크포인트·notes 기반 측정 검토는 각각 드래프트로 남겼다. +- 이미 있는 작업과 겹치는 것: 메시지 파일 직접 쓰기·키 단위 중복 차단(GF-128), 에이전트 판정(GF-130), CI 검사(DRAFT-19). From d234cd62f85dc69fa3d4bca0402c67bece29963e Mon Sep 17 00:00:00 2001 From: coralstay <231006716+coralstay@users.noreply.github.com> Date: Sun, 4 Oct 2026 10:44:02 +0900 Subject: [PATCH 3/8] =?UTF-8?q?[docs][backlog]=20DRAFT-22=C2=B723=20?= =?UTF-8?q?=EC=A1=B0=EC=82=AC=20=ED=9B=84=EC=86=8D=20=EB=93=9C=EB=9E=98?= =?UTF-8?q?=ED=94=84=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Task-Id: GF-127 AI-Tool: claude-code AI-Tool-Version: 2.1.285 Co-Authored-By: Claude AI-Model: claude-opus-5-5 Tokens-Used: in=0 out=0 (no-attributed-turn) Tool-Calls: 0 (no-attributed-turn) Hooks-Commit: 6e017c6 Signed-off-by: coralstay <231006716+coralstay@users.noreply.github.com> --- ...0\353\257\270-\354\266\251\353\217\214.md" | 36 ++++++++++++++++++ ...00\355\206\240\355\225\234\353\213\244.md" | 38 +++++++++++++++++++ 2 files changed, 74 insertions(+) create mode 100644 "backlog/drafts/draft-22 - Signed-off-by-\354\236\220\353\217\231-\354\202\275\354\236\205\354\235\204-\354\236\254\352\262\200\355\206\240\355\225\234\353\213\244-\342\200\224-DCO-\354\235\230\353\257\270-\354\266\251\353\217\214.md" create mode 100644 "backlog/drafts/draft-23 - \355\206\240\355\201\260-\354\270\241\354\240\225\354\235\204-\355\216\270\354\247\221-\354\213\234\354\240\220-\354\262\264\355\201\254\355\217\254\354\235\270\355\212\270\354\231\200-git-notes\353\241\234-\354\230\256\352\270\270\354\247\200-\352\262\200\355\206\240\355\225\234\353\213\244.md" diff --git "a/backlog/drafts/draft-22 - Signed-off-by-\354\236\220\353\217\231-\354\202\275\354\236\205\354\235\204-\354\236\254\352\262\200\355\206\240\355\225\234\353\213\244-\342\200\224-DCO-\354\235\230\353\257\270-\354\266\251\353\217\214.md" "b/backlog/drafts/draft-22 - Signed-off-by-\354\236\220\353\217\231-\354\202\275\354\236\205\354\235\204-\354\236\254\352\262\200\355\206\240\355\225\234\353\213\244-\342\200\224-DCO-\354\235\230\353\257\270-\354\266\251\353\217\214.md" new file mode 100644 index 0000000..e6dfc04 --- /dev/null +++ "b/backlog/drafts/draft-22 - Signed-off-by-\354\236\220\353\217\231-\354\202\275\354\236\205\354\235\204-\354\236\254\352\262\200\355\206\240\355\225\234\353\213\244-\342\200\224-DCO-\354\235\230\353\257\270-\354\266\251\353\217\214.md" @@ -0,0 +1,36 @@ +--- +id: DRAFT-22 +title: Signed-off-by 자동 삽입을 재검토한다 — DCO 의미 충돌 +status: Draft +assignee: [] +created_date: '2026-10-04 01:43' +updated_date: '2026-10-04 01:43' +labels: + - trailers + - policy +dependencies: [] +references: + - decision-25 + - decision-19 +documentation: + - backlog/docs/doc-22 - 유사-프로젝트-조사-—-AI-커밋-출처-기록-도구-비교.md +priority: medium +--- + +## Description + + +post-commit은 모든 커밋에 커미터 정보로 Signed-off-by를 자동으로 붙인다(decision-25가 decision-19의 제거 조항을 대체해 유지). 그런데 Signed-off-by는 관례상 사람이 DCO를 인증한다는 서명이다. + +- Linux 커널 정책: "AI agents MUST NOT add Signed-off-by tags. Only humans can legally certify the DCO." +- ai-attribution-hooks와 crashoverride 가이드는 Signed-off-by를 AI 커밋에 대한 사람의 책임 인정으로 쓰고, AI 트레일러가 있으면 이를 요구한다. + +이 훅이 에이전트 커밋에도 자동으로 붙이면, DCO를 요구하는 저장소에 설치했을 때 사람이 인증하지 않은 커밋이 인증된 것처럼 보인다. 조사 근거는 doc-22. + + +## Acceptance Criteria + +- [ ] #1 Signed-off-by를 유지·제거·사람 커밋에만 붙임 중 하나로 정하고 decision으로 기록한다 +- [ ] #2 결정에 맞게 GF-128 AC #10을 갱신한다 +- [ ] #3 DCO를 요구하는 저장소에 설치할 때의 동작이 README에 적혀 있다 + diff --git "a/backlog/drafts/draft-23 - \355\206\240\355\201\260-\354\270\241\354\240\225\354\235\204-\355\216\270\354\247\221-\354\213\234\354\240\220-\354\262\264\355\201\254\355\217\254\354\235\270\355\212\270\354\231\200-git-notes\353\241\234-\354\230\256\352\270\270\354\247\200-\352\262\200\355\206\240\355\225\234\353\213\244.md" "b/backlog/drafts/draft-23 - \355\206\240\355\201\260-\354\270\241\354\240\225\354\235\204-\355\216\270\354\247\221-\354\213\234\354\240\220-\354\262\264\355\201\254\355\217\254\354\235\270\355\212\270\354\231\200-git-notes\353\241\234-\354\230\256\352\270\270\354\247\200-\352\262\200\355\206\240\355\225\234\353\213\244.md" new file mode 100644 index 0000000..c711d24 --- /dev/null +++ "b/backlog/drafts/draft-23 - \355\206\240\355\201\260-\354\270\241\354\240\225\354\235\204-\355\216\270\354\247\221-\354\213\234\354\240\220-\354\262\264\355\201\254\355\217\254\354\235\270\355\212\270\354\231\200-git-notes\353\241\234-\354\230\256\352\270\270\354\247\200-\352\262\200\355\206\240\355\225\234\353\213\244.md" @@ -0,0 +1,38 @@ +--- +id: DRAFT-23 +title: 토큰 측정을 편집 시점 체크포인트와 git notes로 옮길지 검토한다 +status: Draft +assignee: [] +created_date: '2026-10-04 01:43' +updated_date: '2026-10-04 01:43' +labels: + - measurement + - spike +dependencies: [] +references: + - decision-27 + - decision-24 +documentation: + - backlog/docs/doc-22 - 유사-프로젝트-조사-—-AI-커밋-출처-기록-도구-비교.md + - backlog/docs/doc-16 - 토큰·툴콜-측정-방법과-한계.md +priority: medium +--- + +## Description + + +Tokens-Used/Tool-Calls는 커밋 시점에 Claude Code 트랜스크립트를 거슬러 읽어 이 커밋의 파일을 건드린 응답을 재구성한다(decision-27, doc-16). 측정 방법론은 아직 실험 단계이고 Claude Code에만 된다. + +git-ai는 다르게 한다(doc-22). +- 에이전트가 파일을 고칠 때마다 체크포인트를 남기고, 커밋할 때 합친다 — 사후 재구성이 없다. Claude Code에서는 PostToolUse 훅으로 같은 일을 할 수 있다. +- 결과를 커밋 메시지가 아니라 git notes(refs/notes/ai)에 둔다. 메시지를 다시 쓰지 않아 append-only(decision-24)와 맞고, rebase·cherry-pick 뒤에도 notes를 옮겨 붙인다. 대신 git log만으로 안 보이고 notes를 따로 push·fetch해야 한다. + +두 가지(체크포인트 수집, notes 저장)는 독립적으로 채택할 수 있다. 어느 쪽이 지금 방식보다 나은지 판단할 근거가 필요하다. + + +## Acceptance Criteria + +- [ ] #1 같은 세션을 지금 방식과 체크포인트 방식으로 측정해 결과 차이를 비교한다 +- [ ] #2 notes 저장 시 push·fetch·rebase에서 기록이 살아남는지 실측한다 +- [ ] #3 채택·기각 여부를 decision으로 기록한다 + From 0d9df833ed1de7952068f7b124f58b1e757772e8 Mon Sep 17 00:00:00 2001 From: coralstay <231006716+coralstay@users.noreply.github.com> Date: Sun, 4 Oct 2026 10:47:29 +0900 Subject: [PATCH 4/8] =?UTF-8?q?[feat][hooks]=20=EB=A9=94=EC=8B=9C=EC=A7=80?= =?UTF-8?q?=20=EA=B2=80=EC=A6=9D=EC=9D=84=20prepare-commit-msg=EB=A1=9C=20?= =?UTF-8?q?=EC=9D=B4=EC=A0=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit commit-msg의 검증을 동작 그대로 옮기고 commit-msg를 삭제한다. --no-verify로 검증을 건너뛸 수 없게 된다. git revert가 만드는 Revert/Reapply "..." 제목은 형식·50자 규칙의 예외로 둔다. 스테일 마커는 거부 경로보다 먼저 지워 거부 시 마커가 남지 않는다. Co-Authored-By: Claude Opus 5.5 Task-Id: GF-127 AI-Tool: claude-code AI-Tool-Version: 2.1.285 Co-Authored-By: Claude AI-Model: claude-opus-5-5 Tokens-Used: in=1694468 out=722 Tool-Calls: 18 Hooks-Commit: 100d70e Signed-off-by: coralstay <231006716+coralstay@users.noreply.github.com> --- hooks/commit-msg | 331 ---------------------- hooks/prepare-commit-msg | 362 ++++++++++++++++++++++-- tests/test_branch_task_id_required.py | 2 +- tests/test_config_file_unreadable.py | 11 +- tests/test_config_keys_match_hooks.py | 3 +- tests/test_install_script.py | 7 +- tests/test_message_format_enforced.py | 78 ++++- tests/test_non_utf8_locale.py | 2 +- tests/test_python3_missing.py | 21 +- tests/test_replay_commits_untouched.py | 21 +- tests/test_shell_metacharacters_safe.py | 14 +- tests/test_token_usage_measurement.py | 2 +- tests/test_trailer_hooks_commit.py | 4 + tests/test_trailer_task_id.py | 2 +- tests/test_verify_bypass_detection.py | 7 +- 15 files changed, 451 insertions(+), 416 deletions(-) delete mode 100755 hooks/commit-msg diff --git a/hooks/commit-msg b/hooks/commit-msg deleted file mode 100755 index 35f22a0..0000000 --- a/hooks/commit-msg +++ /dev/null @@ -1,331 +0,0 @@ -#!/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] (subsystem 생략 가능: [type] )", - " 허용 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] ", - " (빈 줄)", - " <본문 또는 트레일러>", - ) - - -# 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] ", - ) - - # 알려진 트레일러 토큰 목록을 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: 트레일러는 강제하지 않지만(원인 커밋을 항상 알 수 있는 건 아님), -# 있으면 참조 해시가 저장소에 실재하는 커밋인지 검증한다 - 오타/잘못된 참조를 잡는다. -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 브랜치 강제: -<번호> 패턴이 브랜치명에 없으면 커밋을 거부한다. -# 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 로 설정하세요.", - ) - - 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) diff --git a/hooks/prepare-commit-msg b/hooks/prepare-commit-msg index 2595512..fdb66be 100755 --- a/hooks/prepare-commit-msg +++ b/hooks/prepare-commit-msg @@ -1,14 +1,13 @@ #!/usr/bin/env python3 # prepare-commit-msg 훅: 커밋 규칙 강제를 훅 하나로 모으는 재설계의 본체다 -# (decision-18). 지금 하는 일은 세 가지다 — 재생·병합 커밋 면제, 에디터 경로 거부, -# 검증마커 기록. -# 메시지 검증(GF-127)과 트레일러 삽입(GF-128)은 후속 태스크에서 구 훅에서 이 파일로 -# 옮겨온다. 그때까지 commit-msg/post-commit이 그대로 자기 일을 계속한다. +# (decision-18). 지금 하는 일은 네 가지다 — 재생·병합 커밋 면제, 에디터 경로 거부, +# 커밋 메시지 검증, 검증마커 기록. +# 메시지 검증은 GF-127에서 commit-msg로부터 옮겨왔고 commit-msg는 그때 삭제됐다 — +# 둘 다 남기면 같은 검증이 두 번 돈다. 트레일러 삽입(GF-128)은 아직 post-commit이 한다. # # GF-126에서 언어별 lint를 pre-commit에서 이 파일로 옮겼다가 GF-135에서 그 lint를 # 통째로 지웠다 — 언어 lint는 coding-agent-git-commit-tool의 범위(형식·출처 기록·이력 불변, decision-25) -# 어디에도 속하지 않는 기능이었다(decision-23). 남은 것은 검증마커 기록뿐이고 그 이유는 -# write_verified_marker()의 주석에 있다. +# 어디에도 속하지 않는 기능이었다(decision-23). # # 이 훅을 본체로 삼는 근거(실측 절차와 결과는 doc-15): prepare-commit-msg는 # --no-verify로도 건너뛸 수 없고, 커밋 객체가 만들어지기 전에 돌며, exit 1로 커밋을 @@ -28,7 +27,9 @@ # 나열돼 있다. 다른 훅과 겹치는 블록(자기 위치 해석, conf 읽기 가드)은 공유 모듈로 # 빼지 않고 파일마다 독립적으로 중복을 유지한다 — 파일 하나만 읽으면 그 훅의 동작을 # 전부 파악할 수 있어야 한다는 감사 가능성 요구사항이다(decision-16). +import fnmatch import os +import re import subprocess import sys import time @@ -100,6 +101,32 @@ def conf_get(key): ).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 + ] + + MARKER = os.path.join(GIT_DIR, conf_get("gitformat.markerFile")) @@ -142,7 +169,7 @@ def is_replay_commit(): # source로 판정하면 두 번째 줄이 빠져나간다 — CI(전역 commit.template이 없는 환경)에서 # 에디터 커밋이 통과하는 것으로 실제로 드러났다. # -# 주석 문자는 열 0의 '#'로 본다 — commit-msg의 본문 판정과 같은 규약이다. +# 주석 문자는 열 0의 '#'로 본다 — 아래 message_lines()의 본문 판정과 같은 규약이다. def has_author_message(): if not MSG_FILE: return True @@ -160,7 +187,7 @@ def has_author_message(): # 에디터 경로 거부(decision-18, AC #2). 에디터는 이 훅이 끝난 뒤에 열리므로 훅이 보는 # 메시지 파일에는 아직 사람이 쓸 내용이 없다 — 최종 메시지를 검증할 방법이 원리적으로 # 없다. 거부하면 "통과한 커밋은 모두 검증을 거쳤다"가 성립하고, 그것이 검증을 이 훅으로 -# 옮겨올 GF-127의 전제다. +# 옮겨온 GF-127의 전제다. def reject_editor_path(): if has_author_message(): return @@ -174,11 +201,18 @@ def reject_editor_path(): ) -# 이전 실행에서 남은 검증마커는 시작 시점에 무효화한다(GF-31). 마커가 "이번 커밋에서 -# 이 훅이 돌았다"는 뜻이어야 하는데, 앞선 커밋이 마커를 쓴 뒤 다른 이유로 실패해 그 -# 마커를 남겼으면 그 값이 다음 커밋으로 새어든다. 마커가 gate로서의 의미를 잃은 뒤에도 -# (아래 write_verified_marker() 참고) 이 무효화는 남는다 — 마커의 나이가 판정에 쓰이지는 -# 않지만, "직전 실행이 남긴 파일"이 그대로 살아 있는 상태를 만들지 않는다. +# 이전 실행에서 남은 검증마커는 판단을 시작하기 전에 무효화한다(GF-31). 마커가 "이번 +# 커밋에서 이 훅이 돌았다"는 뜻이어야 하는데, 앞선 커밋이 마커를 쓴 뒤 다른 이유로 +# 실패해 그 마커를 남겼으면 그 값이 다음 커밋으로 새어든다. 마커가 gate로서의 의미를 +# 잃은 뒤에도(아래 write_verified_marker() 참고) 이 무효화는 남는다 — 마커의 나이가 +# 판정에 쓰이지는 않지만, "직전 실행이 남긴 파일"이 그대로 살아 있는 상태를 만들지 +# 않는다. +# +# 거부 경로보다 **앞에서** 지우는 이유: 예전에는 commit-msg가 거부할 때 finally에서 +# 마커를 지웠다(GF-31) — prepare-commit-msg가 먼저 마커를 써둔 뒤 commit-msg가 커밋을 +# 막으면 post-commit이 돌지 않아 마커가 남았기 때문이다. 검증이 이 훅으로 옮겨온 뒤에는 +# 지우기를 앞당기고 쓰기를 맨 뒤에 두는 것만으로 같은 불변조건이 선다 — 에디터 거부든 +# 검증 거부든 예기치 못한 예외든, 이 훅이 0이 아닌 상태로 끝나면 마커가 없다. def invalidate_stale_marker(): try: os.remove(MARKER) @@ -186,12 +220,276 @@ def invalidate_stale_marker(): pass +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] (subsystem 생략 가능: [type] )", + " 허용 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] ", + " (빈 줄)", + " <본문 또는 트레일러>", + ) + + +# 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] ", + ) + + # 알려진 트레일러 토큰 목록을 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: 트레일러는 강제하지 않지만(원인 커밋을 항상 알 수 있는 건 아님), +# 있으면 참조 해시가 저장소에 실재하는 커밋인지 검증한다 - 오타/잘못된 참조를 잡는다. +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 브랜치 강제: -<번호> 패턴이 브랜치명에 없으면 커밋을 거부한다. +# 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"prepare-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"prepare-commit-msg: AI 도구({ai_tool_gate})가 감지됐지만 gitformat.aiModel이 설정되지 않았습니다.", + " git config gitformat.aiModel 로 설정하세요.", + ) + + 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에서 post-commit과 함께 제거한다.** # # **이 마커는 더 이상 아무것도 gate하지 않는다.** GF-126까지는 "언어별 lint를 전부 # 통과했다"는 뜻이었지만 GF-135에서 그 lint를 지웠으므로(decision-23) 지금 남은 뜻은 -# "prepare-commit-msg가 돌았다"뿐이다. 그래서 조건 없이 쓴다 — 통과/실패를 판정할 -# 대상이 없다. +# "prepare-commit-msg가 돌았다"뿐이다. 메시지 검증이 이 훅으로 옮겨온(GF-127) 뒤로는 +# 검증을 통과한 커밋에서만 써지지만, post-commit이 그 사실을 판정에 쓰지는 않는다. # # 그런데도 계속 써야 하는 이유는 post-commit이다. post-commit은 이 마커의 **부재**를 # --no-verify 우회의 증거로 읽어 Verify-Bypassed: true를 붙인다(decision-3, GF-31). @@ -200,35 +498,41 @@ def invalidate_stale_marker(): # 실측). 형식과 위치는 기존 계약 그대로 — $GIT_DIR/에 # " " 한 줄이다. 바꾸면 post-commit의 판정이 깨진다. # -# 이 훅은 --no-verify로도 건너뛸 수 없으므로(doc-15) 마커는 항상 써지고, 따라서 -# **Verify-Bypassed는 사실상 도달 불가능하다.** 남는 경로는 재생 커밋뿐이다 — 맨 위 -# 면제로 먼저 빠져나가 마커가 없으니 post-commit이 그 트레일러를 여전히 큐에 넣는다. -# 다만 그 경로에서는 amend 자체가 실패해(archive DRAFT-18) 커밋에 남지 않는다(GF-126 -# 실측). 이 시점에는 commit-msg가 아직 살아 있고 --no-verify가 그것을 건너뛰므로 -# '메시지 검증 우회'만 기록 없이 남는다 — 검증을 이 훅으로 옮기는 GF-127이 그 한 -# 태스크짜리 과도기를 닫는다. +# 이 훅은 --no-verify로도 건너뛸 수 없으므로(doc-15) 통과한 커밋에는 마커가 항상 +# 써지고, 따라서 **Verify-Bypassed는 사실상 도달 불가능하다.** 남는 경로는 재생 +# 커밋뿐이다 — 맨 위 면제로 먼저 빠져나가 마커가 없으니 post-commit이 그 트레일러를 +# 여전히 큐에 넣는다. 다만 그 경로에서는 amend 자체가 실패해(archive DRAFT-18) 커밋에 +# 남지 않는다(GF-126 실측). def write_verified_marker(): with open(MARKER, "w", encoding="utf-8") as f: f.write(f"{int(time.time())} {os.getpid()}\n") -# 실행 순서(decision-18): 재생·병합 커밋 면제 → 에디터 경로 거부 → 검증마커 기록. +# 실행 순서(decision-18): 재생·병합 커밋 면제 → (스테일 마커 무효화) → 에디터 경로 +# 거부 → 메시지 검증 → 검증마커 기록. # # 면제가 맨 앞인 것은 바꿀 수 없다. 에디터로 여는 병합/revert 커밋은 source가 template이 -# 아니라 merge로 오지만(실측), 앞으로 판단을 더 붙이면서 면제를 뒤로 미루면 사람이 -# 메시지를 고를 수조차 없는 재생 경로가 거부된다. +# 아니라 merge로 오지만(실측), 면제를 뒤로 미루면 사람이 메시지를 고를 수조차 없는 +# 재생 경로(cherry-pick이 옮겨오는 옛 형식 제목, 병합 메시지 등)가 거부된다. # -# 마커 기록이 맨 뒤인 것은 GF-126의 순서를 그대로 둔 것이다 — 그때는 "lint가 전부 -# 통과한 뒤에만 쓴다"는 뜻이 있었고, GF-135로 그 뜻은 없어졌지만 거부된 커밋에 굳이 -# 파일을 남길 이유도 없다. +# 에디터 거부가 검증보다 앞인 것은 검증할 메시지가 아직 없기 때문이다 — 순서를 +# 바꾸면 에디터 커밋이 "-m을 쓰라"는 안내 대신 형식 오류로 거부돼 원인이 가려진다. +# +# 스테일 마커 무효화를 거부 경로보다 앞에 두고 마커 기록을 맨 뒤에 두므로, 이 훅이 +# 거부로 끝나면 마커가 남지 않는다(invalidate_stale_marker()의 주석 참고). +# +# 검증은 트레일러 삽입보다 먼저 돈다(AC #8) — 사람이 쓴 부분만 검증 대상이다. 지금은 +# 트레일러를 post-commit이 커밋 뒤에 붙이므로 순서가 저절로 보장되고, GF-128에서 +# 삽입이 이 훅으로 옮겨올 때 validate_message() 뒤에 두어야 한다. # # 파일 맨 위의 amend 가드는 이 순서보다 앞서 돈다 — post-commit의 재진입 차단과 같은 # 규약이라 그 위치를 유지한다. if is_replay_commit(): sys.exit(0) -reject_editor_path() invalidate_stale_marker() +reject_editor_path() +validate_message() write_verified_marker() sys.exit(0) diff --git a/tests/test_branch_task_id_required.py b/tests/test_branch_task_id_required.py index ce27dbf..7bd6fc7 100644 --- a/tests/test_branch_task_id_required.py +++ b/tests/test_branch_task_id_required.py @@ -1,4 +1,4 @@ -"""commit-msg가 브랜치명의 Task-Id 패턴을 강제하는지 본다(decision-4). +"""prepare-commit-msg가 브랜치명의 Task-Id 패턴을 강제하는지 본다(decision-4). 구 robustness-commit-msg.bats(GF-23)의 브랜치 관련 케이스. -<번호> 패턴이 없으면 예외 브랜치가 아닌 한 커밋을 거부한다. 예외 목록은 로컬 오버라이드와 내장 diff --git a/tests/test_config_file_unreadable.py b/tests/test_config_file_unreadable.py index fc41d0e..1b660e7 100644 --- a/tests/test_config_file_unreadable.py +++ b/tests/test_config_file_unreadable.py @@ -5,9 +5,9 @@ 실행해 exit 0이 아니고 명확한 에러 메시지가 나오는지 본다 — 빈 값으로 진행하면 원인을 알 수 없는 거부가 된다. -대상은 훅 3개와 install.sh다. GF-135에서 checks/가 삭제되며 그 3개 파일의 가드 검증 -케이스도 함께 사라졌다 — 검증할 파일 자체가 없어졌기 때문이지, 가드 정책이 바뀐 게 -아니다. +대상은 훅 2개와 install.sh다. GF-135에서 checks/가 삭제되며 그 3개 파일의 가드 검증 +케이스도 함께 사라졌고, GF-127에서 commit-msg가 삭제되며 그 케이스도 사라졌다 — 검증할 +파일 자체가 없어졌기 때문이지, 가드 정책이 바뀐 게 아니다. 진짜 저장소의 hooks/gitformat.conf는 절대 건드리지 않고, 매 테스트마다 hooks/를 임시 디렉터리에 복사해 그 사본의 conf만 깨뜨린다. @@ -36,11 +36,6 @@ def assertConfGuardFires(self, result): self.assertNotEqual(0, result.returncode, str(result)) self.assertIn(CONF_GUARD_MESSAGE, result.output) - def test_commit_msg가_멈춘다(self): - """commit-msg(python): conf가 깨지면 명확한 에러로 즉시 멈춘다""" - msg_file = self.write("msgfile", "[feat] test\n") - self.assertConfGuardFires(self.python(self.hooks_copy / "commit-msg", msg_file)) - def test_prepare_commit_msg가_멈춘다(self): """prepare-commit-msg(python): conf가 깨지면 명확한 에러로 즉시 멈춘다""" # GF-126에서 pre-commit이 삭제되며 가드 검증 대상이 이 훅으로 옮겨왔다. diff --git a/tests/test_config_keys_match_hooks.py b/tests/test_config_keys_match_hooks.py index 21b6d18..00955b3 100644 --- a/tests/test_config_keys_match_hooks.py +++ b/tests/test_config_keys_match_hooks.py @@ -21,7 +21,6 @@ # 보고 개별 키는 읽지 않으므로 여기 없다. CONF_READERS = ( HOOKS_DIR / "prepare-commit-msg", - HOOKS_DIR / "commit-msg", HOOKS_DIR / "post-commit", ) @@ -87,7 +86,7 @@ def test_훅이_참조하는_conf_키가_전부_존재하고_값이_있다(self) value, f"누락되거나 빈 값: {key} (참조: {', '.join(sources)})" ) - # 트레일러 키는 섹션 전체를 동적으로 읽어 대조한다. commit-msg는 본문 줄 + # 트레일러 키는 섹션 전체를 동적으로 읽어 대조한다. prepare-commit-msg는 본문 줄 # 길이 예외 판정에 이 섹션을 통째로(--get-regexp) 쓰므로, 이름으로 참조되지 # 않는 키(예: trailer.breakingChange)도 실제로 쓰인다 — 그래서 "훅이 이름으로 # 참조하는 트레일러 키 ⊆ 섹션"만 요구하고, 섹션의 모든 값이 비어있지 않은지는 diff --git a/tests/test_install_script.py b/tests/test_install_script.py index b0a5513..b735c39 100644 --- a/tests/test_install_script.py +++ b/tests/test_install_script.py @@ -89,8 +89,9 @@ def test_global을_두_번_실행해도_심볼릭_링크가_유지된다(self): self.assertEqual(first, second) self.assertEqual(HOOKS_DIR / "prepare-commit-msg", first) - self.assertTrue((TEMPLATE_DIR / "hooks" / "commit-msg").is_symlink()) self.assertTrue((TEMPLATE_DIR / "hooks" / "post-commit").is_symlink()) + # GF-127에서 삭제된 훅의 링크는 남지 않는다. + self.assertFalse((TEMPLATE_DIR / "hooks" / "commit-msg").is_symlink()) # 실제 전역 git 설정이 아니라 가짜 HOME 쪽에 반영됐는지를 직접 확인한다. self.assertEqual( @@ -113,7 +114,7 @@ def test_삭제된_훅의_심볼릭_링크도_정리된다(self): self.assertTrue( (root / "template" / "hooks" / "prepare-commit-msg").is_symlink() ) - self.assertTrue((root / "template" / "hooks" / "commit-msg").is_symlink()) + self.assertTrue((root / "template" / "hooks" / "post-commit").is_symlink()) # hooks/에서 파일 하나를 지운다 (GF-86의 hooks/pre-push 삭제 상황 재현). (root / "hooks" / "prepare-commit-msg").unlink() @@ -126,8 +127,8 @@ def test_삭제된_훅의_심볼릭_링크도_정리된다(self): self.assertFalse(stale.is_symlink()) # 여전히 존재하는 훅의 심볼릭 링크는 그대로 유지된다. - self.assertTrue((root / "template" / "hooks" / "commit-msg").is_symlink()) self.assertTrue((root / "template" / "hooks" / "post-commit").is_symlink()) + self.assertTrue((root / "template" / "hooks" / "gitformat.conf").is_symlink()) # ── 회귀: 테스트가 이 저장소 자신의 설정을 오염시키지 않는다 ───── diff --git a/tests/test_message_format_enforced.py b/tests/test_message_format_enforced.py index 6da2aff..0eef2e1 100644 --- a/tests/test_message_format_enforced.py +++ b/tests/test_message_format_enforced.py @@ -1,7 +1,10 @@ -"""commit-msg가 커밋 메시지 형식·길이·Fixes·AI 모델 게이트를 강제하는지 본다. +"""prepare-commit-msg가 커밋 메시지 형식·길이·Fixes·AI 모델 게이트를 강제하는지 본다. 구 robustness-commit-msg.bats(GF-23)에서 브랜치 Task-Id 강제를 뺀 나머지. -브랜치 쪽은 test_branch_task_id_required.py가 맡는다. +브랜치 쪽은 test_branch_task_id_required.py가 맡는다. GF-127에서 검증이 commit-msg에서 +prepare-commit-msg로 옮겨오고 commit-msg는 삭제됐다 — 아래 케이스는 전부 실제 +`git commit`을 태우는 블랙박스 테스트라 그대로 새 경로를 검증한다. 옮겨오며 생긴 +동작(--no-verify로 우회 불가, git revert 제목 예외, 거부 시 마커 없음)은 맨 아래에 있다. decision-8: 표준 인증이 아니라 실제 버그 이력(GF-30, GF-34, GF-35)에 근거한 실용적 테스트. GF-82에서 서브젝트가 [type][subsystem] 프리픽스로 바뀌었고, GF-83에서 제목 @@ -173,5 +176,76 @@ def test_등록된_트레일러_토큰_줄은_72자를_넘어도_통과한다(se ) + # ── --no-verify로 우회할 수 없다 (GF-127, decision-18) ─────────── + + def test_no_verify로도_형식_검증을_건너뛸_수_없다(self): + """[GF-127] --no-verify를 줘도 형식이 틀린 메시지는 거부된다""" + result = self.commit("형식 없는 제목", "--no-verify") + self.assertRejected(result) + self.assertIn("커밋 메시지가 [type][subsystem] 형식이 아닙니다", result.output) + self.assertRejected(self.git("log", "-1")) + + def test_no_verify로도_길이_검증을_건너뛸_수_없다(self): + """[GF-127] --no-verify를 줘도 본문 줄 72자 초과는 거부된다""" + self.assertRejected(self.commit("[feat] 제목\n\n" + "x" * 73, "--no-verify")) + + def test_no_verify로도_브랜치_Task_Id_강제를_건너뛸_수_없다(self): + """[GF-127] --no-verify를 줘도 Task-Id 없는 non-exempt 브랜치는 거부된다""" + self.git_ok("checkout", "-q", "-b", "no-task-id-here") + self.assertRejected(self.commit("[feat] missing task id", "--no-verify")) + + # ── git revert가 만드는 제목 예외 (GF-127, 유저 결정 (b)) ───────── + + def test_clean_revert_no_edit은_통과한다(self): + """[GF-127] 충돌 없는 git revert --no-edit은 Revert "..." 제목으로 통과한다""" + self.commit_ok("[feat] 되돌릴 커밋") + result = self.git("revert", "--no-edit", "HEAD") + self.assertAccepted(result) + self.assertEqual(2, self.commit_count()) + self.assertEqual('Revert "[feat] 되돌릴 커밋"', self.head_subject()) + + def test_50자_제목을_revert해도_길이_제한에_걸리지_않는다(self): + """[GF-127] git이 만든 revert 제목은 50자를 넘어도 통과한다 (60자)""" + subject = "[feat] " + "x" * 43 + self.commit_ok(subject) + self.assertAccepted(self.git("revert", "--no-edit", "HEAD")) + self.assertEqual(f'Revert "{subject}"', self.head_subject()) + self.assertGreater(len(self.head_subject()), 50) + + def test_revert를_revert한_Reapply도_통과한다(self): + """[GF-127] revert의 revert(git 2.43+의 Reapply "...")와 그 revert도 통과한다""" + self.commit_ok("[feat] 되돌릴 커밋") + self.assertAccepted(self.git("revert", "--no-edit", "HEAD")) + self.assertAccepted(self.git("revert", "--no-edit", "HEAD")) + reapply = self.head_subject() + # git 버전에 따라 Reapply "..." 또는 Revert "Revert "..."" 둘 중 하나다. + self.assertIn( + reapply, + ('Reapply "[feat] 되돌릴 커밋"', 'Revert "Revert "[feat] 되돌릴 커밋""'), + ) + self.assertAccepted(self.git("revert", "--no-edit", "HEAD")) + self.assertEqual(f'Revert "{reapply}"', self.head_subject()) + self.assertEqual(4, self.commit_count()) + + def test_revert_비슷한_손글씨_제목은_거부된다(self): + """[GF-127] 따옴표 없는 Revert 제목이나 다른 git 자동 제목은 예외가 아니다""" + for subject in ("Revert 되돌림", 'revert "소문자"', 'Revert ""', "fixup! [feat] x"): + with self.subTest(subject=subject): + self.assertRejected(self.commit(subject)) + + def test_revert_제목도_본문_빈_줄_규칙은_지킨다(self): + """[GF-127] revert 제목 예외는 제목 형식·길이만이다 — 빈 줄 규칙은 그대로다""" + self.assertRejected(self.commit('Revert "[feat] x"\n바로 이어진 본문')) + + # ── 거부하면 검증 마커가 남지 않는다 (GF-31 → GF-127) ──────────── + + def test_거부되면_스테일_마커도_남지_않는다(self): + """[GF-31] 이전 실행의 마커가 남아 있어도 검증에서 거부되면 마커가 지워진다""" + marker = self.git_dir_file(".gitformat-verified") + marker.write_text("0 0\n", encoding="utf-8") + self.assertRejected(self.commit("형식 없는 제목")) + self.assertFalse(marker.exists(), "거부된 커밋 뒤에 마커가 남았다") + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_non_utf8_locale.py b/tests/test_non_utf8_locale.py index 54e9d32..89e1ccf 100644 --- a/tests/test_non_utf8_locale.py +++ b/tests/test_non_utf8_locale.py @@ -54,7 +54,7 @@ def test_정상_커밋이_로케일_때문에_막히지_않는다(self): # ── stderr 경로: 거부 메시지가 읽을 수 있는 한국어여야 한다 ───── def test_거부_메시지가_깨지지_않는다(self): - """[GF-116] 로케일이 UTF-8이 아니어도 commit-msg 거부 메시지가 깨지지 않는다""" + """[GF-116] 로케일이 UTF-8이 아니어도 prepare-commit-msg 거부 메시지가 깨지지 않는다""" self.write("a.txt", "hi\n") self.git_ok("add", "a.txt") diff --git a/tests/test_python3_missing.py b/tests/test_python3_missing.py index 947f025..9213f99 100644 --- a/tests/test_python3_missing.py +++ b/tests/test_python3_missing.py @@ -27,8 +27,8 @@ def use_hooks_without(self, *hooks): git은 앞선 훅이 실패하면 뒤의 훅을 아예 실행하지 않으므로, 검증하려는 훅보다 앞서 도는 훅을 전부 지워야 그 훅이 실제 검증 대상이 된다. 실행 순서는 - prepare-commit-msg → commit-msg → post-commit이다 — GF-126에서 pre-commit이 - 삭제돼 맨 앞이 prepare-commit-msg가 됐다. + prepare-commit-msg → post-commit이다 — GF-126에서 pre-commit이 삭제돼 맨 앞이 + prepare-commit-msg가 됐고, GF-127에서 commit-msg가 삭제됐다. """ trimmed = self.copy_hooks(*hooks) self.git_ok("config", "core.hooksPath", trimmed) @@ -40,7 +40,7 @@ def baseline_commit(self): self.commit_ok("[feat] baseline commit") return self.head_hash() - # ── prepare-commit-msg/commit-msg: 커밋이 실제로 막힌다 ───────── + # ── prepare-commit-msg: 커밋이 실제로 막힌다 ────────────────────── def test_prepare_commit_msg가_실패해_커밋_객체가_안_만들어진다(self): """[GF-113] python3이 없으면 prepare-commit-msg가 실패해 커밋 객체가 만들어지지 않는다 @@ -61,24 +61,11 @@ def test_prepare_commit_msg가_실패해_커밋_객체가_안_만들어진다(se self.assertEqual(before, self.head_hash()) self.assertEqual(1, self.commit_count()) - def test_commit_msg가_실패해_커밋_객체가_안_만들어진다(self): - """[GF-113] python3이 없으면 commit-msg가 실패해 커밋 객체가 만들어지지 않는다""" - self.use_hooks_without("prepare-commit-msg") - before = self.baseline_commit() - - self.write("a.txt", "hi\n") - self.git_ok("add", "a.txt") - result = self.commit("[feat] blocked by missing python3", env=self.no_python) - self.assertRejected(result) - self.assertIn("python3", result.output) - self.assertEqual(before, self.head_hash()) - self.assertEqual(1, self.commit_count()) - # ── post-commit: 커밋은 남고 트레일러만 조용히 빠진다 ──────────── def test_post_commit만_실패해_트레일러가_누락된다(self): """[GF-113] python3이 없으면 post-commit만 실패해 커밋은 남고 트레일러가 누락된다""" - self.use_hooks_without("prepare-commit-msg", "commit-msg") + self.use_hooks_without("prepare-commit-msg") # 같은 설정에서 python3이 보이면 트레일러가 붙는다는 것부터 확인한다 — 이게 # 없으면 아래 누락이 python3 부재 때문인지 훅 연결이 애초에 안 된 탓인지 diff --git a/tests/test_replay_commits_untouched.py b/tests/test_replay_commits_untouched.py index f9451e4..1ef9a0a 100644 --- a/tests/test_replay_commits_untouched.py +++ b/tests/test_replay_commits_untouched.py @@ -7,9 +7,9 @@ **남은 구 훅은 사본에서 지운 뒤 검증한다.** 구 post-commit이 cherry-pick 중에 내는 DRAFT-18 트레이스백이 아직 그대로 살아 있어, 그걸 같이 태우면 이 파일이 새 훅을 검증하는 -게 아니라 아직 고치지 않은 구 훅을 검증하게 된다. GF-126에서 pre-commit이 삭제돼 지울 -대상이 2개로 줄었고, commit-msg/post-commit도 삭제되는 GF-127~128 이후에는 이 사본 -구성이 곧 실제 구성이 된다. +게 아니라 아직 고치지 않은 구 훅을 검증하게 된다. GF-126에서 pre-commit이, GF-127에서 +commit-msg가 삭제돼 지울 대상은 post-commit 하나만 남았고, post-commit도 삭제되는 +GF-128 이후에는 이 사본 구성이 곧 실제 구성이 된다. source 값과 진행 상태 파일은 git 2.54.0에서 실측했다(2026-09-26). cherry-pick과 rebase 재생은 `source=message`라 CHERRY_PICK_HEAD 같은 진행 상태 파일로만 잡히고, @@ -26,7 +26,7 @@ class ReplayCommitsUntouchedTest(IsolatedRepoTestCase): def setUp(self): - self.hooks_copy = self.copy_hooks("commit-msg", "post-commit") + self.hooks_copy = self.copy_hooks("post-commit") self.repo = self.make_repo(hooks_path=self.hooks_copy) self.hook = self.hooks_copy / "prepare-commit-msg" self.write("base.txt", "base\n") @@ -66,7 +66,9 @@ def assertHookSilent(self, result): def test_cherry_pick이_거부되지_않는다(self): """[GF-125] cherry-pick 재생 커밋은 훅의 거부나 트레이스백 없이 완료된다""" base_branch = self.current_branch() - self.git_ok("checkout", "-q", "-b", "side") + # GF-127 이후 메시지 검증(브랜치 Task-Id 강제 포함)이 prepare-commit-msg에서 + # 돌므로, 준비 단계의 일반 커밋도 Task-Id가 있는 브랜치에서 만들어야 한다. + self.git_ok("checkout", "-q", "-b", "GF-1-side") self.write("a.txt", "hi\n") self.git_ok("add", "a.txt") self.commit_ok("[feat] 재생할 커밋") @@ -83,8 +85,9 @@ def test_revert가_거부되지_않는다(self): self.assertHookSilent(self.git("revert", "--no-edit", "HEAD")) self.assertEqual(2, self.commit_count()) - # git이 만드는 기본 메시지는 [type][subsystem] 형식이 아니다 — 재생·병합 - # 커밋을 면제하는 이유 자체가 이것이다. + # 충돌 없는 revert는 REVERT_HEAD가 없어 면제에 걸리지 않는다(source=message). + # git이 만드는 이 제목은 [type][subsystem] 형식이 아니지만 GF-127에서 형식 + # 규칙의 예외로 인정했으므로 검증을 거치고도 조용히 통과한다. self.assertEqual('Revert "[feat] 기준 커밋"', self.head_subject()) def test_에디터로_여는_revert도_거부되지_않는다(self): @@ -102,7 +105,7 @@ def test_에디터로_여는_revert도_거부되지_않는다(self): def test_병합_커밋이_거부되지_않는다(self): """[GF-125] 병합 커밋(source=merge, MERGE_HEAD 존재)은 거부되지 않는다""" base_branch = self.current_branch() - self.git_ok("checkout", "-q", "-b", "feature") + self.git_ok("checkout", "-q", "-b", "GF-2-feature") self.write("feature.txt", "feature\n") self.git_ok("add", "feature.txt") self.commit_ok("[feat] 병합될 커밋") @@ -112,7 +115,7 @@ def test_병합_커밋이_거부되지_않는다(self): self.git_ok("add", "main.txt") self.commit_ok("[feat] 병합하는 쪽 커밋") - self.assertHookSilent(self.git("merge", "--no-ff", "--no-edit", "feature")) + self.assertHookSilent(self.git("merge", "--no-ff", "--no-edit", "GF-2-feature")) # 부모가 2개인지까지 봐야 진짜 병합 커밋이 만들어진 것이 증명된다. parents = self.git_ok("log", "-1", "--format=%p").stdout.split() diff --git a/tests/test_shell_metacharacters_safe.py b/tests/test_shell_metacharacters_safe.py index 28832f1..eaca2e3 100644 --- a/tests/test_shell_metacharacters_safe.py +++ b/tests/test_shell_metacharacters_safe.py @@ -90,16 +90,14 @@ def test_매우_긴_라인이_섞여도_트레일러_삽입이_깨지지_않는 """[커밋메시지] 매우 긴 라인이 섞여도 트레일러 삽입이 깨지지 않는다""" # 이 테스트의 목적은 post-commit의 interpret-trailers 삽입이 매우 긴 라인 # 앞에서 깨지지 않는지 확인하는 것이지, GF-83의 본문 줄 길이(72자) 검증 - # 자체를 테스트하는 게 아니다 — 20000자 라인은 그 검증에 걸리므로 - # --no-verify로 commit-msg를 건너뛰고 post-commit(항상 실행됨) 경로만 - # 검증한다. --no-verify가 건너뛰지 못하는 prepare-commit-msg는 본문 길이를 - # 보지 않으므로 이 메시지를 막지 않는다. + # 자체를 테스트하는 게 아니다. 예전에는 20000자 본문 줄을 --no-verify로 + # commit-msg를 건너뛰어 통과시켰지만, GF-127에서 검증이 --no-verify로도 + # 건너뛸 수 없는 prepare-commit-msg로 옮겨와 그 우회가 닫혔다. 그래서 72자 + # 예외를 받는 등록된 트레일러 토큰 줄(BREAKING CHANGE)로 긴 라인을 넣는다. self.git_ok("checkout", "-q", "-b", "GF-4-longline") self.stage_one_file() - long_body = "y" * 20000 - self.assertAccepted( - self.commit(f"[feat] 긴 본문\n\n{long_body}", "--no-verify") - ) + long_body = "BREAKING CHANGE: " + "y" * 20000 + self.assertAccepted(self.commit(f"[feat] 긴 본문\n\n{long_body}")) message = self.head_message() self.assertTrailerCount(message, "Task-Id: GF-4", 1) self.assertIn(long_body, message) diff --git a/tests/test_token_usage_measurement.py b/tests/test_token_usage_measurement.py index 3ae1f8f..e417d25 100644 --- a/tests/test_token_usage_measurement.py +++ b/tests/test_token_usage_measurement.py @@ -422,7 +422,7 @@ def test_트랜스크립트_파일이_없으면_사유가_명시된다(self): def test_claude_code_외_도구는_no_usage_channel로_명시된다(self): """[GF-97] claude-code 외 AI 도구가 감지되면 unavailable (no-usage-channel)로 명시된다""" self.stage("a.txt") - # commit-msg 게이트(decision-5)는 claude-code 외 AI 도구가 감지되면 + # prepare-commit-msg의 AI-Model 게이트(decision-5)는 claude-code 외 AI 도구가 감지되면 # gitformat.aiModel이 설정돼 있을 것을 요구한다 — 이 테스트의 관심사는 그 # 게이트 통과 이후 post-commit의 분기이므로 먼저 채워둔다. self.git_ok("config", "gitformat.aiModel", "gpt-5") diff --git a/tests/test_trailer_hooks_commit.py b/tests/test_trailer_hooks_commit.py index 44f9248..bd53078 100644 --- a/tests/test_trailer_hooks_commit.py +++ b/tests/test_trailer_hooks_commit.py @@ -41,6 +41,10 @@ def test_재귀_가드가_유한_시간_안에_끝낸다(self): # GF-97 이후 이 커밋에는 AI-Tool: other-tool과 함께 Tokens-Used/Tool-Calls: # unavailable (no-usage-channel)이 붙지만, 이 테스트의 관심사는 재귀 가드가 # 유한 시간 안에 끝나는지이지 트레일러 값 자체가 아니다. + # GF-127 전에는 --no-verify가 commit-msg의 AI-Model 게이트까지 건너뛰었지만, + # 그 게이트가 --no-verify로 건너뛸 수 없는 prepare-commit-msg로 옮겨왔으므로 + # 게이트를 통과할 모델을 먼저 설정한다. + self.git_ok("config", "gitformat.aiModel", "gpt-5") try: result = self.commit( "[feat] recursion guard check", diff --git a/tests/test_trailer_task_id.py b/tests/test_trailer_task_id.py index 31082e9..09606c7 100644 --- a/tests/test_trailer_task_id.py +++ b/tests/test_trailer_task_id.py @@ -1,6 +1,6 @@ """post-commit이 브랜치명에서 Task-Id 트레일러를 만들어 붙이는지 본다(decision-4). -commit-msg가 이미 브랜치명의 -<번호> 패턴을 강제했으므로(없으면 예외 +prepare-commit-msg가 이미 브랜치명의 -<번호> 패턴을 강제했으므로(없으면 예외 브랜치가 아닌 한 커밋 자체가 거부된다 - test_branch_task_id_required.py), 여기서는 같은 패턴이 트레일러로 정확히 한 번 남는지, 예외 브랜치에서는 아예 붙지 않는지를 본다. bats 판은 부분 문자열 존재만 봤고 개수를 세지 않았다. diff --git a/tests/test_verify_bypass_detection.py b/tests/test_verify_bypass_detection.py index 54681da..2310a64 100644 --- a/tests/test_verify_bypass_detection.py +++ b/tests/test_verify_bypass_detection.py @@ -20,9 +20,10 @@ 실패해(archive DRAFT-18) 트레일러가 커밋에 남지 않는다 — 그 경로를 단언으로 고정하지 않는 것은 지금 동작이 의도된 설계가 아니라 구 post-commit의 미해결 결함이기 때문이다. -이 시점의 한계도 기록해 둔다: --no-verify는 아직 commit-msg를 건너뛰므로 '메시지 검증 -우회'는 여전히 가능하고, 그것을 기록하는 신호는 없다. 검증을 prepare-commit-msg로 옮기는 -GF-127이 그 한 태스크짜리 과도기를 닫는다. +GF-126 시점에는 --no-verify가 아직 commit-msg를 건너뛰어 '메시지 검증 우회'가 기록 없이 +가능했다. GF-127이 검증을 prepare-commit-msg로 옮기고 commit-msg를 삭제해 그 과도기를 +닫았다 — --no-verify로 형식이 틀린 메시지를 넣을 수 없다는 사실은 +test_message_format_enforced.py가 고정한다. """ import unittest From a57d038ae2e6dcd140c849e938ee9988ca19b8f0 Mon Sep 17 00:00:00 2001 From: coralstay <231006716+coralstay@users.noreply.github.com> Date: Sun, 4 Oct 2026 10:48:52 +0900 Subject: [PATCH 5/8] =?UTF-8?q?[docs][hooks]=20=EC=82=AD=EC=A0=9C=EB=90=9C?= =?UTF-8?q?=20commit-msg=EB=A5=BC=20=EA=B0=80=EB=A6=AC=ED=82=A4=EB=8A=94?= =?UTF-8?q?=20=EC=A3=BC=EC=84=9D=20=EC=A0=95=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit gitformat.conf, post-commit, CI 워크플로 주석이 commit-msg를 검증 주체로 적고 있어 prepare-commit-msg로 바로잡는다. Co-Authored-By: Claude Opus 5.5 Task-Id: GF-127 AI-Tool: claude-code AI-Tool-Version: 2.1.285 Co-Authored-By: Claude AI-Model: claude-opus-5-5 Tokens-Used: in=398817 out=59 Tool-Calls: 3 Hooks-Commit: a55bda8 Signed-off-by: coralstay <231006716+coralstay@users.noreply.github.com> --- .github/workflows/test.yml | 2 +- hooks/gitformat.conf | 4 ++-- hooks/post-commit | 19 ++++++++++--------- 3 files changed, 13 insertions(+), 12 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index df77066..8948f5e 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -33,7 +33,7 @@ jobs: - name: Install ruff run: pip install --quiet ruff # ruff에 디렉터리를 넘기면 확장자가 .py인 파일만 훑는다(실측 확인). git이 - # 이름을 고정하는 훅 3개(prepare-commit-msg/commit-msg/post-commit)는 확장자가 + # 이름을 고정하는 훅 2개(prepare-commit-msg/post-commit)는 확장자가 # 없어, Python으로 포팅돼도 hooks/ 하나만 지정하면 조용히 검사에서 빠진다 - # 검사가 아무것도 안 하고 통과하는 GF-32와 같은 유형이다. 그래서 셔뱅으로 # Python 파일을 찾아 함께 넘긴다. 이 근거는 GF-135로 hooks/checks/*.py가 diff --git a/hooks/gitformat.conf b/hooks/gitformat.conf index e57bb88..e8e0b16 100644 --- a/hooks/gitformat.conf +++ b/hooks/gitformat.conf @@ -1,5 +1,5 @@ -# coding-agent-git-commit-tool 내부 기본값 상수 파일(git config 포맷). 훅 3개(prepare-commit-msg, -# commit-msg, post-commit)가 이 파일을 `git config --file`로 읽어 값을 가져온다 — +# coding-agent-git-commit-tool 내부 기본값 상수 파일(git config 포맷). 훅 2개(prepare-commit-msg, +# post-commit)가 이 파일을 `git config --file`로 읽어 값을 가져온다 — # 각 훅의 실행 로직(함수/제어흐름)은 이 파일과 무관하게 파일마다 독립적으로 # 유지된다(공유하는 건 값뿐). # diff --git a/hooks/post-commit b/hooks/post-commit index 09b25c2..e27adf2 100755 --- a/hooks/post-commit +++ b/hooks/post-commit @@ -3,8 +3,9 @@ # 삽입한다(decision-3). post-commit은 --no-verify를 쓰든 --amend를 하든 git이 항상 # 실행을 보장하는 훅이라는 점을 이용한다. # -# 판단 근거: prepare-commit-msg는 재생 커밋 면제나 에디터 경로 거부로 빠져나가지 -# 않으면 직전 마커를 지우고 조건 없이 새로 쓴다. 이 훅은 그 마커의 존재 여부만 보고, +# 판단 근거: prepare-commit-msg는 재생 커밋 면제로 빠져나가지 않으면 직전 마커를 +# 지우고, 에디터 경로 거부와 메시지 검증을 통과하면 새로 쓴다(거부하면 커밋 자체가 +# 막혀 이 훅이 돌지 않는다). 이 훅은 그 마커의 존재 여부만 보고, # 확인 후에는 항상(존재하든 안 하든) 지운다 — 그래야 다음 커밋으로 스테일 마커가 # 새지 않는다. # @@ -119,7 +120,7 @@ except OSError: pass # 커밋 메시지 원문은 유효하지 않은 UTF-8 바이트를 포함할 수 있다(GF-80, 실제 CI에서 -# 재현). commit-msg는 메시지를 읽기만 하므로 errors="replace"로 충분하지만, 이 훅은 +# 재현). prepare-commit-msg는 메시지를 읽기만 하므로 errors="replace"로 충분하지만, 이 훅은 # 읽은 메시지를 amend로 다시 써넣기 때문에 replace를 쓰면 깨진 바이트가 U+FFFD로 # 치환돼 커밋 내용이 조용히 손상된다. surrogateescape는 디코드 불가 바이트를 대리 # 문자로 보존했다가 인코드 시 원래 바이트로 정확히 되돌리므로, 예외 없이 바이트 @@ -207,10 +208,10 @@ def detect_verify_bypass(): queue_trailer(TRAILER_VERIFY_BYPASSED, "true") -# Task-Id(decision-4): commit-msg가 이미 브랜치명에 -<번호> 패턴이 있는지 -# 검증했으므로(없으면 예외 브랜치가 아닌 한 커밋 자체가 거부됨), 여기서는 같은 패턴을 -# 다시 찾아 Task-Id 트레일러로 남기기만 한다. 여기서 계산하는 TASK_PREFIX/BRANCH -# 블록은 commit-msg의 enforce_task_id_branch가 검증할 때 쓰는 블록과 동일하게 +# Task-Id(decision-4): prepare-commit-msg가 이미 브랜치명에 -<번호> 패턴이 +# 있는지 검증했으므로(없으면 예외 브랜치가 아닌 한 커밋 자체가 거부됨), 여기서는 같은 +# 패턴을 다시 찾아 Task-Id 트레일러로 남기기만 한다. 여기서 계산하는 TASK_PREFIX/BRANCH +# 블록은 prepare-commit-msg의 enforce_task_id_branch가 검증할 때 쓰는 블록과 동일하게 # 유지한다(파일별 독립 중복). def trailer_task_id(): task_prefix_default = conf_get("gitformat.taskPrefixDefault") @@ -228,7 +229,7 @@ def trailer_task_id(): ) branch = branch_result.stdout.rstrip("\n") if branch_result.returncode == 0 else "HEAD" - # commit-msg와 동일하게 앵커링해서 부분 문자열 오매치를 피한다(GF-34). + # prepare-commit-msg와 동일하게 앵커링해서 부분 문자열 오매치를 피한다(GF-34). match = re.search( rf"(^|[^a-zA-Z0-9]){re.escape(task_prefix)}-([0-9]+)", branch, re.IGNORECASE ) @@ -301,7 +302,7 @@ def read_transcript_model(path): # AI-Model(decision-5): Claude Code는 세션 트랜스크립트 # (~/.claude/projects//.jsonl)의 message.model 필드를 읽는다 — # 이는 Anthropic API 응답을 애플리케이션이 그대로 기록한 값이라 자가신고가 -# 아니다. 그 외 도구는 commit-msg 게이트가 이미 검증한 gitformat.aiModel을 +# 아니다. 그 외 도구는 prepare-commit-msg 게이트가 이미 검증한 gitformat.aiModel을 # 쓴다. CLAUDE_CODE_SESSION_ID는 트랜스크립트 파일 경로를 찾는 데만 쓰고, 값 # 자체를 커밋 footer에 남기지는 않는다(세션 식별자가 커밋 이력에 영구히 남는 걸 # 피하기 위함, decision-5 amendment). From 03082e4f39480a2efb739b15c1aa56ce5de9f77d Mon Sep 17 00:00:00 2001 From: coralstay <231006716+coralstay@users.noreply.github.com> Date: Sun, 4 Oct 2026 10:49:10 +0900 Subject: [PATCH 6/8] =?UTF-8?q?[docs][backlog]=20GF-127=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=20=EB=85=B8=ED=8A=B8=20=EA=B8=B0=EB=A1=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Task-Id: GF-127 AI-Tool: claude-code AI-Tool-Version: 2.1.285 Co-Authored-By: Claude AI-Model: claude-opus-5-5 Tokens-Used: in=0 out=0 (no-attributed-turn) Tool-Calls: 0 (no-attributed-turn) Hooks-Commit: e70c3aa Signed-off-by: coralstay <231006716+coralstay@users.noreply.github.com> --- ...-msg\353\241\234-\354\235\264\354\240\204.md" | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git "a/backlog/tasks/gf-127 - \354\273\244\353\260\213-\353\251\224\354\213\234\354\247\200-\352\262\200\354\246\235\354\235\204-prepare-commit-msg\353\241\234-\354\235\264\354\240\204.md" "b/backlog/tasks/gf-127 - \354\273\244\353\260\213-\353\251\224\354\213\234\354\247\200-\352\262\200\354\246\235\354\235\204-prepare-commit-msg\353\241\234-\354\235\264\354\240\204.md" index 6a5bd7c..aa3ebb5 100644 --- "a/backlog/tasks/gf-127 - \354\273\244\353\260\213-\353\251\224\354\213\234\354\247\200-\352\262\200\354\246\235\354\235\204-prepare-commit-msg\353\241\234-\354\235\264\354\240\204.md" +++ "b/backlog/tasks/gf-127 - \354\273\244\353\260\213-\353\251\224\354\213\234\354\247\200-\352\262\200\354\246\235\354\235\204-prepare-commit-msg\353\241\234-\354\235\264\354\240\204.md" @@ -5,7 +5,7 @@ status: In Progress assignee: - '@claude' created_date: '2026-09-25 19:33' -updated_date: '2026-10-04 01:39' +updated_date: '2026-10-04 01:49' labels: - hooks - validation @@ -52,6 +52,20 @@ type: feature - [ ] #3 이 저장소 자신의 커밋이 새 훅으로 정상 생성되는지 확인 +## Implementation Notes + + +구현(0d9df83, a57d038): +- commit-msg의 검증 함수(형식·길이·빈 줄·Fixes·브랜치 Task-Id·AI-Model 게이트, type 목록 비면 중단)를 동작 그대로 prepare-commit-msg로 옮기고 같은 커밋에서 hooks/commit-msg를 삭제했다(AC #9). 오류 메시지 접두어만 prepare-commit-msg:로 바뀌었다. +- 실행 순서: 재생·병합 면제 → 스테일 마커 무효화 → 에디터 경로 거부 → validate_message() → 마커 기록. 무효화를 거부 경로보다 앞에 두어 거부·예외로 끝나면 마커가 남지 않는다(GF-31의 commit-msg finally 정리를 대체). +- 메시지 파일은 주석 제거 전 원문이다. commit-msg와 같게 열 0의 '#' 줄만 빼고 commit.cleanup/core.commentChar는 보지 않는다(-m/-F 경로에서 이 훅과 구 commit-msg가 보는 내용은 같다). +- revert 제목 예외(유저 결정 b): 'Revert "..."'와 'Reapply "..."'를 fullmatch로 인정. git 2.54.0 실측으로 revert의 revert는 'Reapply "<제목>"'이 되므로 함께 넣었다. 중첩은 바깥 따옴표만 보므로 통과한다. 이 제목은 50자 제한에서도 뺀다(git이 원래 제목에 접두어를 붙여 50자 제목의 revert는 60자). 본문 72자·빈 줄 규칙은 그대로 적용. +- --amend 모호성(source=commit)은 알려진 한계로 validate_message() 주석에 남겼다. +- AC #8: 트레일러는 아직 post-commit이 커밋 뒤에 붙이므로 검증이 먼저 돈다. GF-128에서 삽입을 validate_message() 뒤에 둬야 한다고 주석에 적었다. +- 테스트: --no-verify 우회 불가(형식·길이·브랜치), clean revert --no-edit 통과, 60자 revert 제목 통과, Reapply·그 revert 통과, revert 비슷한 손글씨 제목 거부, 거부 시 스테일 마커 제거를 추가. commit-msg 전용 케이스(conf 가드, python3 부재)는 삭제하고, --no-verify로 검증을 피하던 테스트 2개와 Task-Id 없는 브랜치에서 준비 커밋을 만들던 replay 테스트를 고쳤다. install 테스트는 commit-msg 링크가 정리되는지 확인하도록 바꿨다(install.sh 변경 없음). +- 결과: python3 -m unittest discover -s tests → Ran 113 tests OK, ruff check . 통과. 이 저장소의 커밋 0d9df83이 새 훅으로 생성됐고, --no-verify로 형식 틀린 커밋은 거부됐다. + + ## Comments From f5cb49694745a93f13f4d2a6522268d7ab37dc32 Mon Sep 17 00:00:00 2001 From: coralstay <231006716+coralstay@users.noreply.github.com> Date: Sun, 4 Oct 2026 10:51:35 +0900 Subject: [PATCH 7/8] =?UTF-8?q?[test][hooks]=20type=20=EB=AA=A9=EB=A1=9D?= =?UTF-8?q?=EC=9D=B4=20=EB=B9=84=EC=97=88=EC=9D=84=20=EB=95=8C=20=EA=B1=B0?= =?UTF-8?q?=EB=B6=80=EB=A5=BC=20=EA=B2=80=EC=A6=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AC #7의 증거가 될 테스트가 없었다. revert 제목 예외가 깨진 설정을 가리지 않는지도 함께 본다. Co-Authored-By: Claude Opus 5.5 Task-Id: GF-127 AI-Tool: claude-code AI-Tool-Version: 2.1.285 Co-Authored-By: Claude AI-Model: claude-opus-5-5 Tokens-Used: in=668636 out=1978 Tool-Calls: 5 Hooks-Commit: 149cc6b Signed-off-by: coralstay <231006716+coralstay@users.noreply.github.com> --- tests/test_message_format_enforced.py | 58 +++++++++++++++++++++++---- 1 file changed, 51 insertions(+), 7 deletions(-) diff --git a/tests/test_message_format_enforced.py b/tests/test_message_format_enforced.py index 0eef2e1..20aa0b9 100644 --- a/tests/test_message_format_enforced.py +++ b/tests/test_message_format_enforced.py @@ -65,13 +65,17 @@ def test_AI_AGENT_미설정이면_게이트를_건너뛴다(self): def test_claude_code면_aiModel_없이도_통과한다(self): """[결정테이블] AI_AGENT=claude-code면 aiModel 미설정이어도 통과한다""" self.assertAccepted( - self.commit("[feat] claude code agent", env={"AI_AGENT": "claude-code_2-1-0"}) + self.commit( + "[feat] claude code agent", env={"AI_AGENT": "claude-code_2-1-0"} + ) ) def test_비_claude_도구에_aiModel_미설정이면_거부된다(self): """[결정테이블] 비-claude-code 도구 + aiModel 미설정이면 거부된다""" self.assertRejected( - self.commit("[feat] other tool no model", env={"AI_AGENT": "other-tool_1-0"}) + self.commit( + "[feat] other tool no model", env={"AI_AGENT": "other-tool_1-0"} + ) ) def test_화이트리스트에_없는_aiModel은_거부된다(self): @@ -171,10 +175,7 @@ def test_본문_줄이_73자면_거부된다(self): def test_등록된_트레일러_토큰_줄은_72자를_넘어도_통과한다(self): """[GF-83] 등록된 트레일러 토큰으로 시작하는 줄은 72자를 넘어도 통과한다""" - self.assertAccepted( - self.commit("[feat] 제목\n\nBREAKING CHANGE: " + "y" * 70) - ) - + self.assertAccepted(self.commit("[feat] 제목\n\nBREAKING CHANGE: " + "y" * 70)) # ── --no-verify로 우회할 수 없다 (GF-127, decision-18) ─────────── @@ -229,7 +230,12 @@ def test_revert를_revert한_Reapply도_통과한다(self): def test_revert_비슷한_손글씨_제목은_거부된다(self): """[GF-127] 따옴표 없는 Revert 제목이나 다른 git 자동 제목은 예외가 아니다""" - for subject in ("Revert 되돌림", 'revert "소문자"', 'Revert ""', "fixup! [feat] x"): + for subject in ( + "Revert 되돌림", + 'revert "소문자"', + 'Revert ""', + "fixup! [feat] x", + ): with self.subTest(subject=subject): self.assertRejected(self.commit(subject)) @@ -246,6 +252,44 @@ def test_거부되면_스테일_마커도_남지_않는다(self): self.assertRejected(self.commit("형식 없는 제목")) self.assertFalse(marker.exists(), "거부된 커밋 뒤에 마커가 남았다") + # ── type 목록이 비면 원인을 밝히며 멈춘다 (GF-76 → GF-127) ───────── + + def test_type_목록이_비면_원인을_밝히며_거부된다(self): + """[GF-76] conf에 gitformat.type이 하나도 없으면 조용히 통과하지 않고 원인을 밝힌다""" + hooks_copy = self.copy_hooks() + self.run_cmd_ok( + [ + "git", + "config", + "--file", + hooks_copy / "gitformat.conf", + "--unset-all", + "gitformat.type", + ] + ) + self.git_ok("config", "core.hooksPath", str(hooks_copy)) + result = self.commit("[feat] 정상 제목") + self.assertRejected(result) + self.assertIn("type 목록을 읽을 수 없습니다", result.output) + + def test_type_목록이_비면_revert_제목도_거부된다(self): + """[GF-127] revert 제목 예외가 깨진 설정을 가리지 않는다""" + hooks_copy = self.copy_hooks() + self.run_cmd_ok( + [ + "git", + "config", + "--file", + hooks_copy / "gitformat.conf", + "--unset-all", + "gitformat.type", + ] + ) + self.git_ok("config", "core.hooksPath", str(hooks_copy)) + result = self.commit('Revert "[feat] x"') + self.assertRejected(result) + self.assertIn("type 목록을 읽을 수 없습니다", result.output) + if __name__ == "__main__": unittest.main() From a2080c002a798f5cea6ad95a05a3c631c3ab4c03 Mon Sep 17 00:00:00 2001 From: coralstay <231006716+coralstay@users.noreply.github.com> Date: Sun, 4 Oct 2026 10:52:44 +0900 Subject: [PATCH 8/8] =?UTF-8?q?[docs][backlog]=20GF-127=20=EC=99=84?= =?UTF-8?q?=EB=A3=8C=20=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Task-Id: GF-127 AI-Tool: claude-code AI-Tool-Version: 2.1.285 Co-Authored-By: Claude AI-Model: claude-opus-5-5 Tokens-Used: in=0 out=0 (no-attributed-turn) Tool-Calls: 0 (no-attributed-turn) Hooks-Commit: c74ba4e Signed-off-by: coralstay <231006716+coralstay@users.noreply.github.com> --- ...g\353\241\234-\354\235\264\354\240\204.md" | 36 +++++++++++-------- 1 file changed, 22 insertions(+), 14 deletions(-) diff --git "a/backlog/tasks/gf-127 - \354\273\244\353\260\213-\353\251\224\354\213\234\354\247\200-\352\262\200\354\246\235\354\235\204-prepare-commit-msg\353\241\234-\354\235\264\354\240\204.md" "b/backlog/tasks/gf-127 - \354\273\244\353\260\213-\353\251\224\354\213\234\354\247\200-\352\262\200\354\246\235\354\235\204-prepare-commit-msg\353\241\234-\354\235\264\354\240\204.md" index aa3ebb5..e720e60 100644 --- "a/backlog/tasks/gf-127 - \354\273\244\353\260\213-\353\251\224\354\213\234\354\247\200-\352\262\200\354\246\235\354\235\204-prepare-commit-msg\353\241\234-\354\235\264\354\240\204.md" +++ "b/backlog/tasks/gf-127 - \354\273\244\353\260\213-\353\251\224\354\213\234\354\247\200-\352\262\200\354\246\235\354\235\204-prepare-commit-msg\353\241\234-\354\235\264\354\240\204.md" @@ -1,11 +1,11 @@ --- id: GF-127 title: 커밋 메시지 검증을 prepare-commit-msg로 이전 -status: In Progress +status: Done assignee: - '@claude' created_date: '2026-09-25 19:33' -updated_date: '2026-10-04 01:49' +updated_date: '2026-10-04 01:52' labels: - hooks - validation @@ -34,22 +34,22 @@ type: feature ## Acceptance Criteria -- [ ] #1 제목이 [type][subsystem] <설명> 형식인지 검증한다 (subsystem은 생략 가능) -- [ ] #2 제목이 50자를 넘으면 거부한다 (바이트가 아니라 유니코드 코드포인트 기준) -- [ ] #3 본문 줄이 72자를 넘으면 거부하되, 등록된 트레일러 토큰으로 시작하는 줄은 예외로 둔다 -- [ ] #4 본문이나 트레일러가 있으면 제목과의 사이에 빈 줄을 요구한다 -- [ ] #5 Fixes 트레일러가 있으면 참조 해시가 저장소에 실재하는 커밋인지 검증한다 -- [ ] #6 브랜치명에 -<번호> 패턴이 없으면 거부하되 예외 브랜치와 detached HEAD는 면제한다 -- [ ] #7 커밋 타입 목록을 설정 파일에서 읽으며, 목록이 비면 조용히 통과하지 않고 원인을 밝히며 중단한다 -- [ ] #8 검증이 트레일러 삽입보다 먼저 실행된다 -- [ ] #9 같은 커밋에서 hooks/commit-msg를 삭제한다 — 기능을 옮기고 구 훅을 남기면 검증이 두 번 실행된다 +- [x] #1 제목이 [type][subsystem] <설명> 형식인지 검증한다 (subsystem은 생략 가능) +- [x] #2 제목이 50자를 넘으면 거부한다 (바이트가 아니라 유니코드 코드포인트 기준) +- [x] #3 본문 줄이 72자를 넘으면 거부하되, 등록된 트레일러 토큰으로 시작하는 줄은 예외로 둔다 +- [x] #4 본문이나 트레일러가 있으면 제목과의 사이에 빈 줄을 요구한다 +- [x] #5 Fixes 트레일러가 있으면 참조 해시가 저장소에 실재하는 커밋인지 검증한다 +- [x] #6 브랜치명에 -<번호> 패턴이 없으면 거부하되 예외 브랜치와 detached HEAD는 면제한다 +- [x] #7 커밋 타입 목록을 설정 파일에서 읽으며, 목록이 비면 조용히 통과하지 않고 원인을 밝히며 중단한다 +- [x] #8 검증이 트레일러 삽입보다 먼저 실행된다 +- [x] #9 같은 커밋에서 hooks/commit-msg를 삭제한다 — 기능을 옮기고 구 훅을 남기면 검증이 두 번 실행된다 ## Definition of Done -- [ ] #1 python3 -m unittest 스위트 전체 통과 (이관 전이면 bats tests/ 통과) -- [ ] #2 ruff check 통과 -- [ ] #3 이 저장소 자신의 커밋이 새 훅으로 정상 생성되는지 확인 +- [x] #1 python3 -m unittest 스위트 전체 통과 (이관 전이면 bats tests/ 통과) +- [x] #2 ruff check 통과 +- [x] #3 이 저장소 자신의 커밋이 새 훅으로 정상 생성되는지 확인 ## Implementation Notes @@ -64,6 +64,8 @@ type: feature - AC #8: 트레일러는 아직 post-commit이 커밋 뒤에 붙이므로 검증이 먼저 돈다. GF-128에서 삽입을 validate_message() 뒤에 둬야 한다고 주석에 적었다. - 테스트: --no-verify 우회 불가(형식·길이·브랜치), clean revert --no-edit 통과, 60자 revert 제목 통과, Reapply·그 revert 통과, revert 비슷한 손글씨 제목 거부, 거부 시 스테일 마커 제거를 추가. commit-msg 전용 케이스(conf 가드, python3 부재)는 삭제하고, --no-verify로 검증을 피하던 테스트 2개와 Task-Id 없는 브랜치에서 준비 커밋을 만들던 replay 테스트를 고쳤다. install 테스트는 commit-msg 링크가 정리되는지 확인하도록 바꿨다(install.sh 변경 없음). - 결과: python3 -m unittest discover -s tests → Ran 113 tests OK, ruff check . 통과. 이 저장소의 커밋 0d9df83이 새 훅으로 생성됐고, --no-verify로 형식 틀린 커밋은 거부됐다. + +검증(2026-10-04): python3 -m unittest discover -s tests → 115 tests OK, ruff check → All checks passed. AC#7 증거 테스트가 없어 2개 추가(f5cb496), 가드를 지우면 둘 다 실패하는 것을 확인. 수동 실측: 50자 가까운 제목을 git revert --no-edit → Revert "..." 통과, 다시 revert → Reapply "..." 통과, git commit --no-verify -m 'bad subject' → 거부·마커 없음. AC#8: 검증은 커밋 생성 전 prepare-commit-msg에서, 트레일러는 커밋 생성 후 post-commit에서 붙으므로 순서가 구조적으로 보장된다. 이 저장소의 GF-127 커밋들이 새 훅으로 만들어짐(DoD#3). ## Comments @@ -106,3 +108,9 @@ created: 2026-10-04 01:39 2026-10-04 유저 결정: clean revert 문제는 (b)로 간다 — git이 만드는 'Revert "..."' 제목을 제목 규칙의 예외로 인정한다. 면제(a)는 cherry-pick까지 넓어지고, (c)는 git 기본 동작을 막는다. --amend 모호성(source=commit)은 이 태스크에서 해결하지 않고 알려진 한계로 남긴다. --- + +## Final Summary + + +커밋 메시지 검증(제목 형식·50자·본문 72자·빈 줄·Fixes·브랜치 Task-Id·type 목록 가드·AI-Model 게이트)을 commit-msg에서 prepare-commit-msg로 옮기고 commit-msg를 삭제했다. 이제 --no-verify로 검증을 건너뛸 수 없다. clean git revert가 거부되는 회귀는 유저 결정 (b)대로 git이 만드는 Revert "..."/Reapply "..." 제목을 형식·50자 규칙의 예외로 인정해 막았다. 거부 시 검증 마커가 남지 않도록 무효화를 앞당겼다. 검증: unittest 115개 통과, ruff 통과, revert/reapply/--no-verify 수동 실측. 알려진 한계: --amend 에디터 경로는 이전 메시지를 검증, 손으로 쓴 Revert "..." 제목도 예외를 받음, SHA-256 저장소의 revert 본문 줄은 72자를 넘음. README·hooks/readme.md·.gitmessage의 commit-msg 언급은 GF-132/GF-134에서 정리. +