diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index df77066..8c44ddc 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)는 확장자가 + # 이름을 고정하는 훅(prepare-commit-msg, GF-128부터 하나)은 확장자가 # 없어, Python으로 포팅돼도 hooks/ 하나만 지정하면 조용히 검사에서 빠진다 - # 검사가 아무것도 안 하고 통과하는 GF-32와 같은 유형이다. 그래서 셔뱅으로 # Python 파일을 찾아 함께 넘긴다. 이 근거는 GF-135로 hooks/checks/*.py가 diff --git "a/backlog/archive/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/archive/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/archive/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/decisions/decision-30 - Signed-off-by\353\212\224-\354\202\254\353\236\214\354\235\264-\353\247\214\353\223\240-\354\273\244\353\260\213\354\227\220\353\247\214-\353\266\231\354\235\270\353\213\244-decision-25\302\26728\354\235\230-\354\234\240\354\247\200-\354\241\260\355\225\255-\353\214\200\354\262\264.md" "b/backlog/decisions/decision-30 - Signed-off-by\353\212\224-\354\202\254\353\236\214\354\235\264-\353\247\214\353\223\240-\354\273\244\353\260\213\354\227\220\353\247\214-\353\266\231\354\235\270\353\213\244-decision-25\302\26728\354\235\230-\354\234\240\354\247\200-\354\241\260\355\225\255-\353\214\200\354\262\264.md" new file mode 100644 index 0000000..a302b7b --- /dev/null +++ "b/backlog/decisions/decision-30 - Signed-off-by\353\212\224-\354\202\254\353\236\214\354\235\264-\353\247\214\353\223\240-\354\273\244\353\260\213\354\227\220\353\247\214-\353\266\231\354\235\270\353\213\244-decision-25\302\26728\354\235\230-\354\234\240\354\247\200-\354\241\260\355\225\255-\353\214\200\354\262\264.md" @@ -0,0 +1,40 @@ +--- +id: decision-30 +title: Signed-off-by는 사람이 만든 커밋에만 붙인다 (decision-25·28의 유지 조항 대체) +date: '2026-10-04 01:58' +status: accepted +--- +## Context + +`post-commit`은 모든 커밋에 커미터 정보로 `Signed-off-by`를 붙여 왔다(decision-25, 유저 확정은 +decision-28). 2026-10-04 유사 프로젝트 조사(doc-22)에서 이 트레일러가 업계에서 쓰이는 의미와 +충돌한다는 게 드러났다. + +- 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 커밋에 대한 사람의 책임 + 인정으로 쓴다. + +에이전트 커밋에 훅이 자동으로 붙이면, DCO를 요구하는 저장소에서 사람이 인증하지 않은 커밋이 +인증된 것처럼 보인다(DRAFT-22). + +## Decision + +**`Signed-off-by`는 사람이 만든 커밋에만 붙인다** — 유저 결정(2026-10-04). + +- "사람이 만든 커밋"은 훅이 AI 도구를 감지하지 못한 커밋이다. 판정은 `AI-Agent`를 붙일지 + 정하는 신호와 같은 것을 쓴다(지금은 `AI_AGENT` 환경변수, GF-130에서 재작성 예정). +- 에이전트 커밋에는 붙이지 않는다. 사람이 메시지에 직접 쓴 `Signed-off-by`는 키 단위 중복 + 판정(GF-128)에 따라 그대로 둔다 — 운영자가 스스로 서명하는 것은 막지 않는다. + +decision-25의 `Signed-off-by` 유지 조항과 decision-28을 이 decision이 대체한다. + +## Consequences + +- GF-128 AC #10을 이 decision에 맞게 고친다. +- DRAFT-19가 `Signed-off-by`와 실제 커미터의 불일치를 이력 재작성의 증거로 쓰려던 검사는 + 사람 커밋에만 적용된다. 에이전트 커밋은 다른 증거(예: `Hooks-Commit`, 커밋 객체의 + committer)로 봐야 한다. +- 판정이 에이전트 감지에 기대므로, 감지가 틀리면(에이전트인데 신호가 없으면) 에이전트 + 커밋에 `Signed-off-by`가 붙는다. 감지의 정확도는 GF-130의 몫이다. +- DRAFT-22는 이 decision과 GF-128로 처리되어 보관한다. 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). 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으로 기록한다 + 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..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,10 +1,11 @@ --- id: GF-127 title: 커밋 메시지 검증을 prepare-commit-msg로 이전 -status: To Do -assignee: [] +status: Done +assignee: + - '@claude' created_date: '2026-09-25 19:33' -updated_date: '2026-09-26 02:35' +updated_date: '2026-10-04 01:52' labels: - hooks - validation @@ -33,24 +34,40 @@ 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 + + +구현(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로 형식 틀린 커밋은 거부됐다. + +검증(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 @@ -84,4 +101,16 @@ 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)은 이 태스크에서 해결하지 않고 알려진 한계로 남긴다. +--- + +## 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에서 정리. + diff --git "a/backlog/tasks/gf-128 - \355\212\270\353\240\210\354\235\274\353\237\254-\354\202\275\354\236\205\354\235\204-prepare-commit-msg\353\241\234-\354\235\264\354\240\204\355\225\230\352\263\240-\355\202\244-\353\213\250\354\234\204-\354\244\221\353\263\265\354\235\204-\354\260\250\353\213\250.md" "b/backlog/tasks/gf-128 - \355\212\270\353\240\210\354\235\274\353\237\254-\354\202\275\354\236\205\354\235\204-prepare-commit-msg\353\241\234-\354\235\264\354\240\204\355\225\230\352\263\240-\355\202\244-\353\213\250\354\234\204-\354\244\221\353\263\265\354\235\204-\354\260\250\353\213\250.md" index 47612d5..1152753 100644 --- "a/backlog/tasks/gf-128 - \355\212\270\353\240\210\354\235\274\353\237\254-\354\202\275\354\236\205\354\235\204-prepare-commit-msg\353\241\234-\354\235\264\354\240\204\355\225\230\352\263\240-\355\202\244-\353\213\250\354\234\204-\354\244\221\353\263\265\354\235\204-\354\260\250\353\213\250.md" +++ "b/backlog/tasks/gf-128 - \355\212\270\353\240\210\354\235\274\353\237\254-\354\202\275\354\236\205\354\235\204-prepare-commit-msg\353\241\234-\354\235\264\354\240\204\355\225\230\352\263\240-\355\202\244-\353\213\250\354\234\204-\354\244\221\353\263\265\354\235\204-\354\260\250\353\213\250.md" @@ -1,10 +1,11 @@ --- id: GF-128 title: 트레일러 삽입을 prepare-commit-msg로 이전하고 키 단위 중복을 차단 -status: To Do -assignee: [] +status: Done +assignee: + - '@claude' created_date: '2026-09-25 19:33' -updated_date: '2026-10-03 12:48' +updated_date: '2026-10-04 06:21' labels: - hooks - trailers @@ -15,10 +16,12 @@ references: - decision-18 - decision-25 - decision-28 + - decision-30 documentation: - backlog/docs/doc-13 - git-format-재설계-계획-—-커밋-규칙을-prepare-commit-msg로-통합.md - backlog/docs/doc-14 - 용어-정리-—-턴-트랜스크립트-귀속-마커-구분.md - backlog/docs/doc-18 - 재설계-작업-순서와-의존성.md + - backlog/docs/doc-22 - 유사-프로젝트-조사-—-AI-커밋-출처-기록-도구-비교.md modified_files: - hooks/prepare-commit-msg - hooks/post-commit @@ -39,25 +42,40 @@ type: feature ## Acceptance Criteria -- [ ] #1 트레일러를 git interpret-trailers --in-place로 커밋 메시지 파일에 직접 쓴다 (git commit --amend를 사용하지 않는다) -- [ ] #2 중복 판정은 메시지 원문을 줄 단위로 읽어 해당 키로 시작하는 줄이 있으면 그 키를 건너뛴다 -- [ ] #3 메시지에 빈 줄로 분리된 Task-Id 문단이 먼저 있어도 Task-Id가 한 줄만 남는다 -- [ ] #4 메시지에 다른 값의 Co-Authored-By가 있으면 훅이 추가하지 않고 원래 값이 보존된다 -- [ ] #5 AI-Tool/AI-Tool-Version/AI-Model을 AI-Agent 한 줄(<도구>/<버전> (<모델>))로 합친다 -- [ ] #6 AI-Agent는 구성요소를 못 구해도 줄을 남긴다 (version-unavailable / model-unavailable) -- [ ] #7 재귀 가드가 필요 없어져 제거된다 -- [ ] #8 git rebase로 커밋을 재생해도 트레일러가 추가되지 않고 훅이 실패하지도 않는다 -- [ ] #9 같은 커밋에서 hooks/post-commit을 삭제한다 — 남겨두면 prepare가 넣은 트레일러에 post-commit이 Signed-off-by와 Verify-Bypassed를 또 붙여 이 저장소의 실제 이력에 잘못된 footer가 남는다 -- [ ] #10 Verify-Bypassed 트레일러를 더 이상 삽입하지 않는다. Signed-off-by는 계속 삽입한다(decision-25가 decision-19의 제거 조항을 대체) +- [x] #1 트레일러를 git interpret-trailers --in-place로 커밋 메시지 파일에 직접 쓴다 (git commit --amend를 사용하지 않는다) +- [x] #2 중복 판정은 메시지 원문을 줄 단위로 읽어 해당 키로 시작하는 줄이 있으면 그 키를 건너뛴다 +- [x] #3 메시지에 빈 줄로 분리된 Task-Id 문단이 먼저 있어도 Task-Id가 한 줄만 남는다 +- [x] #4 메시지에 다른 값의 Co-Authored-By가 있으면 훅이 추가하지 않고 원래 값이 보존된다 +- [x] #5 AI-Tool/AI-Tool-Version/AI-Model을 AI-Agent 한 줄(<도구>/<버전> (<모델>))로 합친다 +- [x] #6 AI-Agent는 구성요소를 못 구해도 줄을 남긴다 (version-unavailable / model-unavailable) +- [x] #7 재귀 가드가 필요 없어져 제거된다 +- [x] #8 git rebase로 커밋을 재생해도 트레일러가 추가되지 않고 훅이 실패하지도 않는다 +- [x] #9 같은 커밋에서 hooks/post-commit을 삭제한다 — 남겨두면 prepare가 넣은 트레일러에 post-commit이 Signed-off-by와 Verify-Bypassed를 또 붙여 이 저장소의 실제 이력에 잘못된 footer가 남는다 +- [x] #10 Verify-Bypassed 트레일러를 더 이상 삽입하지 않는다. Signed-off-by는 AI 도구가 감지되지 않은 커밋에만 삽입한다(decision-30이 decision-25·28의 유지 조항을 대체) ## 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 + + +2026-10-04 구현(서브에이전트): +- 트레일러 삽입을 prepare-commit-msg로 옮겼다. validate_message() 뒤 insert_trailers()가 git interpret-trailers --in-place --where end --if-exists add --if-missing add로 메시지 파일에 쓴다. hooks/post-commit은 같은 커밋에서 삭제했다(AC #1, #9). +- 중복 판정: 메시지 원문 모든 줄에서 '<키>:'로 시작하는 줄을 대소문자 무시로 찾고, 있으면 그 키는 값도 계산하지 않는다(AC #2~#4). Tokens-Used와 Tool-Calls가 둘 다 이미 있으면 토큰 측정 자체를 건너뛴다. --amend --no-edit이 응답을 소비만 하고 값을 못 남기는 일을 막는다. +- 트레일러 집합: Task-Id, AI-Agent(<도구>/<버전> (<모델>), 빈 자리는 version-unavailable/model-unavailable), Co-Authored-By(claude-code만), Tokens-Used, Tool-Calls, Hooks-Commit, Signed-off-by(AI 도구 미감지일 때만, git var GIT_COMMITTER_IDENT에서 시각을 뗀 값). 판정 신호는 ai_tool_id() 하나로 모델 게이트와 공유한다. conf: aiAgent 추가, verifyBypassed/aiTool/aiToolVersion/aiModel(trailer)/markerFile 삭제. +- 검증마커와 _GITFORMAT_AMEND_GUARD를 삭제했다(AC #7). +- 토큰 측정 대상 경로는 git diff-index --cached 다. GIT_INDEX_FILE을 그대로 물려받으므로 commit -a(.git/index.lock)와 commit <경로>(.git/next-index-.lock)의 임시 인덱스를 읽는다(git 2.54.0 실측, 테스트 2개). --amend도 HEAD와 비교하므로 대상은 amend가 새로 얹는 변경이다. 훅은 --amend -m을 일반 커밋과 구분할 수 없다(source=message, 실측). +- 귀속 기록은 트레일러를 메시지 파일에 쓴 뒤 저장한다. 한계(주석에 기록): 이 훅 뒤에 git이 커밋을 포기하면 귀속된 응답이 기록에만 남는다. 실측 경로는 서명 실패(exit 128 failed to write commit object)와 -e -m 또는 에디터 --amend에서 메시지를 비운 경우다. 빈 커밋(--allow-empty 없음)은 훅 전에 거부돼 해당하지 않는다. 고치려면 커밋 후 확인 단계가 필요해 범위 밖으로 두었다(과소 보고 방향의 실패). +- 테스트: 파일 삭제는 로컬 protect-tests 훅이 막아 test_verify_bypass_detection.py를 'Verify-Bypassed/검증마커가 다시 나타나지 않는다'는 AC #10 회귀 테스트로 바꿨다. tests/test_trailer_key_dedup.py를 새로 만들었다(AC #2~#4, amend). 추가한 것: rebase 재생(AC #8), Signed-off-by 사람/에이전트(AC #10), AI-Agent 축약(AC #6), GIT_INDEX_FILE 2종, amend 측정 2종, 거부 시 기록 불변. install 테스트는 실제 template/hooks의 post-commit 링크가 정리되는지 단언한다. + +검증(2026-10-04): unittest 129개 OK, ruff 통과. 수동 실측(스크래치 저장소): AI_AGENT 없는 커밋 → Signed-off-by 있음·AI-Agent 없음, 앞 문단에 Task-Id가 있으면 한 줄만 유지; AI_AGENT 있는 commit -a → AI-Agent·Co-Authored-By·토큰 트레일러, Signed-off-by 없음; git rebase 재생 → exit 0, 트레일러 추가·중복 없음. 이 저장소 커밋 937573d·ff6d252·08e26e9: 중복 없음, Signed-off-by 없음, 직접 쓴 Co-Authored-By 값 보존. 편차: protect_tests 훅이 테스트 파일 삭제를 막아 test_verify_bypass_detection.py를 AC#10 회귀 테스트로 다시 썼다. + + ## Comments @@ -67,3 +85,9 @@ created: 2026-10-03 03:14 2026-10-03: decision-25가 decision-19의 Signed-off-by 제거 조항을 대체했다(더 최근 문서인 decision-24·DRAFT-19가 Signed-off-by를 전제로 한다). 예전 AC#7(Signed-off-by와 Verify-Bypassed 미삽입)을 지우고 Signed-off-by는 유지하는 AC로 다시 적었다. 설명 본문의 'Signed-off-by를 없앤다'는 이 코멘트로 대체된다. --- + +## Final Summary + + +트레일러 삽입을 post-commit의 --amend에서 prepare-commit-msg의 interpret-trailers --in-place로 옮기고 post-commit을 삭제했다. 커밋이 처음부터 최종 메시지로 만들어져 해시가 바뀌지 않고, 재귀 가드·검증 마커·rebase 중 실패가 함께 사라졌다. 중복 판정을 원문 줄 단위·키 기준(대소문자 무시)으로 바꿔 Task-Id/Co-Authored-By 중복을 없앴다. AI-Tool/AI-Tool-Version/AI-Model을 AI-Agent 한 줄로 합쳤고, Verify-Bypassed를 없앴으며, Signed-off-by는 AI 도구가 감지되지 않은 커밋에만 붙인다(decision-30). 토큰 측정 대상은 스테이징된 인덱스(GIT_INDEX_FILE 존중)에서 구한다. 검증: unittest 129개, ruff, 사람·에이전트·commit -a·rebase 수동 실측. 남은 한계: 훅 이후 git이 커밋을 중단하면(서명 실패, 빈 메시지) 그 턴이 귀속된 것으로 기록돼 재시도 커밋에서 토큰이 적게 잡힌다. + 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/gitformat.conf b/hooks/gitformat.conf index e57bb88..7973e39 100644 --- a/hooks/gitformat.conf +++ b/hooks/gitformat.conf @@ -1,14 +1,13 @@ -# 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 포맷). 훅 +# prepare-commit-msg가 이 파일을 `git config --file`로 읽어 값을 가져온다 — +# 훅의 실행 로직(함수/제어흐름)은 이 파일과 무관하게 유지된다(공유하는 건 값뿐). +# GF-128에서 post-commit이 삭제되며 이 파일을 읽는 훅은 하나가 됐다. # # 이 파일은 coding-agent-git-commit-tool 자체의 "내부 기본값"이고, 컨슈머 저장소가 오버라이드하는 # `git config gitformat.taskPrefix`/`gitformat.branchExempt`/`gitformat.aiModel` # (컨슈머 저장소의 로컬/전역 git config)과는 레이어가 다르다 — 오버라이드가 있으면 # 그 값이 우선하고, 없으면 여기 기본값을 쓴다. [gitformat] - markerFile = .gitformat-verified taskPrefixDefault = GF branchExempt = main branchExempt = master @@ -38,12 +37,9 @@ subjectMaxLength = 50 bodyLineMaxLength = 72 [gitformat "trailer"] - verifyBypassed = Verify-Bypassed taskId = Task-Id - aiTool = AI-Tool - aiToolVersion = AI-Tool-Version + aiAgent = AI-Agent coAuthoredBy = Co-Authored-By - aiModel = AI-Model tokensUsed = Tokens-Used toolCalls = Tool-Calls hooksCommit = Hooks-Commit diff --git a/hooks/post-commit b/hooks/post-commit deleted file mode 100755 index 09b25c2..0000000 --- a/hooks/post-commit +++ /dev/null @@ -1,770 +0,0 @@ -#!/usr/bin/env python3 -# post-commit 훅: --no-verify 우회를 탐지해 Verify-Bypassed 트레일러를 프로그래밍적으로 -# 삽입한다(decision-3). post-commit은 --no-verify를 쓰든 --amend를 하든 git이 항상 -# 실행을 보장하는 훅이라는 점을 이용한다. -# -# 판단 근거: prepare-commit-msg는 재생 커밋 면제나 에디터 경로 거부로 빠져나가지 -# 않으면 직전 마커를 지우고 조건 없이 새로 쓴다. 이 훅은 그 마커의 존재 여부만 보고, -# 확인 후에는 항상(존재하든 안 하든) 지운다 — 그래야 다음 커밋으로 스테일 마커가 -# 새지 않는다. -# -# 각 트레일러 판단 단계는 이름 있는 함수로 분해돼 있고(GF-87), 실행 순서는 파일 맨 -# 아래에 나열돼 있다. 다른 훅과 겹치는 블록(자기 위치 해석, conf 읽기 가드, -# TASK_PREFIX/BRANCH 계산)은 공유 모듈로 빼지 않고 파일마다 독립적으로 중복을 -# 유지한다 — 파일 하나만 읽으면 그 훅의 동작을 전부 파악할 수 있어야 한다는 -# 감사 가능성 요구사항이다(decision-16). -import json -import os -import re -import subprocess -import sys - -# 로케일이 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 commit --amend`로 이뤄지는데, amend 자체가 -# post-commit을 재발동시킨다. 이 가드는 git rev-parse 한 번보다도 앞, 파일에서 가장 -# 먼저 실행돼야 한다 — 늦으면 그만큼 재귀가 깊어지고, 빠지면 무한 재귀가 된다. -if os.environ.get("_GITFORMAT_AMEND_GUARD") == "1": - sys.exit(0) - -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 local_get(key): - return subprocess.run( - ["git", "config", "--get", key], - capture_output=True, - encoding="utf-8", - check=False, - ).stdout.rstrip("\n") - - -# gitformat.conf(GF-44)에 이 훅의 내부 기본값(마커 파일명, trailer 키 이름 10종, -# Task-Id 접두어 기본값, AI 도구 식별자, Co-Authored-By 값)이 모여 있다. 값만 -# 여기서 읽고, 이 값들을 쓰는 로직은 이 파일 안에서만 독립적으로 유지한다. -MARKER = os.path.join(GIT_DIR, conf_get("gitformat.markerFile")) -# Tokens-Used/Tool-Calls 귀속 기록(decision-27, GF-129). .gitformat-verified와 같은 -# "커밋 간 git-미추적 상태 파일" 관례를 따르되, 이 파일은 삭제되지 않고 세션 동안 -# 계속 자란다. 첫 줄은 세션 ID, 그다음 줄부터는 이미 어느 커밋에 귀속된 응답의 키다 -# (attributed_record_load()/attributed_record_save()). 응답 하나가 두 커밋에 들어가지 -# 않게 하려는 것이라, 세션 ID가 다르면(응답 키는 세션 사이에 유일하다는 보장이 없다) -# 기록을 버리고 새로 시작한다. -# 파일명은 gitformat.conf로 빼지 않는다 - 컨슈머가 오버라이드할 이유가 없는 순수 -# 내부 구현 세부사항이라 다른 trailer 키 이름들과 성격이 다르다. -ATTRIBUTED_RECORD = os.path.join(GIT_DIR, ".gitformat-token-attributed") -# GF-139까지 쓰던 델타 커서("<세션 ID> <줄 수>"). 의미가 델타에서 커밋당 귀속으로 -# 바뀌어(decision-27) 더는 읽지 않는다. 남겨 두면 "아직 쓰이는 상태 파일"로 오해받으니 -# 측정에 성공할 때 지운다 - 읽지 않으므로 지우는 시점은 값에 영향이 없다. -LEGACY_CURSOR = os.path.join(GIT_DIR, ".gitformat-token-cursor") - -TRAILER_VERIFY_BYPASSED = conf_get("gitformat.trailer.verifyBypassed") -TRAILER_TASK_ID = conf_get("gitformat.trailer.taskId") -TRAILER_AI_TOOL = conf_get("gitformat.trailer.aiTool") -TRAILER_AI_TOOL_VERSION = conf_get("gitformat.trailer.aiToolVersion") -TRAILER_CO_AUTHORED_BY = conf_get("gitformat.trailer.coAuthoredBy") -TRAILER_AI_MODEL = conf_get("gitformat.trailer.aiModel") -TRAILER_TOKENS_USED = conf_get("gitformat.trailer.tokensUsed") -TRAILER_TOOL_CALLS = conf_get("gitformat.trailer.toolCalls") -TRAILER_HOOKS_COMMIT = conf_get("gitformat.trailer.hooksCommit") -TRAILER_SIGNED_OFF_BY = conf_get("gitformat.trailer.signedOffBy") -AI_TOOL_CLAUDE_CODE = conf_get("gitformat.aiToolClaudeCode") -CO_AUTHORED_BY_VALUE = conf_get("gitformat.coAuthoredBy") - -VERIFIED = os.path.isfile(MARKER) -try: - os.remove(MARKER) -except OSError: - pass - -# 커밋 메시지 원문은 유효하지 않은 UTF-8 바이트를 포함할 수 있다(GF-80, 실제 CI에서 -# 재현). commit-msg는 메시지를 읽기만 하므로 errors="replace"로 충분하지만, 이 훅은 -# 읽은 메시지를 amend로 다시 써넣기 때문에 replace를 쓰면 깨진 바이트가 U+FFFD로 -# 치환돼 커밋 내용이 조용히 손상된다. surrogateescape는 디코드 불가 바이트를 대리 -# 문자로 보존했다가 인코드 시 원래 바이트로 정확히 되돌리므로, 예외 없이 바이트 -# 단위 왕복이 보장된다. 아래 interpret-trailers 왕복과 amend 인자에도 같은 처리를 -# 쓴다(파이썬은 POSIX에서 argv를 surrogateescape로 인코드한다). -CURRENT_MSG = subprocess.run( - ["git", "log", "-1", "--pretty=%B"], - stdout=subprocess.PIPE, - encoding="utf-8", - errors="surrogateescape", - check=True, -).stdout.rstrip("\n") - -# grep과 동일하게 개행(\n)만 줄 경계로 취급한다 — splitlines()는 \r 등도 줄 -# 경계로 보아 트레일러 값 안에 그런 문자가 있으면 판정이 sh 판과 어긋난다. -EXISTING_TRAILERS = subprocess.run( - ["git", "interpret-trailers", "--parse"], - input=CURRENT_MSG + "\n", - capture_output=True, - encoding="utf-8", - errors="surrogateescape", - check=False, -).stdout.split("\n") - - -# git interpret-trailers의 --if-exists(doNothing/addIfDifferent 등)는 키를 -# "접두어"로 매칭한다 — 예: "AI-Tool"이 이미 있으면 "AI-Tool-Version"도 같은 -# 트레일러로 오인해 값이 우연히 같으면 통째로 누락시킨다(GF-33). 이 모호한 -# 판단에 기대는 대신, 기존 트레일러를 직접 파싱해 "키: 값" 한 줄이 정확히 -# 일치할 때만 건너뛰고, 그 외엔 항상 --if-exists add로 강제 추가한다. -def trailer_exists(key, value): - return f"{key}: {value}" in EXISTING_TRAILERS - - -# 각 트레일러 판단 함수는 필요할 때 이 버퍼에 "키: 값" 한 줄을 쌓기만 한다 — 값 -# 안에 공백이 있어도(예: "Signed-off-by: Jane Dev ") 한 줄 = -# 한 트레일러라는 경계가 깨지지 않는다. 인자 조립과 amend 실행은 파일 맨 아래에서 -# 한 번만 한다. -TRAILER_QUEUE = [] - - -def queue_trailer(key, value): - TRAILER_QUEUE.append(f"{key}: {value}") - - -# sh 판은 $PWD를 썼다. os.getcwd()는 심볼릭 링크를 해석한 물리 경로를 돌려주므로 -# (/var -> /private/var 같은 환경) 그대로 쓰면 Claude Code가 실제로 만든 세션 -# 디렉터리 슬러그와 어긋난다. POSIX 셸이 시작할 때 하는 것과 동일하게, 환경변수 -# PWD가 현재 디렉터리를 가리키는 절대경로일 때만 그 값을 쓴다. -def shell_pwd(): - pwd = os.environ.get("PWD", "") - if pwd.startswith("/"): - try: - if os.path.samefile(pwd, "."): - return pwd - except OSError: - pass - return os.getcwd() - - -# Claude Code가 ~/.claude/projects/ 아래 세션 디렉터리를 만들 때 쓰는 실제 규칙은 -# "영숫자가 아닌 모든 문자를 하이픈으로 치환"이다(GF-98 - 슬래시만 치환하면 밑줄/ -# 점이 든 경로에서 트랜스크립트를 영영 못 찾는다). -# -# 여기는 이 저장소에서 외부 계약에 의존하는 유일한 지점이다(GF-119). 이 슬러그 -# 규칙과 트랜스크립트 경로 레이아웃은 Claude Code의 문서화되지 않은 내부 구현이라 -# 우리가 지킬 수 있는 약속이 아니다 - 상대가 규칙을 바꾸면 이 함수는 예외 없이 -# "없는 경로"를 반환하고, 측정은 조용히 어긋난다. 그래서 실패를 숨기지 않는 쪽으로 -# 설계했다: Tokens-Used/Tool-Calls는 unavailable (transcript-not-found)로 사유를 -# 남기므로 커밋 footer만 봐도 결합이 깨진 걸 알 수 있다. 다만 AI-Model은 -# decision-5의 fail-open 때문에 신호 없이 누락된다 - 이 비대칭은 의도된 것이고 -# 진단 절차는 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") - - -# --no-verify 우회 탐지(decision-3): 마커가 없다는 건 prepare-commit-msg가 마커를 -# 남기지 못했다는 뜻이다. prepare-commit-msg는 --no-verify로 건너뛸 수 없으므로 -# 지금은 재생 커밋(그 훅이 면제로 먼저 빠져나간 경우)에서만 마커가 없다. -def detect_verify_bypass(): - if not VERIFIED and not trailer_exists(TRAILER_VERIFY_BYPASSED, "true"): - queue_trailer(TRAILER_VERIFY_BYPASSED, "true") - - -# Task-Id(decision-4): commit-msg가 이미 브랜치명에 -<번호> 패턴이 있는지 -# 검증했으므로(없으면 예외 브랜치가 아닌 한 커밋 자체가 거부됨), 여기서는 같은 패턴을 -# 다시 찾아 Task-Id 트레일러로 남기기만 한다. 여기서 계산하는 TASK_PREFIX/BRANCH -# 블록은 commit-msg의 enforce_task_id_branch가 검증할 때 쓰는 블록과 동일하게 -# 유지한다(파일별 독립 중복). -def trailer_task_id(): - task_prefix_default = conf_get("gitformat.taskPrefixDefault") - task_prefix = local_get("gitformat.taskPrefix") - # 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" - - # commit-msg와 동일하게 앵커링해서 부분 문자열 오매치를 피한다(GF-34). - match = re.search( - rf"(^|[^a-zA-Z0-9]){re.escape(task_prefix)}-([0-9]+)", branch, re.IGNORECASE - ) - if match is None: - return - - task_id = f"{task_prefix}-{match.group(2)}" - if not trailer_exists(TRAILER_TASK_ID, task_id): - queue_trailer(TRAILER_TASK_ID, task_id) - - -# AI-Tool/AI-Tool-Version/Co-Authored-By(decision-5). AI_TOOL_ID는 -# trailer_ai_model()/trailer_tokens_used()도 참조하므로 여기서 전역으로 채워둔다. -AI_TOOL_ID = "" - - -def trailer_ai_tool(): - global AI_TOOL_ID - # AI_AGENT는 Claude Code 프로세스가 하위 프로세스에 주입하는 값이라 LLM이 - # 스스로 만든 값이 아니다(예: claude-code_2-1-227_agent). - ai_agent = os.environ.get("AI_AGENT", "") - AI_TOOL_ID = ai_agent.split("_", 1)[0] - - if AI_TOOL_ID and not trailer_exists(TRAILER_AI_TOOL, AI_TOOL_ID): - queue_trailer(TRAILER_AI_TOOL, AI_TOOL_ID) - - # 구분자가 없는데 두 번째 필드를 뽑으면 원문 전체가 돌아와 AI-Tool-Version이 - # AI-Tool과 같아져버린다(GF-33). 밑줄이 있을 때만 버전을 채운다. - if "_" in ai_agent: - ai_tool_version = ai_agent.split("_")[1].replace("-", ".") - if ai_tool_version and not trailer_exists( - TRAILER_AI_TOOL_VERSION, ai_tool_version - ): - queue_trailer(TRAILER_AI_TOOL_VERSION, ai_tool_version) - - # Co-Authored-By는 실제로 Claude가 작업했다고 확신할 수 있는 경우(claude-code)에만 - # 붙인다. 다른 AI 도구까지 "Claude"로 공동저자 표기하면 잘못된 귀속이 된다. - if AI_TOOL_ID == AI_TOOL_CLAUDE_CODE and not trailer_exists( - TRAILER_CO_AUTHORED_BY, CO_AUTHORED_BY_VALUE - ): - queue_trailer(TRAILER_CO_AUTHORED_BY, CO_AUTHORED_BY_VALUE) - - -# 트랜스크립트 마지막 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-Model(decision-5): Claude Code는 세션 트랜스크립트 -# (~/.claude/projects//.jsonl)의 message.model 필드를 읽는다 — -# 이는 Anthropic API 응답을 애플리케이션이 그대로 기록한 값이라 자가신고가 -# 아니다. 그 외 도구는 commit-msg 게이트가 이미 검증한 gitformat.aiModel을 -# 쓴다. CLAUDE_CODE_SESSION_ID는 트랜스크립트 파일 경로를 찾는 데만 쓰고, 값 -# 자체를 커밋 footer에 남기지는 않는다(세션 식별자가 커밋 이력에 영구히 남는 걸 -# 피하기 위함, decision-5 amendment). -def trailer_ai_model(): - ai_model = "" - - if AI_TOOL_ID == AI_TOOL_CLAUDE_CODE and os.environ.get("CLAUDE_CODE_SESSION_ID"): - # 트랜스크립트 조회는 어떤 이유로 실패하든(파일 없음/포맷 변경/권한) 커밋을 - # 막지 않고 트레일러만 조용히 생략하는 의도된 fail-open이다(decision-5). - # 이 파일에서 예외를 삼키는 곳은 여기 하나뿐이다 — 나머지 경로는 실패를 - # 조용히 흘려보내지 않는다(GF-76). - try: - ai_model = read_transcript_model(claude_transcript_path()) - except (OSError, ValueError, TypeError): - ai_model = "" - elif AI_TOOL_ID: - ai_model = local_get("gitformat.aiModel") - - if ai_model and not trailer_exists(TRAILER_AI_MODEL, ai_model): - queue_trailer(TRAILER_AI_MODEL, ai_model) - - -# in에 합치는 입력측 usage 필드. 캐시 토큰도 모델이 실제로 읽어 들인 입력이라 빼면 -# 값이 무의미해진다(doc-16 실측: 입력측이 전체의 거의 전부다). out은 output_tokens -# 하나다(decision-27, GF-129). -INPUT_USAGE_FIELDS = ( - "input_tokens", - "cache_creation_input_tokens", - "cache_read_input_tokens", -) -OUTPUT_USAGE_FIELD = "output_tokens" - - -# JSONL 트랜스크립트 하나를 읽어 (줄 키, 파싱된 줄) 목록으로 돌려준다. 줄 키는 -# "<트랜스크립트 디렉터리 기준 파일 경로>:<1부터 센 줄 번호>"로, requestId/message.id가 -# 없는 줄을 응답으로 삼을 때 쓴다(group_responses()). 트랜스크립트는 뒤에 덧붙기만 -# 하므로 같은 줄은 다음 커밋에서도 같은 키를 얻는다. -# -# 줄 하나라도 JSON 파싱에 실패하면 ValueError를 그대로 올려 배치 전체를 실패시킨다 — -# 불량 줄만 건너뛰면 "일부만 집계된 값"이 정상값인 척 기록되므로(GF-111/GF-112). -# 파일을 못 읽으면 OSError가 올라간다. 둘 다 호출자가 사유 슬러그로 바꾼다. -def read_transcript_entries(path, key_prefix): - with open(path, "rb") as f: - data = f.read().decode("utf-8", errors="replace") - lines = data.split("\n") - if lines and lines[-1] == "": - lines.pop() - entries = [] - for number, line in enumerate(lines, start=1): - if not line.strip(): - continue - entries.append((f"{key_prefix}:{number}", json.loads(line))) - return entries - - -# assistant 줄을 응답 단위로 묶는다. 트랜스크립트는 API 응답 하나를 content 블록마다 -# 한 줄씩 기록하고 각 줄이 같은 message.usage를 반복해서 들고 있다 - 줄마다 더하면 -# 2~3배 부풀려진다(GF-139 실측: assistant 215줄이 응답 109개). 같은 응답의 줄은 -# 최상위 requestId와 message.id를 공유하므로 " "를 응답 키로 -# 삼는다. 이 키는 그대로 귀속 기록 파일의 한 줄이 되므로, 공백·개행이 든 id는(실제로는 -# 본 적 없다) 기록을 깨뜨리지 않도록 id가 없는 것과 같이 취급한다. -# -# - usage는 응답의 첫 줄 값만 쓴다 - 나머지 줄은 같은 값의 반복이다. -# - tool_use 블록은 모든 줄에서 모은다 - 한 응답의 도구 호출이 여러 줄에 흩어져 있어 -# 첫 줄만 보면 귀속 근거를 놓친다(AC#2). -# - id가 하나라도 없는 줄은 같은 응답인지 판단할 근거가 없으므로 줄 키로 각자 하나의 -# 응답이 된다(GF-139 규칙 유지). -# -# 키가 같으면 파일이 달라도(본 트랜스크립트와 서브에이전트 트랜스크립트) 같은 응답으로 -# 본다 - 같은 API 응답을 두 번 더하지 않는다는 원칙이 파일 경계보다 우선이다. -def group_responses(entries): - responses = {} - for line_key, entry in entries: - if not isinstance(entry, dict) or entry.get("type") != "assistant": - continue - message = entry.get("message") - if not isinstance(message, dict): - continue - request_id = entry.get("requestId") - message_id = message.get("id") - if ( - isinstance(request_id, str) - and re.fullmatch(r"\S+", request_id) - and isinstance(message_id, str) - and re.fullmatch(r"\S+", message_id) - ): - response_key = f"{request_id} {message_id}" - else: - response_key = line_key - response = responses.get(response_key) - if response is None: - response = {"usage": message.get("usage"), "tool_uses": []} - responses[response_key] = response - content = message.get("content") - if isinstance(content, list): - response["tool_uses"].extend( - block - for block in content - if isinstance(block, dict) and block.get("type") == "tool_use" - ) - return responses - - -# 귀속 대상 경로 = 방금 만든 커밋이 바꾼 파일 목록(저장소 상대경로). post-commit은 -# 커밋이 이미 만들어진 뒤에 돌기 때문에 git diff --cached는 비어 있다 - 그래서 HEAD -# 커밋 자체의 파일 목록을 읽는다(AC#8). --root는 첫 커밋(부모 없음)에서도 목록을 -# 내게 하고, -z는 core.quotePath가 비ASCII 경로를 따옴표·이스케이프로 감싸는 것을 -# 막아 원래 경로 그대로 받게 한다. 이름 바꾸기는 -M을 주지 않으므로 옛 이름과 새 -# 이름이 둘 다 나온다 - 어느 쪽을 건드린 응답이든 이 커밋의 작업이다. -def commit_target_paths(): - output = subprocess.run( - [ - "git", - "diff-tree", - "--root", - "--no-commit-id", - "--name-only", - "-r", - "-z", - "HEAD", - ], - stdout=subprocess.PIPE, - encoding="utf-8", - errors="surrogateescape", - check=True, - ).stdout - return {path for path in output.split("\0") if path} - - -# Bash 명령 판정에서 대상 경로 바로 앞에 와도 되는 글자: 공백, 따옴표, 백틱, 셸 -# 구두점(= ( < > | ; & , :). 경로 바로 뒤에는 경로를 이어 쓰는 글자(영숫자 _ . - /)가 -# 오면 안 된다 - hooks/post-commit-old나 a.txt.bak은 다른 파일이다. -BASH_PATH_LEFT_BOUNDARY = "\\s'\"`=(<>|;&,:" -BASH_PATH_RIGHT_FORBIDDEN = "A-Za-z0-9_.\\-/" - - -# 귀속 판정기. 대상 경로와 저장소 최상위 경로를 한 번만 계산해 두고, tool_use 블록 -# 하나가 대상 경로를 실제로 건드렸는지 판정한다(decision-27, GF-129). 판정은 경로 -# 문자열 대조뿐이라 완벽하지 않다 - 경로를 쓰지 않고 파일을 바꾸는 명령(글롭, 변수, -# cd 후 파일명만 쓰기)은 놓치고, 경로를 문자열로 담기만 한 명령(git add, 커밋 -# 메시지에 경로 언급)은 귀속한다. 놓치는 쪽을 택한 것은 오귀속보다 과소 보고가 -# 낫다는 판단이다(doc-16 "판정 규칙"). -class AttributionMatcher: - def __init__(self, targets): - self.targets = targets - self.top = subprocess.run( - ["git", "rev-parse", "--show-toplevel"], - stdout=subprocess.PIPE, - encoding="utf-8", - errors="surrogateescape", - check=True, - ).stdout.rstrip("\n") - # macOS의 /tmp → /private/tmp처럼 같은 디렉터리가 두 이름을 가지면, 도구가 - # 기록한 절대경로와 git이 알려준 최상위 경로가 서로 다른 이름을 쓸 수 있다. - # 비교는 양쪽 모두 심볼릭 링크를 푼 물리 경로로 한다. - self.real_top = os.path.realpath(self.top) - self.bash_patterns = [self._bash_pattern(target) for target in sorted(targets)] - - # Bash 명령 문자열에서 대상 경로 하나를 찾는 정규식(AC#4). - # - # - 하위 디렉터리 안의 대상(hooks/post-commit): 저장소 상대경로가 경계로 구분돼 - # 들어 있으면 귀속한다. 왼쪽 경계에 '/'도 허용한다 - 그래야 절대경로 - # (/…/git-format/hooks/post-commit)나 ../git-format/hooks/post-commit처럼 - # 상대경로를 꼬리로 품은 표기도 잡힌다. myhooks/post-commit은 왼쪽이 영문자라 - # 걸리지 않는다. 파일명(post-commit)만 든 명령은 상대경로가 없으니 귀속하지 - # 않는다 - README.md 같은 흔한 이름이 무관한 응답을 끌어오는 것을 막는다. - # - 저장소 최상위의 대상(a.txt): 상대경로가 곧 파일명이다. 그래서 왼쪽 경계에 - # '/'를 허용하지 않는다 - 허용하면 sub/a.txt 같은 다른 파일의 파일명 매칭이 - # 된다. 대신 ./a.txt와 저장소 최상위 절대경로(논리·물리 둘 다)를 붙인 표기는 - # 명시적으로 받는다. - def _bash_pattern(self, target): - right = rf"(?![{BASH_PATH_RIGHT_FORBIDDEN}])" - if "/" in target: - left = rf"(?/subagents/*.jsonl)를 훑어 아직 귀속되지 않은 응답 중 -# 이 커밋이 바꾼 파일을 건드린 것을 고른다 - "a 수정 → b 먼저 커밋 → a 커밋" 순서에서도 -# a를 고친 응답이 뒤 커밋에 들어간다. 응답 하나는 한 커밋에만 귀속되도록, 귀속한 응답 -# 키를 ATTRIBUTED_RECORD에 남긴다. 이 커밋의 파일을 건드리지 않은 응답(무관한 파일 -# 탐색, 도구 호출 없는 대화)은 어느 커밋에도 들어가지 않는다 - 커밋당 토큰량이라는 -# 정의에서 의도된 결과다. Read도 file_path를 가지므로 커밋한 파일을 읽은 응답은 -# 귀속된다. -# -# 성공하면 MEASUREMENT_STATUS=ok와 INPUT_TOKENS_SUM/OUTPUT_TOKENS_SUM/TOOL_CALL_COUNT/ -# ATTRIBUTED_RESPONSE_COUNT를 채운다. 실패하면 MEASUREMENT_STATUS=unavailable + -# MEASUREMENT_REASON(실패 지점을 가리키는 짧은 슬러그)만 남기고 기록 파일도 갱신하지 -# 않는다 - 다음 커밋이 같은 응답들을 다시 판정할 수 있어야 한다. 실패 여부/사유는 -# 예외가 아니라 이 상태 변수들로만 전달한다. -MEASUREMENT_STATUS = "unavailable" -MEASUREMENT_REASON = "" -INPUT_TOKENS_SUM = 0 -OUTPUT_TOKENS_SUM = 0 -TOOL_CALL_COUNT = 0 -ATTRIBUTED_RESPONSE_COUNT = 0 - - -def measure_claude_code_token_usage(): - global MEASUREMENT_STATUS, MEASUREMENT_REASON, INPUT_TOKENS_SUM, OUTPUT_TOKENS_SUM - global TOOL_CALL_COUNT, ATTRIBUTED_RESPONSE_COUNT - MEASUREMENT_STATUS = "unavailable" - MEASUREMENT_REASON = "" - - session_id = os.environ.get("CLAUDE_CODE_SESSION_ID", "") - if not session_id: - MEASUREMENT_REASON = "no-session-id" - return - - transcript = claude_transcript_path() - if not os.path.isfile(transcript): - MEASUREMENT_REASON = "transcript-not-found" - return - - # 서브에이전트 트랜스크립트는 Claude Code가 본 트랜스크립트 옆 - # <세션 ID>/subagents/에 같은 JSONL 형식으로 남긴다(AC#11). 서브에이전트를 쓰지 - # 않은 세션에는 이 디렉터리가 없으므로 없는 건 정상이다. 정렬은 줄 키와 응답 순서를 - # 실행마다 같게 하려는 것이다. 줄 키의 파일 부분은 트랜스크립트 디렉터리 기준 경로라 - # 본 트랜스크립트와 서브에이전트 파일이 서로 겹치지 않는다. - sources = [(transcript, os.path.basename(transcript))] - subagent_dir = os.path.join(os.path.dirname(transcript), session_id, "subagents") - if os.path.isdir(subagent_dir): - try: - names = sorted(os.listdir(subagent_dir)) - except OSError: - MEASUREMENT_REASON = "transcript-unreadable" - return - for name in names: - path = os.path.join(subagent_dir, name) - if name.endswith(".jsonl") and os.path.isfile(path): - sources.append((path, f"{session_id}/subagents/{name}")) - - # 한 파일, 한 줄이라도 깨지면 배치 전체가 실패한다 - 일부만 집계한 값을 정상값처럼 - # 남기지 않는다(GF-112). 이제는 구간이 아니라 세션 전체를 매번 읽으므로, 한 번 - # 깨진 줄이 생기면 그 세션의 이후 커밋은 계속 transcript-parse-failed가 된다 - - # 조용히 틀린 값을 남기는 것보다 사유가 계속 보이는 쪽을 택했다. - entries = [] - for path, key_prefix in sources: - try: - entries += read_transcript_entries(path, key_prefix) - except OSError: - MEASUREMENT_REASON = "transcript-unreadable" - return - except (ValueError, TypeError): - MEASUREMENT_REASON = "transcript-parse-failed" - return - - responses = group_responses(entries) - matcher = AttributionMatcher(commit_target_paths()) - recorded = attributed_record_load(session_id) - already_attributed = set(recorded) - - input_sum = 0 - output_sum = 0 - calls = 0 - new_keys = [] - for response_key, response in responses.items(): - if response_key in already_attributed: - continue - # Tool-Calls는 귀속된 응답의 tool_use 전부가 아니라 대상 경로를 실제로 건드린 - # 블록만 센다(AC#6) - 같은 응답 안의 ls·grep 같은 호출은 이 커밋의 작업이 아니다. - touching = sum(1 for block in response["tool_uses"] if matcher.touches(block)) - if not touching: - continue - new_keys.append(response_key) - calls += touching - usage = response["usage"] - if isinstance(usage, dict): - for field in INPUT_USAGE_FIELDS: - value = usage.get(field) - input_sum += value if isinstance(value, int) else 0 - value = usage.get(OUTPUT_USAGE_FIELD) - output_sum += value if isinstance(value, int) else 0 - - # 여기까지 왔으면 트랜스크립트를 실제로 읽어 판정했으므로, 귀속된 응답이 없어도 - # 기록을 쓴다 - 세션이 바뀐 직후라면 첫 줄(세션 ID)을 새 세션으로 맞춰 둔다. - attributed_record_save(session_id, recorded + new_keys) - try: - os.remove(LEGACY_CURSOR) - except OSError: - pass - INPUT_TOKENS_SUM = input_sum - OUTPUT_TOKENS_SUM = output_sum - TOOL_CALL_COUNT = calls - ATTRIBUTED_RESPONSE_COUNT = len(new_keys) - MEASUREMENT_STATUS = "ok" - - -# trailer_tokens_used(): AI_TOOL_ID로 분기하는 디스패처(GF-97). 사람 커밋 -# (AI_TOOL_ID가 빈 값)은 트레일러를 전혀 붙이지 않는다. claude-code는 -# measure_claude_code_token_usage()의 실측 결과를 쓴다. 그 외 감지된 AI 도구는 -# 아직 서버발급 usage를 읽을 로컬 채널이 없으므로(decision-15) 항상 -# "unavailable (no-usage-channel)"을 남긴다. -# -# 측정에 성공한 값은 두 가지로 나뉜다(decision-27). 귀속된 응답이 있으면 -# "in=<입력> out=<출력>"이고, 그 usage가 정말 0이어도 사유 없이 그대로 남긴다 - -# 측정해서 나온 0은 "unavailable"과 구분되는 정당한 값이다. 귀속된 응답이 하나도 -# 없으면 같은 0이라도 "이 커밋 파일을 건드린 응답을 못 찾았다"는 뜻이므로 -# (no-attributed-turn)을 붙여 진짜 측정값 0과 footer만 보고 구별되게 한다(AC#12). -def trailer_tokens_used(): - if not AI_TOOL_ID: - return - - if AI_TOOL_ID == AI_TOOL_CLAUDE_CODE: - measure_claude_code_token_usage() - if MEASUREMENT_STATUS == "ok" and ATTRIBUTED_RESPONSE_COUNT: - tokens_value = f"in={INPUT_TOKENS_SUM} out={OUTPUT_TOKENS_SUM}" - calls_value = str(TOOL_CALL_COUNT) - elif MEASUREMENT_STATUS == "ok": - tokens_value = "in=0 out=0 (no-attributed-turn)" - calls_value = "0 (no-attributed-turn)" - else: - tokens_value = f"unavailable ({MEASUREMENT_REASON})" - calls_value = f"unavailable ({MEASUREMENT_REASON})" - else: - tokens_value = "unavailable (no-usage-channel)" - calls_value = "unavailable (no-usage-channel)" - - if not trailer_exists(TRAILER_TOKENS_USED, tokens_value): - queue_trailer(TRAILER_TOKENS_USED, tokens_value) - if not trailer_exists(TRAILER_TOOL_CALLS, calls_value): - queue_trailer(TRAILER_TOOL_CALLS, calls_value) - - -# Hooks-Commit(decision-5): 이 커밋을 검증한 coding-agent-git-commit-tool 훅 코드 자체의 커밋 해시. -# 수동 버전 문자열 대신 훅 스크립트가 위치한 저장소의 HEAD를 그대로 읽으므로 항상 -# 정확하다. hooks/가 coding-agent-git-commit-tool 클론 안에 있지 않으면(예: 훅만 복사해 배포한 경우) -# 조회가 실패하는데, 그때는 커밋을 막지 않고 이 트레일러만 생략하는 게 의도된 -# 동작이다 - 이 함수의 판단이 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 - - hooks_commit = result.stdout.rstrip("\n") - if not trailer_exists(TRAILER_HOOKS_COMMIT, hooks_commit): - queue_trailer(TRAILER_HOOKS_COMMIT, hooks_commit) - - -# Signed-off-by(리누스/DCO 스타일): 커미터 정보를 `git commit -s`와 동일한 방식으로 -# 모든 커밋에 자동 삽입한다. AI 여부와 무관하며, 사람이 직접 입력하게 강제하지 않는다. -def trailer_signed_off_by(): - signed_off_by_value = subprocess.run( - ["git", "log", "-1", "--format=%cn <%ce>"], - stdout=subprocess.PIPE, - encoding="utf-8", - errors="surrogateescape", - check=True, - ).stdout.rstrip("\n") - if signed_off_by_value and not trailer_exists( - TRAILER_SIGNED_OFF_BY, signed_off_by_value - ): - queue_trailer(TRAILER_SIGNED_OFF_BY, signed_off_by_value) - - -detect_verify_bypass() -trailer_task_id() -trailer_ai_tool() -trailer_ai_model() -trailer_tokens_used() -trailer_hooks_commit() -trailer_signed_off_by() - -if TRAILER_QUEUE: - TRAILER_ARGS = [] - for trailer_line in TRAILER_QUEUE: - TRAILER_ARGS += ["--trailer", trailer_line] - - # 위에서 이미 정확히 일치하는 트레일러는 걸러냈으므로(trailer_exists), 여기서는 - # git의 접두어 기반 --if-exists 판단에 기대지 않고 항상 추가한다(GF-33). - NEW_MSG = subprocess.run( - ["git", "interpret-trailers", "--if-exists", "add", *TRAILER_ARGS], - input=CURRENT_MSG + "\n", - stdout=subprocess.PIPE, - encoding="utf-8", - errors="surrogateescape", - check=True, - ).stdout.rstrip("\n") - - subprocess.run( - ["git", "commit", "--amend", "--no-verify", "-m", NEW_MSG], - stdout=subprocess.DEVNULL, - env={**os.environ, "_GITFORMAT_AMEND_GUARD": "1"}, - encoding="utf-8", - check=True, - ) - -sys.exit(0) diff --git a/hooks/prepare-commit-msg b/hooks/prepare-commit-msg index 2595512..f34c82c 100755 --- a/hooks/prepare-commit-msg +++ b/hooks/prepare-commit-msg @@ -1,14 +1,15 @@ #!/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으로부터 +# 옮겨왔고 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). 남은 것은 검증마커 기록뿐이고 그 이유는 -# write_verified_marker()의 주석에 있다. +# 어디에도 속하지 않는 기능이었다(decision-23). # # 이 훅을 본체로 삼는 근거(실측 절차와 결과는 doc-15): prepare-commit-msg는 # --no-verify로도 건너뛸 수 없고, 커밋 객체가 만들어지기 전에 돌며, exit 1로 커밋을 @@ -28,10 +29,12 @@ # 나열돼 있다. 다른 훅과 겹치는 블록(자기 위치 해석, conf 읽기 가드)은 공유 모듈로 # 빼지 않고 파일마다 독립적으로 중복을 유지한다 — 파일 하나만 읽으면 그 훅의 동작을 # 전부 파악할 수 있어야 한다는 감사 가능성 요구사항이다(decision-16). +import fnmatch +import json import os +import re import subprocess import sys -import time from pathlib import Path # 로케일이 UTF-8을 제공하지 않는 환경에서는 Python의 stdout 인코딩이 ascii로 떨어져, @@ -48,15 +51,6 @@ for _stream in (sys.stdout, sys.stderr): except (AttributeError, ValueError, OSError): pass -# post-commit이 트레일러를 붙이려고 도는 `git commit --amend`는 이 훅을 다시 실행시킨다 -# — --no-verify는 prepare-commit-msg를 건너뛰지 못하기 때문이다(doc-15). 그대로 두면 -# post-commit이 지운 검증마커를 amend 쪽에서 다시 써서 $GIT_DIR에 남긴다(GF-126 실측: -# 정상 커밋 뒤에도 마커가 남아 테스트가 잡았다). amend는 메시지만 바꾸므로 이 훅의 -# 판단이 달라질 여지가 없으니, post-commit이 넘기는 가드를 보고 즉시 빠져나간다. -# 이 가드는 post-commit과 함께 GF-128에서 사라진다. -if os.environ.get("_GITFORMAT_AMEND_GUARD") == "1": - sys.exit(0) - # git이 넘기는 인자. $1=메시지 파일, $2=source. 인자가 없는 호출은 git에서 오지 # 않지만, 그때 IndexError로 죽는 대신 빈 값으로 두면 아래 판단이 무해하게 통과한다. # @@ -100,7 +94,30 @@ def conf_get(key): ).stdout.rstrip("\n") -MARKER = os.path.join(GIT_DIR, conf_get("gitformat.markerFile")) +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): @@ -142,7 +159,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 +177,7 @@ def has_author_message(): # 에디터 경로 거부(decision-18, AC #2). 에디터는 이 훅이 끝난 뒤에 열리므로 훅이 보는 # 메시지 파일에는 아직 사람이 쓸 내용이 없다 — 최종 메시지를 검증할 방법이 원리적으로 # 없다. 거부하면 "통과한 커밋은 모두 검증을 거쳤다"가 성립하고, 그것이 검증을 이 훅으로 -# 옮겨올 GF-127의 전제다. +# 옮겨온 GF-127의 전제다. def reject_editor_path(): if has_author_message(): return @@ -174,61 +191,1006 @@ def reject_editor_path(): ) -# 이전 실행에서 남은 검증마커는 시작 시점에 무효화한다(GF-31). 마커가 "이번 커밋에서 -# 이 훅이 돌았다"는 뜻이어야 하는데, 앞선 커밋이 마커를 쓴 뒤 다른 이유로 실패해 그 -# 마커를 남겼으면 그 값이 다음 커밋으로 새어든다. 마커가 gate로서의 의미를 잃은 뒤에도 -# (아래 write_verified_marker() 참고) 이 무효화는 남는다 — 마커의 나이가 판정에 쓰이지는 -# 않지만, "직전 실행이 남긴 파일"이 그대로 살아 있는 상태를 만들지 않는다. -def invalidate_stale_marker(): +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 접두어와 현재 브랜치명. 브랜치 강제(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 + + +# 브랜치명에서 -<번호>를 찾는다. 접두어 앞에 글자/숫자가 아닌 문자(또는 +# 문자열 시작)가 오도록 앵커링한다(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 브랜치 강제: -<번호> 패턴이 브랜치명에 없으면 커밋을 거부한다. +# main/master/develop/release/* 등 예외 브랜치와 detached HEAD는 면제한다. +def enforce_task_id_branch(): + task_prefix, branch = task_prefix_and_branch() + + if branch == "HEAD": + return + + # 로컬 오버라이드와 내장 기본값을 합친다(union) - README가 --add로 패턴을 + # "추가"하라고 안내하므로, 로컬에 하나라도 추가되면 main/master/develop/ + # release/* 기본값이 통째로 사라지면 안 된다(GF-78). + exempt = local_get_all("gitformat.branchExempt") + conf_get_all("gitformat.branchExempt") + # release/* 같은 글롭 패턴이므로 리터럴 비교가 아니라 글롭 매칭을 쓴다. + if any(fnmatch.fnmatchcase(branch, pattern) for pattern in exempt): + return + + if find_task_id(task_prefix, branch) is None: + fail( + f"prepare-commit-msg: 브랜치명에 {task_prefix}-<번호> 패턴이 없습니다 (현재 브랜치: {branch}).", + f" 예: {task_prefix}-12-install-script", + " Task-Id 없이 커밋하려면 예외 브랜치(main/master/develop/release/*)에서 작업하세요.", + ) + + +# 감지된 AI 도구 식별자. 빈 문자열이면 "AI 도구 미감지 = 사람 커밋"이다. 이 값 하나가 +# 모델 게이트, AI-Agent/Co-Authored-By/Tokens-Used/Tool-Calls, Signed-off-by(decision-30: +# 사람 커밋에만)를 함께 가른다 — 판정 신호가 둘로 갈리면 AI-Agent와 Signed-off-by가 한 +# 커밋에 같이 붙거나 둘 다 빠지는 모순이 생긴다. +# +# AI_AGENT는 Claude Code 프로세스가 하위 프로세스에 주입하는 값이라 LLM이 스스로 만든 +# 값이 아니다(예: claude-code_2-1-227_agent). 이 신호를 검증 가능한 것으로 바꾸는 일은 +# GF-130의 몫이다. +def ai_tool_id(): + return os.environ.get("AI_AGENT", "").split("_", 1)[0] + + +# AI-Model 존재/화이트리스트 강제(decision-5). Claude Code는 세션 트랜스크립트에서 +# 모델을 자동으로 얻으므로(trailer_ai_agent()) 이 게이트에서 제외한다. +def enforce_ai_model_gate(): + ai_tool_claude_code = conf_get("gitformat.aiToolClaudeCode") + ai_tool_gate = ai_tool_id() + if not ai_tool_gate or ai_tool_gate == ai_tool_claude_code: + return + + configured_model = subprocess.run( + ["git", "config", "--get", "gitformat.aiModel"], + capture_output=True, + encoding="utf-8", + check=False, + ).stdout.rstrip("\n") + if not configured_model: + fail( + f"prepare-commit-msg: AI 도구({ai_tool_gate})가 감지됐지만 gitformat.aiModel이 설정되지 않았습니다.", + " git config gitformat.aiModel 로 설정하세요.", + ) + + known_models = conf_get_all("gitformat.knownModel") + if known_models and configured_model not in known_models: + fail( + f"prepare-commit-msg: gitformat.aiModel 값 '{configured_model}'이 알려진 모델 목록에 없습니다.", + " hooks/gitformat.conf의 gitformat.knownModel 항목에 추가하세요.", + ) + + +# 커밋 메시지 검증(decision-18, GF-127). 구 commit-msg의 검증을 동작 그대로 옮겼다. +# --no-verify는 commit-msg를 건너뛰지만 이 훅은 건너뛰지 못하므로(doc-15), 여기로 +# 옮기면서 메시지 검증 우회가 불가능해졌다. +# +# 알려진 한계 — --amend: source=commit은 `--amend --no-edit`(이 파일이 곧 최종 +# 메시지)과 `--amend`(이 훅 뒤에 에디터가 열림)를 구분하지 못한다. 둘 다 직전 커밋 +# 메시지가 들어 있어 에디터 거부를 통과하고, 이 검증은 **직전 메시지**를 본다. 그래서 +# 에디터로 연 --amend에서 사람이 고친 최종 메시지는 검증되지 않는다. GF-127에서 +# 해결하지 않기로 했다(2026-10-04 유저 결정). +def validate_message(): + content = message_lines() + validate_format(content) + validate_length(content) + validate_fixes_trailer(content) + enforce_task_id_branch() + enforce_ai_model_gate() + + +# ── 트레일러 삽입(GF-128, decision-18·19·27·30) ───────────────────────────── +# +# GF-128 전에는 post-commit이 커밋을 만든 **뒤에** `git commit --amend`로 트레일러를 +# 붙였다. 그 구조가 낳은 문제가 셋이다 — amend가 훅을 재발동시켜 재귀 가드가 필요했고, +# rebase·cherry-pick 도중에는 amend 자체가 실패해 트레이스백을 냈고(archive DRAFT-18), +# 커밋이 한 번 만들어졌다가 다시 쓰였다. 지금은 커밋 객체가 만들어지기 전에 메시지 +# 파일($1)에 `git interpret-trailers --in-place`로 직접 쓴다. 커밋은 처음부터 최종 +# 메시지로 만들어진다. +# +# 붙이는 트레일러와 조건(decision-19, decision-27, decision-30): +# Task-Id 브랜치명에 -<번호>가 있을 때 +# AI-Agent AI 도구가 감지되면 항상. <도구>/<버전> (<모델>) +# Co-Authored-By 도구가 claude-code일 때 +# Tokens-Used AI 도구가 감지되면 항상(측정 못 하면 unavailable (사유)) +# Tool-Calls 위와 같다 +# Hooks-Commit 항상(훅 클론의 HEAD를 못 읽으면 생략) +# Signed-off-by AI 도구가 감지되지 않았을 때만(decision-30) +# +# AI-Tool/AI-Tool-Version/AI-Model은 AI-Agent 한 줄로 합쳤고, Verify-Bypassed는 +# 없앴다 — 이 훅은 --no-verify로 건너뛸 수 없어 탐지할 우회가 없다(decision-18). + +# Tokens-Used/Tool-Calls 귀속 기록(decision-27, GF-129). 커밋 사이에 남는 git-미추적 +# 상태 파일이고, 세션 동안 계속 자란다. 첫 줄은 세션 ID, 그다음 줄부터는 이미 어느 +# 커밋에 귀속된 응답의 키다(attributed_record_load()/attributed_record_save()). 응답 +# 하나가 두 커밋에 들어가지 않게 하려는 것이라, 세션 ID가 다르면(응답 키는 세션 사이에 +# 유일하다는 보장이 없다) 기록을 버리고 새로 시작한다. +# 파일명은 gitformat.conf로 빼지 않는다 - 컨슈머가 오버라이드할 이유가 없는 순수 +# 내부 구현 세부사항이라 트레일러 키 이름들과 성격이 다르다. +ATTRIBUTED_RECORD = os.path.join(GIT_DIR, ".gitformat-token-attributed") +# GF-139까지 쓰던 델타 커서("<세션 ID> <줄 수>"). 의미가 델타에서 커밋당 귀속으로 +# 바뀌어(decision-27) 더는 읽지 않는다. 남겨 두면 "아직 쓰이는 상태 파일"로 오해받으니 +# 측정에 성공할 때 지운다 - 읽지 않으므로 지우는 시점은 값에 영향이 없다. +LEGACY_CURSOR = os.path.join(GIT_DIR, ".gitformat-token-cursor") + + +def local_get(key): + return subprocess.run( + ["git", "config", "--get", key], + capture_output=True, + encoding="utf-8", + check=False, + ).stdout.rstrip("\n") + + +# 메시지에 이미 이 키의 줄이 있는가(decision-19의 키 단위 중복 판정, AC #2). +# +# 메시지 원문을 줄 단위로 읽어 "<키>:"로 시작하는 줄이 하나라도 있으면 그 키를 붙이지 +# 않는다. 값은 보지 않는다 — 사람이나 에이전트가 직접 쓴 값을 훅이 덮어쓰거나 옆에 또 +# 붙이지 않는다(Co-Authored-By: <다른 값>이 있으면 그대로 둔다, AC #4). +# +# `git interpret-trailers --parse`를 쓰지 않는 이유: 그 명령은 메시지 **맨 끝의 연속된 +# 트레일러 블록만** 본다. 빈 줄로 분리된 앞 문단의 Task-Id를 못 봐 두 번째 Task-Id를 +# 붙였던 것이 GF-128 이전 중복의 원인이었다(AC #3). 콜론까지 비교하므로 AI-Tool이 +# AI-Tool-Version을 가리는 접두어 오매치(GF-33)도 없다. +# +# 키는 대소문자를 구분하지 않는다. git이 트레일러 키를 그렇게 다루고, 실제로 +# Co-authored-by(GitHub 웹 UI)와 Co-Authored-By가 섞여 쓰인다 — 대소문자만 다른 키를 +# 다른 키로 보면 같은 트레일러가 두 줄이 된다. +def message_has_key(raw_lines, key): + prefix = f"{key.lower()}:" + return any(line.lower().startswith(prefix) for line in raw_lines) + + +def raw_message_lines(): + # 메시지는 유효하지 않은 UTF-8 바이트를 포함할 수 있다(GF-80). 여기서는 키를 찾기만 + # 하고 파일은 interpret-trailers가 바이트 그대로 다시 쓰므로 replace로 충분하다. + return Path(MSG_FILE).read_bytes().decode("utf-8", errors="replace").split("\n") + + +# Task-Id(decision-4): enforce_task_id_branch()가 이미 브랜치명의 패턴을 강제했으므로 +# (없으면 예외 브랜치가 아닌 한 커밋 자체가 거부됨) 같은 패턴을 다시 찾아 값만 만든다. +def trailer_task_id(): + task_prefix, branch = task_prefix_and_branch() + match = find_task_id(task_prefix, branch) + if match is None: + return "" + return f"{task_prefix}-{match.group(2)}" + + +# sh 판은 $PWD를 썼다. os.getcwd()는 심볼릭 링크를 해석한 물리 경로를 돌려주므로 +# (/var -> /private/var 같은 환경) 그대로 쓰면 Claude Code가 실제로 만든 세션 +# 디렉터리 슬러그와 어긋난다. POSIX 셸이 시작할 때 하는 것과 동일하게, 환경변수 +# PWD가 현재 디렉터리를 가리키는 절대경로일 때만 그 값을 쓴다. +def shell_pwd(): + pwd = os.environ.get("PWD", "") + if pwd.startswith("/"): + try: + if os.path.samefile(pwd, "."): + return pwd + except OSError: + pass + return os.getcwd() + + +# Claude Code가 ~/.claude/projects/ 아래 세션 디렉터리를 만들 때 쓰는 실제 규칙은 +# "영숫자가 아닌 모든 문자를 하이픈으로 치환"이다(GF-98 - 슬래시만 치환하면 밑줄/ +# 점이 든 경로에서 트랜스크립트를 영영 못 찾는다). +# +# 여기는 이 저장소에서 외부 계약에 의존하는 유일한 지점이다(GF-119). 이 슬러그 +# 규칙과 트랜스크립트 경로 레이아웃은 Claude Code의 문서화되지 않은 내부 구현이라 +# 우리가 지킬 수 있는 약속이 아니다 - 상대가 규칙을 바꾸면 이 함수는 예외 없이 +# "없는 경로"를 반환하고, 측정은 조용히 어긋난다. 그래서 실패를 숨기지 않는 쪽으로 +# 설계했다: Tokens-Used/Tool-Calls는 unavailable (transcript-not-found)로 사유를 +# 남기고, AI-Agent의 모델 자리는 model-unavailable이 된다 - 커밋 footer만 봐도 +# 결합이 깨진 걸 알 수 있다. 진단 절차는 doc-6 "한계"에 적어뒀다. +def claude_transcript_path(): + slug = re.sub(r"[^A-Za-z0-9]", "-", shell_pwd()) + session_id = os.environ.get("CLAUDE_CODE_SESSION_ID", "") + home = os.environ.get("HOME", "") + return os.path.join(home, ".claude", "projects", slug, f"{session_id}.jsonl") + + +# 트랜스크립트 마지막 200줄에서 가장 나중의 assistant 턴 message.model을 고른다. +# 한 줄이라도 JSON 파싱에 실패하면 거기서 멈추되 그때까지 찾은 값은 유지한다 — +# 스트리밍 파서가 오류를 만나기 전까지의 출력을 그대로 남기던 동작과 같다. +def read_transcript_model(path): + with open(path, "rb") as f: + data = f.read().decode("utf-8", errors="replace") + lines = data.split("\n") + if lines and lines[-1] == "": + lines.pop() + + model = "" + for line in lines[-200:]: + if not line.strip(): + continue + try: + entry = json.loads(line) + except ValueError: + break + if not isinstance(entry, dict) or entry.get("type") != "assistant": + continue + message = entry.get("message") + if isinstance(message, dict) and message.get("model"): + model = message["model"] + return model + + +# AI-Agent(decision-19): AI-Tool/AI-Tool-Version/AI-Model을 `<도구>/<버전> (<모델>)` +# 한 줄로 합친다. 키가 하나라 AI-Tool이 AI-Tool-Version을 가리던 접두어 오매치(GF-33)가 +# 구조적으로 사라진다. +# +# 구성요소를 못 구해도 줄은 남긴다(AC #6) — 버전은 version-unavailable, 모델은 +# model-unavailable. 조용한 생략은 "AI가 관여하지 않았다"로 읽혀 잘못된 귀속이 된다. +# +# - 도구·버전: AI_AGENT의 첫째·둘째 필드(밑줄 구분). 버전의 하이픈은 점으로 바꾼다 +# (2-1-227 → 2.1.227). 구분자가 없는데 둘째 필드를 뽑으면 원문 전체가 돌아와 버전이 +# 도구 이름과 같아지므로(GF-33) 밑줄이 있을 때만 버전을 채운다. +# - 모델: Claude Code는 세션 트랜스크립트(~/.claude/projects//.jsonl)의 +# message.model이다 — Anthropic API 응답을 애플리케이션이 그대로 기록한 값이라 +# 자가신고가 아니다. 그 외 도구는 enforce_ai_model_gate()가 이미 검증한 +# gitformat.aiModel을 쓴다. CLAUDE_CODE_SESSION_ID는 트랜스크립트 경로를 찾는 데만 +# 쓰고 값 자체를 footer에 남기지 않는다(decision-5 amendment). +def trailer_ai_agent(tool): + ai_agent = os.environ.get("AI_AGENT", "") + version = ai_agent.split("_")[1].replace("-", ".") if "_" in ai_agent else "" + + model = "" + if tool == conf_get("gitformat.aiToolClaudeCode"): + if os.environ.get("CLAUDE_CODE_SESSION_ID"): + # 트랜스크립트 조회는 어떤 이유로 실패하든(파일 없음/포맷 변경/권한) 커밋을 + # 막지 않고 모델 자리만 model-unavailable로 두는 의도된 fail-open이다 + # (decision-5). 이 파일에서 예외를 삼키는 곳은 여기와 토큰 측정뿐이다 — + # 나머지 경로는 실패를 조용히 흘려보내지 않는다(GF-76). + try: + model = read_transcript_model(claude_transcript_path()) + except (OSError, ValueError, TypeError): + model = "" + else: + model = local_get("gitformat.aiModel") + + return f"{tool}/{version or 'version-unavailable'} ({model or 'model-unavailable'})" + + +# in에 합치는 입력측 usage 필드. 캐시 토큰도 모델이 실제로 읽어 들인 입력이라 빼면 +# 값이 무의미해진다(doc-16 실측: 입력측이 전체의 거의 전부다). out은 output_tokens +# 하나다(decision-27, GF-129). +INPUT_USAGE_FIELDS = ( + "input_tokens", + "cache_creation_input_tokens", + "cache_read_input_tokens", +) +OUTPUT_USAGE_FIELD = "output_tokens" + + +# JSONL 트랜스크립트 하나를 읽어 (줄 키, 파싱된 줄) 목록으로 돌려준다. 줄 키는 +# "<트랜스크립트 디렉터리 기준 파일 경로>:<1부터 센 줄 번호>"로, requestId/message.id가 +# 없는 줄을 응답으로 삼을 때 쓴다(group_responses()). 트랜스크립트는 뒤에 덧붙기만 +# 하므로 같은 줄은 다음 커밋에서도 같은 키를 얻는다. +# +# 줄 하나라도 JSON 파싱에 실패하면 ValueError를 그대로 올려 배치 전체를 실패시킨다 — +# 불량 줄만 건너뛰면 "일부만 집계된 값"이 정상값인 척 기록되므로(GF-111/GF-112). +# 파일을 못 읽으면 OSError가 올라간다. 둘 다 호출자가 사유 슬러그로 바꾼다. +def read_transcript_entries(path, key_prefix): + with open(path, "rb") as f: + data = f.read().decode("utf-8", errors="replace") + lines = data.split("\n") + if lines and lines[-1] == "": + lines.pop() + entries = [] + for number, line in enumerate(lines, start=1): + if not line.strip(): + continue + entries.append((f"{key_prefix}:{number}", json.loads(line))) + return entries + + +# assistant 줄을 응답 단위로 묶는다. 트랜스크립트는 API 응답 하나를 content 블록마다 +# 한 줄씩 기록하고 각 줄이 같은 message.usage를 반복해서 들고 있다 - 줄마다 더하면 +# 2~3배 부풀려진다(GF-139 실측: assistant 215줄이 응답 109개). 같은 응답의 줄은 +# 최상위 requestId와 message.id를 공유하므로 " "를 응답 키로 +# 삼는다. 이 키는 그대로 귀속 기록 파일의 한 줄이 되므로, 공백·개행이 든 id는(실제로는 +# 본 적 없다) 기록을 깨뜨리지 않도록 id가 없는 것과 같이 취급한다. +# +# - usage는 응답의 첫 줄 값만 쓴다 - 나머지 줄은 같은 값의 반복이다. +# - tool_use 블록은 모든 줄에서 모은다 - 한 응답의 도구 호출이 여러 줄에 흩어져 있어 +# 첫 줄만 보면 귀속 근거를 놓친다. +# - id가 하나라도 없는 줄은 같은 응답인지 판단할 근거가 없으므로 줄 키로 각자 하나의 +# 응답이 된다(GF-139 규칙 유지). +# +# 키가 같으면 파일이 달라도(본 트랜스크립트와 서브에이전트 트랜스크립트) 같은 응답으로 +# 본다 - 같은 API 응답을 두 번 더하지 않는다는 원칙이 파일 경계보다 우선이다. +def group_responses(entries): + responses = {} + for line_key, entry in entries: + if not isinstance(entry, dict) or entry.get("type") != "assistant": + continue + message = entry.get("message") + if not isinstance(message, dict): + continue + request_id = entry.get("requestId") + message_id = message.get("id") + if ( + isinstance(request_id, str) + and re.fullmatch(r"\S+", request_id) + and isinstance(message_id, str) + and re.fullmatch(r"\S+", message_id) + ): + response_key = f"{request_id} {message_id}" + else: + response_key = line_key + response = responses.get(response_key) + if response is None: + response = {"usage": message.get("usage"), "tool_uses": []} + responses[response_key] = response + content = message.get("content") + if isinstance(content, list): + response["tool_uses"].extend( + block + for block in content + if isinstance(block, dict) and block.get("type") == "tool_use" + ) + return responses + + +# 귀속 대상 경로 = 이 커밋이 바꿀 파일 목록(저장소 상대경로). +# +# 이 훅은 커밋 객체가 만들어지기 **전에** 돌므로 HEAD 커밋의 파일 목록(GF-128 전 +# post-commit이 쓰던 diff-tree HEAD)이 아니라, 커밋될 인덱스와 HEAD의 차이를 본다. +# +# 인덱스는 반드시 git이 넘겨준 것을 써야 한다. `git commit -a`와 `git commit <경로>`는 +# 커밋할 내용을 임시 인덱스에 만들고 GIT_INDEX_FILE로 그 위치를 알려준다(git 2.54.0 +# 실측: -a는 .git/index.lock, <경로>는 .git/next-index-.lock). 여기서 그 +# 환경변수를 지우거나 .git/index를 직접 지정하면 -a로 커밋하는 수정 파일, <경로>로 +# 고른 파일이 빠지고 스테이징만 돼 있고 커밋되지 않을 파일이 끼어든다. 자식 git은 +# 환경을 그대로 물려받으므로 아무것도 하지 않으면 맞는 인덱스를 읽는다. +# +# 비교 기준은 HEAD다. 첫 커밋(HEAD 없음)이면 빈 트리와 비교한다 — 빈 트리 해시는 +# 저장소의 해시 알고리즘(SHA-1/SHA-256)에 따라 다르므로 상수로 박지 않고 git에게 +# 묻는다. +# +# --amend도 HEAD와 비교한다. 즉 대상은 "amend가 원래 커밋 위에 새로 얹는 변경"이다. +# 원래 커밋의 파일을 건드린 응답은 그 커밋이 만들어질 때 이미 귀속 기록에 들어갔으므로 +# 다시 세지 않는다는 doc-16의 규칙과 맞는다. 훅은 --amend 여부를 알 길이 없다 — +# `--amend -m`은 source=message로 와서 일반 커밋과 구분되지 않는다(실측). +# +# diff-index는 plumbing이라 diff.renames 같은 사용자 설정을 읽지 않는다 — 이름 바꾸기가 +# 옛 이름 삭제와 새 이름 추가로 나와 둘 다 대상이 된다(어느 쪽을 건드린 응답이든 이 +# 커밋의 작업이다). -z는 core.quotePath가 비ASCII 경로를 따옴표·이스케이프로 감싸는 +# 것을 막아 원래 경로 그대로 받게 한다. +def commit_target_paths(): + head = subprocess.run( + ["git", "rev-parse", "--verify", "--quiet", "HEAD"], + capture_output=True, + encoding="utf-8", + check=False, + ) + if head.returncode == 0: + base = head.stdout.strip() + else: + base = subprocess.run( + ["git", "hash-object", "-t", "tree", "--stdin"], + input="", + capture_output=True, + encoding="utf-8", + check=True, + ).stdout.strip() + output = subprocess.run( + ["git", "diff-index", "--cached", "--name-only", "-z", base], + stdout=subprocess.PIPE, + encoding="utf-8", + errors="surrogateescape", + check=True, + ).stdout + return {path for path in output.split("\0") if path} + + +# Bash 명령 판정에서 대상 경로 바로 앞에 와도 되는 글자: 공백, 따옴표, 백틱, 셸 +# 구두점(= ( < > | ; & , :). 경로 바로 뒤에는 경로를 이어 쓰는 글자(영숫자 _ . - /)가 +# 오면 안 된다 - hooks/prepare-commit-msg-old나 a.txt.bak은 다른 파일이다. +BASH_PATH_LEFT_BOUNDARY = "\\s'\"`=(<>|;&,:" +BASH_PATH_RIGHT_FORBIDDEN = "A-Za-z0-9_.\\-/" + + +# 귀속 판정기. 대상 경로와 저장소 최상위 경로를 한 번만 계산해 두고, tool_use 블록 +# 하나가 대상 경로를 실제로 건드렸는지 판정한다(decision-27, GF-129). 판정은 경로 +# 문자열 대조뿐이라 완벽하지 않다 - 경로를 쓰지 않고 파일을 바꾸는 명령(글롭, 변수, +# cd 후 파일명만 쓰기)은 놓치고, 경로를 문자열로 담기만 한 명령(git add, 커밋 +# 메시지에 경로 언급)은 귀속한다. 놓치는 쪽을 택한 것은 오귀속보다 과소 보고가 +# 낫다는 판단이다(doc-16 "판정 규칙"). +class AttributionMatcher: + def __init__(self, targets): + self.targets = targets + self.top = subprocess.run( + ["git", "rev-parse", "--show-toplevel"], + stdout=subprocess.PIPE, + encoding="utf-8", + errors="surrogateescape", + check=True, + ).stdout.rstrip("\n") + # macOS의 /tmp → /private/tmp처럼 같은 디렉터리가 두 이름을 가지면, 도구가 + # 기록한 절대경로와 git이 알려준 최상위 경로가 서로 다른 이름을 쓸 수 있다. + # 비교는 양쪽 모두 심볼릭 링크를 푼 물리 경로로 한다. + self.real_top = os.path.realpath(self.top) + self.bash_patterns = [self._bash_pattern(target) for target in sorted(targets)] + + # Bash 명령 문자열에서 대상 경로 하나를 찾는 정규식. + # + # - 하위 디렉터리 안의 대상(hooks/gitformat.conf): 저장소 상대경로가 경계로 구분돼 + # 들어 있으면 귀속한다. 왼쪽 경계에 '/'도 허용한다 - 그래야 절대경로 + # (/…/repo/hooks/gitformat.conf)나 ../repo/hooks/gitformat.conf처럼 상대경로를 + # 꼬리로 품은 표기도 잡힌다. myhooks/gitformat.conf는 왼쪽이 영문자라 걸리지 + # 않는다. 파일명만 든 명령은 상대경로가 없으니 귀속하지 않는다 - README.md 같은 + # 흔한 이름이 무관한 응답을 끌어오는 것을 막는다. + # - 저장소 최상위의 대상(a.txt): 상대경로가 곧 파일명이다. 그래서 왼쪽 경계에 + # '/'를 허용하지 않는다 - 허용하면 sub/a.txt 같은 다른 파일의 파일명 매칭이 + # 된다. 대신 ./a.txt와 저장소 최상위 절대경로(논리·물리 둘 다)를 붙인 표기는 + # 명시적으로 받는다. + def _bash_pattern(self, target): + right = rf"(?![{BASH_PATH_RIGHT_FORBIDDEN}])" + if "/" in target: + left = rf"(?/subagents/*.jsonl)를 훑어 아직 귀속되지 않은 응답 중 +# 이 커밋이 바꿀 파일을 건드린 것을 고른다 - "a 수정 → b 먼저 커밋 → a 커밋" 순서에서도 +# a를 고친 응답이 뒤 커밋에 들어간다. 이 커밋의 파일을 건드리지 않은 응답(무관한 파일 +# 탐색, 도구 호출 없는 대화)은 어느 커밋에도 들어가지 않는다 - 커밋당 토큰량이라는 +# 정의에서 의도된 결과다. Read도 file_path를 가지므로 커밋한 파일을 읽은 응답은 +# 귀속된다. # -# **이 마커는 더 이상 아무것도 gate하지 않는다.** GF-126까지는 "언어별 lint를 전부 -# 통과했다"는 뜻이었지만 GF-135에서 그 lint를 지웠으므로(decision-23) 지금 남은 뜻은 -# "prepare-commit-msg가 돌았다"뿐이다. 그래서 조건 없이 쓴다 — 통과/실패를 판정할 -# 대상이 없다. +# 돌려주는 값은 dict다. 성공하면 {"ok": True, "in", "out", "calls", "session_id", +# "keys"(지금까지의 기록 + 이번에 귀속한 응답 키), "new"(이번에 귀속한 응답 수)}이고, +# 실패하면 {"ok": False, "reason": <실패 지점을 가리키는 짧은 슬러그>}다. 기록 파일은 +# 여기서 쓰지 않는다 - 트레일러가 메시지 파일에 실제로 들어간 뒤에 쓴다 +# (insert_trailers()). 실패하면 기록을 갱신하지 않으므로 다음 커밋이 같은 응답들을 +# 다시 판정할 수 있다. +def measure_claude_code_token_usage(): + session_id = os.environ.get("CLAUDE_CODE_SESSION_ID", "") + if not session_id: + return {"ok": False, "reason": "no-session-id"} + + transcript = claude_transcript_path() + if not os.path.isfile(transcript): + return {"ok": False, "reason": "transcript-not-found"} + + # 서브에이전트 트랜스크립트는 Claude Code가 본 트랜스크립트 옆 + # <세션 ID>/subagents/에 같은 JSONL 형식으로 남긴다. 서브에이전트를 쓰지 않은 + # 세션에는 이 디렉터리가 없으므로 없는 건 정상이다. 정렬은 줄 키와 응답 순서를 + # 실행마다 같게 하려는 것이다. 줄 키의 파일 부분은 트랜스크립트 디렉터리 기준 경로라 + # 본 트랜스크립트와 서브에이전트 파일이 서로 겹치지 않는다. + sources = [(transcript, os.path.basename(transcript))] + subagent_dir = os.path.join(os.path.dirname(transcript), session_id, "subagents") + if os.path.isdir(subagent_dir): + try: + names = sorted(os.listdir(subagent_dir)) + except OSError: + return {"ok": False, "reason": "transcript-unreadable"} + for name in names: + path = os.path.join(subagent_dir, name) + if name.endswith(".jsonl") and os.path.isfile(path): + sources.append((path, f"{session_id}/subagents/{name}")) + + # 한 파일, 한 줄이라도 깨지면 배치 전체가 실패한다 - 일부만 집계한 값을 정상값처럼 + # 남기지 않는다(GF-112). 구간이 아니라 세션 전체를 매번 읽으므로, 한 번 깨진 줄이 + # 생기면 그 세션의 이후 커밋은 계속 transcript-parse-failed가 된다 - 조용히 틀린 + # 값을 남기는 것보다 사유가 계속 보이는 쪽을 택했다. + entries = [] + for path, key_prefix in sources: + try: + entries += read_transcript_entries(path, key_prefix) + except OSError: + return {"ok": False, "reason": "transcript-unreadable"} + except (ValueError, TypeError): + return {"ok": False, "reason": "transcript-parse-failed"} + + responses = group_responses(entries) + matcher = AttributionMatcher(commit_target_paths()) + recorded = attributed_record_load(session_id) + already_attributed = set(recorded) + + input_sum = 0 + output_sum = 0 + calls = 0 + new_keys = [] + for response_key, response in responses.items(): + if response_key in already_attributed: + continue + # Tool-Calls는 귀속된 응답의 tool_use 전부가 아니라 대상 경로를 실제로 건드린 + # 블록만 센다 - 같은 응답 안의 ls·grep 같은 호출은 이 커밋의 작업이 아니다. + touching = sum(1 for block in response["tool_uses"] if matcher.touches(block)) + if not touching: + continue + new_keys.append(response_key) + calls += touching + usage = response["usage"] + if isinstance(usage, dict): + for field in INPUT_USAGE_FIELDS: + value = usage.get(field) + input_sum += value if isinstance(value, int) else 0 + value = usage.get(OUTPUT_USAGE_FIELD) + output_sum += value if isinstance(value, int) else 0 + + return { + "ok": True, + "in": input_sum, + "out": output_sum, + "calls": calls, + "session_id": session_id, + "keys": recorded + new_keys, + "new": len(new_keys), + } + + +# Tokens-Used/Tool-Calls 값(GF-97, decision-27). 사람 커밋(도구 미감지)은 호출되지 +# 않는다. claude-code는 measure_claude_code_token_usage()의 실측 결과를 쓴다. 그 외 +# 감지된 AI 도구는 아직 서버발급 usage를 읽을 로컬 채널이 없으므로(decision-15) 항상 +# "unavailable (no-usage-channel)"이다. # -# 그런데도 계속 써야 하는 이유는 post-commit이다. post-commit은 이 마커의 **부재**를 -# --no-verify 우회의 증거로 읽어 Verify-Bypassed: true를 붙인다(decision-3, GF-31). -# 아무도 마커를 쓰지 않게 되면 아직 살아 있는 post-commit이 **모든 커밋에** 그 트레일러를 -# 붙인다 — 이 저장소는 자기 훅으로 커밋하므로 잘못된 footer가 실제 이력에 남는다(GF-126 -# 실측). 형식과 위치는 기존 계약 그대로 — $GIT_DIR/에 -# " " 한 줄이다. 바꾸면 post-commit의 판정이 깨진다. +# 측정에 성공한 값은 두 가지로 나뉜다(decision-27). 귀속된 응답이 있으면 +# "in=<입력> out=<출력>"이고, 그 usage가 정말 0이어도 사유 없이 그대로 남긴다 - +# 측정해서 나온 0은 "unavailable"과 구분되는 정당한 값이다. 귀속된 응답이 하나도 +# 없으면 같은 0이라도 "이 커밋 파일을 건드린 응답을 못 찾았다"는 뜻이므로 +# (no-attributed-turn)을 붙여 진짜 측정값 0과 footer만 보고 구별되게 한다. # -# 이 훅은 --no-verify로도 건너뛸 수 없으므로(doc-15) 마커는 항상 써지고, 따라서 -# **Verify-Bypassed는 사실상 도달 불가능하다.** 남는 경로는 재생 커밋뿐이다 — 맨 위 -# 면제로 먼저 빠져나가 마커가 없으니 post-commit이 그 트레일러를 여전히 큐에 넣는다. -# 다만 그 경로에서는 amend 자체가 실패해(archive DRAFT-18) 커밋에 남지 않는다(GF-126 -# 실측). 이 시점에는 commit-msg가 아직 살아 있고 --no-verify가 그것을 건너뛰므로 -# '메시지 검증 우회'만 기록 없이 남는다 — 검증을 이 훅으로 옮기는 GF-127이 그 한 -# 태스크짜리 과도기를 닫는다. -def write_verified_marker(): - with open(MARKER, "w", encoding="utf-8") as f: - f.write(f"{int(time.time())} {os.getpid()}\n") +# (Tokens-Used 값, Tool-Calls 값, 측정 결과)를 돌려준다. 측정 결과는 귀속 기록을 쓸지 +# 정하는 데 쓰이며, 측정하지 않았으면 None이다. +def trailer_token_values(tool): + if tool != conf_get("gitformat.aiToolClaudeCode"): + no_channel = "unavailable (no-usage-channel)" + return no_channel, no_channel, None + measured = measure_claude_code_token_usage() + if not measured["ok"]: + reason = f"unavailable ({measured['reason']})" + return reason, reason, measured + if measured["new"]: + return ( + f"in={measured['in']} out={measured['out']}", + str(measured["calls"]), + measured, + ) + return "in=0 out=0 (no-attributed-turn)", "0 (no-attributed-turn)", measured -# 실행 순서(decision-18): 재생·병합 커밋 면제 → 에디터 경로 거부 → 검증마커 기록. + +# 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`가 "이름 <메일> "를 주므로 뒤의 시각 두 필드를 뗀다. +# 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가 둘 다 이미 있으면 돌지 않는다. +# `--amend --no-edit`처럼 앞 커밋의 트레일러를 그대로 들고 오는 경우, 측정만 하고 +# 트레일러를 못 쓴 채 응답들을 귀속 기록에 넣으면 그 토큰이 어디에도 남지 않는다. +# +# 쓰기는 `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) + + measured = None + 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"), + ) + tokens_key = conf_get("gitformat.trailer.tokensUsed") + calls_key = conf_get("gitformat.trailer.toolCalls") + if not ( + message_has_key(raw_lines, tokens_key) + and message_has_key(raw_lines, calls_key) + ): + tokens_value, calls_value, measured = trailer_token_values(tool) + add(tokens_key, lambda: tokens_value) + add(calls_key, lambda: calls_value) + + 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()}", + ) + + # 귀속 기록은 트레일러가 메시지 파일에 들어간 **뒤에** 쓴다 — 쓰기에 실패해 커밋이 + # 막혔는데 응답만 "귀속됨"으로 남는 일이 없게 한다. 측정에 실패했으면 쓰지 않는다. + # + # 알려진 한계: 이 훅이 끝난 뒤에도 git이 커밋을 포기할 수 있다. git 2.54.0 실측으로 + # 확인한 경로는 (1) 서명 실패(gpg/ssh 서명기가 실패하거나 사람이 중단하면 "failed to + # write commit object", exit 128) (2) `-e -m`이나 에디터로 여는 --amend에서 사람이 + # 메시지를 비워 "Aborting commit due to empty commit message"로 끝나는 경우다. + # 그러면 이번에 귀속한 응답은 기록에만 남고 어느 커밋에도 들어가지 않는다 — 다시 + # 커밋하면 그 응답은 이미 귀속된 것으로 보여 no-attributed-turn이나 더 작은 값이 + # 된다(과소 보고). 반면 빈 커밋(--allow-empty 없이 바뀐 것이 없을 때)은 이 훅이 + # 돌기 **전에** 거부되므로 해당하지 않는다(실측). 커밋 성립 여부는 커밋 뒤에 도는 + # 훅만 알 수 있는데, 그 훅(post-commit)을 없애는 것이 이 재설계의 목적이라 여기서는 + # 고치지 않는다. 오귀속보다 과소 보고가 낫다는 판단(doc-16)과도 같은 방향의 실패다. + if measured is not None and measured["ok"]: + attributed_record_save(measured["session_id"], measured["keys"]) + try: + os.remove(LEGACY_CURSOR) + except OSError: + pass + + +# 실행 순서(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로 오지만(실측), 앞으로 판단을 더 붙이면서 면제를 뒤로 미루면 사람이 -# 메시지를 고를 수조차 없는 재생 경로가 거부된다. +# 아니라 merge로 오지만(실측), 면제를 뒤로 미루면 사람이 메시지를 고를 수조차 없는 +# 재생 경로(cherry-pick이 옮겨오는 옛 형식 제목, 병합 메시지 등)가 거부된다. +# +# 에디터 거부가 검증보다 앞인 것은 검증할 메시지가 아직 없기 때문이다 — 순서를 +# 바꾸면 에디터 커밋이 "-m을 쓰라"는 안내 대신 형식 오류로 거부돼 원인이 가려진다. # -# 마커 기록이 맨 뒤인 것은 GF-126의 순서를 그대로 둔 것이다 — 그때는 "lint가 전부 -# 통과한 뒤에만 쓴다"는 뜻이 있었고, GF-135로 그 뜻은 없어졌지만 거부된 커밋에 굳이 -# 파일을 남길 이유도 없다. +# 검증은 트레일러 삽입보다 먼저 돈다(GF-127 AC #8) — 사람이 쓴 부분만 검증 대상이다. +# 순서를 바꾸면 훅이 붙인 트레일러가 본문 길이 검증 등을 받고, 사람이 쓴 메시지가 +# 아니라 훅 자신의 출력 때문에 커밋이 거부될 수 있다. 검증에서 거부되면 삽입(과 토큰 +# 측정·귀속 기록)까지 가지 않는다. # -# 파일 맨 위의 amend 가드는 이 순서보다 앞서 돈다 — post-commit의 재진입 차단과 같은 -# 규약이라 그 위치를 유지한다. +# 재생·병합 커밋은 면제로 먼저 빠져나가므로 트레일러도 붙지 않는다(GF-128 AC #8). +# 재생 커밋은 원래 커밋의 트레일러를 그대로 들고 온다. if is_replay_commit(): sys.exit(0) reject_editor_path() -invalidate_stale_marker() -write_verified_marker() +validate_message() +insert_trailers() sys.exit(0) diff --git a/tests/isolated_repo.py b/tests/isolated_repo.py index 95a8ae7..a4884a5 100644 --- a/tests/isolated_repo.py +++ b/tests/isolated_repo.py @@ -42,14 +42,11 @@ # 된다. bats 판은 그 케이스에서만 `env -u`로 지웠지만, 여기서는 기본값으로 지워 # 모든 테스트가 실행 환경과 무관하게 같은 결과를 내게 한다 — AI 경로를 검증하는 # 테스트는 필요한 값을 명시적으로 넘긴다. -# _GITFORMAT_AMEND_GUARD: 값이 새어들어오면 post-commit이 즉시 빠져나가 트레일러가 -# 하나도 붙지 않는다. # GIT_*: 러너가 훅/래퍼 안에서 실행될 때 새어들어오면 임시 저장소가 아니라 이 # 저장소의 인덱스를 건드린다. _STRIPPED_ENV = ( "AI_AGENT", "CLAUDE_CODE_SESSION_ID", - "_GITFORMAT_AMEND_GUARD", "GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE", 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..e7d7dba 100644 --- a/tests/test_config_file_unreadable.py +++ b/tests/test_config_file_unreadable.py @@ -5,9 +5,10 @@ 실행해 exit 0이 아니고 명확한 에러 메시지가 나오는지 본다 — 빈 값으로 진행하면 원인을 알 수 없는 거부가 된다. -대상은 훅 3개와 install.sh다. GF-135에서 checks/가 삭제되며 그 3개 파일의 가드 검증 -케이스도 함께 사라졌다 — 검증할 파일 자체가 없어졌기 때문이지, 가드 정책이 바뀐 게 -아니다. +대상은 훅 prepare-commit-msg와 install.sh다. GF-135에서 checks/가 삭제되며 그 3개 파일의 +가드 검증 케이스도 함께 사라졌고, GF-127에서 commit-msg가, GF-128에서 post-commit이 +삭제되며 그 케이스도 사라졌다 — 검증할 파일 자체가 없어졌기 때문이지, 가드 정책이 바뀐 +게 아니다. 진짜 저장소의 hooks/gitformat.conf는 절대 건드리지 않고, 매 테스트마다 hooks/를 임시 디렉터리에 복사해 그 사본의 conf만 깨뜨린다. @@ -36,25 +37,16 @@ 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이 삭제되며 가드 검증 대상이 이 훅으로 옮겨왔다. - # GF-135에서 언어별 검사가 사라진 뒤에도 이 훅은 gitformat.markerFile을 - # conf에서 읽으므로 가드가 여전히 필요하다. + # 이 훅은 커밋 type 목록·길이 제한·트레일러 키를 conf에서 읽으므로 가드가 + # 여전히 필요하다. msg_file = self.write("msgfile", "[feat] test\n") self.assertConfGuardFires( self.python(self.hooks_copy / "prepare-commit-msg", msg_file) ) - def test_post_commit이_멈춘다(self): - """post-commit(python): conf가 깨지면 명확한 에러로 즉시 멈춘다""" - self.assertConfGuardFires(self.python(self.hooks_copy / "post-commit")) - def test_install_sh가_멈춘다(self): """install.sh: conf가 깨지면 명확한 에러로 즉시 멈춘다""" # install.sh는 자기 옆의 hooks/를 읽으므로 클론 구조를 그대로 흉내낸다. diff --git a/tests/test_config_keys_match_hooks.py b/tests/test_config_keys_match_hooks.py index 21b6d18..189c788 100644 --- a/tests/test_config_keys_match_hooks.py +++ b/tests/test_config_keys_match_hooks.py @@ -18,12 +18,8 @@ GITMESSAGE = GITFORMAT_ROOT / ".gitmessage" # gitformat.conf를 키 단위로 읽는 파일들. install.sh는 `--list`로 읽기 가능 여부만 -# 보고 개별 키는 읽지 않으므로 여기 없다. -CONF_READERS = ( - HOOKS_DIR / "prepare-commit-msg", - HOOKS_DIR / "commit-msg", - HOOKS_DIR / "post-commit", -) +# 보고 개별 키는 읽지 않으므로 여기 없다. post-commit은 GF-128에서 삭제됐다. +CONF_READERS = (HOOKS_DIR / "prepare-commit-msg",) # 훅이 실제로 쓰는 conf 읽기 형태를 그대로 뽑는다: conf_get("...") / # conf_get_all("...") 헬퍼 호출이다. GF-135까지는 `--file CONF ... --get`를 인라인으로 @@ -87,7 +83,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..94a3731 100644 --- a/tests/test_install_script.py +++ b/tests/test_install_script.py @@ -89,8 +89,11 @@ 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·GF-128에서 삭제된 훅의 링크는 남지 않는다 — 이 저장소의 실제 + # template/hooks/에는 예전 --global 실행이 만든 링크가 남아 있을 수 있어, + # sync_template()의 정리가 실제로 돌았다는 증거가 된다. + self.assertFalse((TEMPLATE_DIR / "hooks" / "commit-msg").is_symlink()) + self.assertFalse((TEMPLATE_DIR / "hooks" / "post-commit").is_symlink()) # 실제 전역 git 설정이 아니라 가짜 HOME 쪽에 반영됐는지를 직접 확인한다. self.assertEqual( @@ -113,7 +116,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" / "readme.md").is_symlink()) # hooks/에서 파일 하나를 지운다 (GF-86의 hooks/pre-push 삭제 상황 재현). (root / "hooks" / "prepare-commit-msg").unlink() @@ -126,8 +129,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" / "readme.md").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..124caea 100644 --- a/tests/test_message_format_enforced.py +++ b/tests/test_message_format_enforced.py @@ -1,7 +1,11 @@ -"""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 제목 예외)은 맨 아래에 있다. 거부 시 마커가 +남지 않는지 보던 케이스는 GF-128에서 검증마커 자체가 사라지며 함께 없어졌다. decision-8: 표준 인증이 아니라 실제 버그 이력(GF-30, GF-34, GF-35)에 근거한 실용적 테스트. GF-82에서 서브젝트가 [type][subsystem] 프리픽스로 바뀌었고, GF-83에서 제목 @@ -62,13 +66,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): @@ -168,9 +176,111 @@ 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) ─────────── + + 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바로 이어진 본문')) + + # ── 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__": 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..573b762 100644 --- a/tests/test_python3_missing.py +++ b/tests/test_python3_missing.py @@ -1,9 +1,11 @@ """훅이 실행되는 시점의 PATH에 python3이 없을 때의 동작을 본다(구 robustness-python-path.bats). GF-113, decision-16: sh 시절에는 없던 실패 모드라 "sh/Python 동일성 재검증"이 아니라 -신규 동작 검증이다. 훅마다 결과가 비대칭이라는 README의 서술(커밋 객체를 만들기 전에 -도는 훅은 커밋 차단, post-commit은 트레일러 조용한 누락)이 실제로 그런지 확인하는 것이 -목적이다. +신규 동작 검증이다. 커밋 객체를 만들기 전에 도는 훅은 python3이 없으면 커밋을 막는다는 +것을 확인한다. GF-128 전에는 post-commit이 실패해 커밋은 남고 트레일러만 조용히 빠지는 +비대칭 경로도 여기서 고정했지만, post-commit이 삭제되고 트레일러 삽입이 +prepare-commit-msg로 옮겨와 그 경로가 없어졌다 — 이제 python3 부재는 언제나 드러나는 +실패다(decision-18 "대가와 한계"). python3은 **자식 프로세스의 환경변수에서만** 지운다 — 러너 자신은 계속 자기 python3로 돌아간다(GF-124 AC #7). bats 판은 PATH를 셸 변수로 다뤄 같은 효과를 냈지만, 러너와 @@ -22,25 +24,13 @@ def setUp(self): self.shadow_path = self.path_without("python3") self.no_python = {"PATH": str(self.shadow_path)} - def use_hooks_without(self, *hooks): - """특정 훅만 남긴 hooks/ 사본으로 core.hooksPath를 돌린다. - - git은 앞선 훅이 실패하면 뒤의 훅을 아예 실행하지 않으므로, 검증하려는 훅보다 - 앞서 도는 훅을 전부 지워야 그 훅이 실제 검증 대상이 된다. 실행 순서는 - prepare-commit-msg → commit-msg → post-commit이다 — GF-126에서 pre-commit이 - 삭제돼 맨 앞이 prepare-commit-msg가 됐다. - """ - trimmed = self.copy_hooks(*hooks) - self.git_ok("config", "core.hooksPath", trimmed) - return trimmed - def baseline_commit(self): self.write("base.txt", "base\n") self.git_ok("add", "base.txt") 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,45 +51,6 @@ 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") - - # 같은 설정에서 python3이 보이면 트레일러가 붙는다는 것부터 확인한다 — 이게 - # 없으면 아래 누락이 python3 부재 때문인지 훅 연결이 애초에 안 된 탓인지 - # 구분할 수 없다. - self.baseline_commit() - self.assertTrailerCount(self.head_message(), "Signed-off-by:", 1) - - self.write("a.txt", "hi\n") - self.git_ok("add", "a.txt") - result = self.commit("[feat] trailerless commit", env=self.no_python) - - # --no-verify도 아니고 에러도 아니다 — 커밋은 평범하게 성공한 것처럼 보인다. - self.assertAccepted(result) - self.assertEqual(2, self.commit_count()) - self.assertEqual("[feat] trailerless commit", self.head_subject()) - - message = self.head_message() - self.assertTrailerKeyAbsent(message, "Signed-off-by") - self.assertTrailerKeyAbsent(message, "Hooks-Commit") - self.assertTrailerKeyAbsent(message, "Task-Id") - if __name__ == "__main__": unittest.main() diff --git a/tests/test_replay_commits_untouched.py b/tests/test_replay_commits_untouched.py index f9451e4..2312f9d 100644 --- a/tests/test_replay_commits_untouched.py +++ b/tests/test_replay_commits_untouched.py @@ -5,11 +5,10 @@ DRAFT-18)를 원인 단계에서 없애는 장치이므로, 재생 경로에서 훅이 조용히 통과하는지를 고정해 둔다. -**남은 구 훅은 사본에서 지운 뒤 검증한다.** 구 post-commit이 cherry-pick 중에 내는 -DRAFT-18 트레이스백이 아직 그대로 살아 있어, 그걸 같이 태우면 이 파일이 새 훅을 검증하는 -게 아니라 아직 고치지 않은 구 훅을 검증하게 된다. GF-126에서 pre-commit이 삭제돼 지울 -대상이 2개로 줄었고, commit-msg/post-commit도 삭제되는 GF-127~128 이후에는 이 사본 -구성이 곧 실제 구성이 된다. +GF-128 전에는 구 post-commit을 사본에서 지운 뒤 검증했다 — 그 훅이 cherry-pick 중에 +내는 DRAFT-18 트레이스백이 남아 있었기 때문이다. GF-128에서 post-commit이 삭제되고 +트레일러 삽입이 이 훅으로 옮겨와, 이제는 실제 hooks/를 그대로 연결해 검증한다. 재생 +커밋에 트레일러가 붙지 않는 것도 같은 면제 덕분이다(GF-128 AC #8). source 값과 진행 상태 파일은 git 2.54.0에서 실측했다(2026-09-26). cherry-pick과 rebase 재생은 `source=message`라 CHERRY_PICK_HEAD 같은 진행 상태 파일로만 잡히고, @@ -18,7 +17,7 @@ import unittest -from isolated_repo import IsolatedRepoTestCase +from isolated_repo import HOOKS_DIR, IsolatedRepoTestCase # AC #1이 규정하는 진행 상태 파일. 훅은 존재 여부만 보므로 내용은 무엇이든 된다. PROGRESS_FILES = ("MERGE_HEAD", "CHERRY_PICK_HEAD", "REBASE_HEAD", "REVERT_HEAD") @@ -26,9 +25,8 @@ class ReplayCommitsUntouchedTest(IsolatedRepoTestCase): def setUp(self): - self.hooks_copy = self.copy_hooks("commit-msg", "post-commit") - self.repo = self.make_repo(hooks_path=self.hooks_copy) - self.hook = self.hooks_copy / "prepare-commit-msg" + super().setUp() + self.hook = HOOKS_DIR / "prepare-commit-msg" self.write("base.txt", "base\n") self.git_ok("add", "base.txt") self.commit_ok("[feat] 기준 커밋") @@ -66,7 +64,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 +83,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 +103,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,12 +113,48 @@ 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() self.assertEqual(2, len(parents), f"병합 커밋이 아니다: {parents}") + def test_rebase_재생은_트레일러를_붙이지_않고_실패하지도_않는다(self): + """[GF-128 AC #8] 실제 git rebase로 커밋을 재생해도 트레일러가 추가되지 않고 훅이 실패하지 않는다""" + base_branch = self.current_branch() + # 재생할 커밋은 훅 없이 만든다 — 훅을 거친 커밋은 이미 트레일러 키를 다 갖고 + # 있어서, 면제가 깨져도 키 단위 중복 판정 때문에 메시지가 그대로일 수 있다. + # 트레일러가 하나도 없는 커밋이라야 "아무것도 붙이지 않았다"가 증명된다. + no_hooks = self.temp_dir(prefix="gitformat-nohooks-") + self.git_ok("checkout", "-q", "-b", "GF-5-rebase") + self.git_ok("config", "core.hooksPath", no_hooks) + for name in ("one", "two"): + self.write(f"{name}.txt", f"{name}\n") + self.git_ok("add", f"{name}.txt") + self.commit_ok(f"[feat] 재생할 커밋 {name}") + self.git_ok("config", "core.hooksPath", HOOKS_DIR) + + # 기준 브랜치를 앞으로 보내 rebase가 fast-forward가 아니라 실제 재생이 되게 한다. + self.git_ok("checkout", "-q", base_branch) + self.write("main.txt", "main\n") + self.git_ok("add", "main.txt") + self.commit_ok("[feat] 기준 브랜치 전진") + self.git_ok("checkout", "-q", "GF-5-rebase") + + self.assertHookSilent(self.git("rebase", base_branch)) + + self.assertEqual(4, self.commit_count()) + messages = self.git_ok("log", "-2", "--format=%B%x00").stdout.split("\0") + messages = [m.strip() for m in messages if m.strip()] + self.assertEqual( + ["[feat] 재생할 커밋 two", "[feat] 재생할 커밋 one"], messages + ) + # 재생된 커밋이 정말 새로 만들어졌는지(기준 브랜치 위에 올라갔는지) 확인한다. + self.assertEqual( + self.git_ok("rev-parse", base_branch).stdout.strip(), + self.git_ok("rev-parse", "HEAD~2").stdout.strip(), + ) + # ── 훅을 직접 호출해 면제 조건 하나씩 고정한다 ────────────────── def test_진행_상태_파일이_있으면_에디터_경로도_통과한다(self): diff --git a/tests/test_shell_metacharacters_safe.py b/tests/test_shell_metacharacters_safe.py index 28832f1..57f31ee 100644 --- a/tests/test_shell_metacharacters_safe.py +++ b/tests/test_shell_metacharacters_safe.py @@ -88,18 +88,16 @@ def test_non_UTF8_바이트가_섞여도_트레일러_삽입이_깨지지_않는 def test_매우_긴_라인이_섞여도_트레일러_삽입이_깨지지_않는다(self): """[커밋메시지] 매우 긴 라인이 섞여도 트레일러 삽입이 깨지지 않는다""" - # 이 테스트의 목적은 post-commit의 interpret-trailers 삽입이 매우 긴 라인 - # 앞에서 깨지지 않는지 확인하는 것이지, GF-83의 본문 줄 길이(72자) 검증 - # 자체를 테스트하는 게 아니다 — 20000자 라인은 그 검증에 걸리므로 - # --no-verify로 commit-msg를 건너뛰고 post-commit(항상 실행됨) 경로만 - # 검증한다. --no-verify가 건너뛰지 못하는 prepare-commit-msg는 본문 길이를 - # 보지 않으므로 이 메시지를 막지 않는다. + # 이 테스트의 목적은 interpret-trailers 삽입(GF-128부터 + # prepare-commit-msg)이 매우 긴 라인 앞에서 깨지지 않는지 확인하는 것이지, GF-83의 본문 줄 길이(72자) 검증 + # 자체를 테스트하는 게 아니다. 예전에는 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..1686679 100644 --- a/tests/test_token_usage_measurement.py +++ b/tests/test_token_usage_measurement.py @@ -158,7 +158,10 @@ def test_실측_0은_no_attributed_turn과_구별된다(self): self.assertTrailerCount(message, "Tokens-Used: in=0 out=0", 1) self.assertTrailerCount(message, "Tool-Calls: 1", 1) self.assertNotIn("no-attributed-turn", message) - self.assertNotIn("unavailable", message) + # AI-Agent의 model-unavailable(이 가짜 트랜스크립트엔 model이 없다)과 구분하려고 + # 측정 트레일러만 본다. + self.assertNotIn("Tokens-Used: unavailable", message) + self.assertNotIn("Tool-Calls: unavailable", message) def test_귀속된_응답이_없으면_no_attributed_turn을_남긴다(self): """[GF-129] 어떤 응답도 커밋 파일을 건드리지 않았으면 in=0 out=0 (no-attributed-turn)이다""" @@ -247,6 +250,103 @@ def test_저장소_최상위_파일은_경계로_구분된_파일명으로_매 self.assertTrailerCount(message, "Tokens-Used: in=11 out=11", 1) self.assertTrailerCount(message, "Tool-Calls: 2", 1) + # ── 대상 경로는 커밋될 인덱스에서 온다 (GF-128) ─────────────── + # + # GF-128부터 측정은 커밋 객체가 생기기 전(prepare-commit-msg)에 돈다. 대상 경로는 + # git이 GIT_INDEX_FILE로 넘기는 "커밋될 인덱스"와 HEAD의 차이다 — -a와 <경로> + # 커밋은 임시 인덱스를 쓰므로(git 2.54.0 실측) .git/index를 직접 보면 틀린다. + + def baseline(self): + self.stage("a.txt", "b.txt") + self.git_ok("commit", "-q", "-m", "[feat] baseline", env={"AI_AGENT": None}) + + def test_commit_a는_스테이징하지_않은_수정_파일도_대상으로_삼는다(self): + """[GF-128] git commit -a로 커밋하는 수정 파일(스테이징 안 됨)을 건드린 응답도 귀속한다""" + self.baseline() + home = self.fake_home() + self.write_transcript(home, response("req-1", "msg-1", [edit("a.txt")], 10, 5)) + self.write("a.txt", "changed\n") + self.assertAccepted( + self.commit("[feat] commit all", "-a", env=self.claude_env(home)) + ) + message = self.head_message() + self.assertTrailerCount(message, "Tokens-Used: in=10 out=5", 1) + self.assertTrailerCount(message, "Tool-Calls: 1", 1) + + def test_경로를_지정한_커밋은_그_경로만_대상으로_삼는다(self): + """[GF-128] git commit <경로>는 지정한 파일만 대상이고, 스테이징만 된 다른 파일은 빠진다""" + self.baseline() + home = self.fake_home() + self.write_transcript( + home, + response("req-a", "msg-a", [edit("a.txt")], 10, 5) + + response("req-b", "msg-b", [edit("b.txt")], 1000, 500), + ) + self.write("a.txt", "changed\n") + # b.txt는 스테이징만 해 두고 이번 커밋에서는 뺀다. + self.write("b.txt", "staged only\n") + self.git_ok("add", "b.txt") + self.assertAccepted( + self.git( + "commit", "-m", "[feat] commit path", "--", "a.txt", + env=self.claude_env(home), + ) + ) + message = self.head_message() + self.assertTrailerCount(message, "Tokens-Used: in=10 out=5", 1) + # b.txt를 건드린 응답은 아직 귀속되지 않았다 — 다음 커밋 몫이다. + self.assertNotIn("req-b msg-b", self.record_lines()) + + def test_amend는_원래_커밋_위에_새로_얹는_변경만_대상으로_삼는다(self): + """[GF-128] --amend -m은 HEAD 대비 새로 스테이징한 파일을 건드린 응답만 귀속한다""" + home = self.fake_home() + self.write_transcript(home, response("req-1", "msg-1", [edit("a.txt")], 10, 5)) + self.stage("a.txt") + self.commit_ai("[feat] amend original", home) + + self.write_transcript( + home, + response("req-2", "msg-2", [edit("b.txt")], 7, 3) + # 원래 커밋의 파일만 읽은 응답 — amend가 a.txt를 바꾸지 않으므로 빠진다. + + response("req-3", "msg-3", [tool_use("Read", file_path="a.txt")], 900, 90), + ) + self.stage("b.txt") + self.assertAccepted( + self.commit("[feat] amended", "--amend", env=self.claude_env(home)) + ) + message = self.head_message() + self.assertTrailerCount(message, "Tokens-Used: in=7 out=3", 1) + self.assertEqual( + ["fake-session", "req-1 msg-1", "req-2 msg-2"], self.record_lines() + ) + + def test_amend_no_edit은_측정하지_않아_응답을_소비하지_않는다(self): + """[GF-128] 앞 커밋의 Tokens-Used/Tool-Calls를 들고 오는 --amend --no-edit은 측정을 건너뛰어 귀속 기록을 바꾸지 않는다""" + home = self.fake_home() + self.write_transcript(home, response("req-1", "msg-1", [edit("a.txt")], 10, 5)) + self.stage("a.txt") + self.commit_ai("[feat] keep trailers", home) + before = self.record_lines() + + # 새 응답이 있어도, 트레일러 키가 이미 있어 값을 못 남기므로 소비하면 안 된다. + self.write_transcript(home, response("req-2", "msg-2", [edit("b.txt")], 7, 3)) + self.stage("b.txt") + self.assertAccepted( + self.git("commit", "--amend", "--no-edit", env=self.claude_env(home)) + ) + self.assertEqual(before, self.record_lines()) + self.assertTrailerCount(self.head_message(), "Tokens-Used: in=10 out=5", 1) + + def test_거부된_커밋은_귀속_기록을_바꾸지_않는다(self): + """[GF-128] 검증에서 거부된 커밋은 측정 전에 멈춰 응답을 소비하지 않는다""" + home = self.fake_home() + self.write_transcript(home, response("req-1", "msg-1", [edit("a.txt")], 10, 5)) + self.stage("a.txt") + self.assertRejected(self.commit("형식 없는 제목", env=self.claude_env(home))) + self.assertFalse(self.git_dir_file(RECORD_NAME).exists()) + message = self.commit_ai("[feat] retry after rejection", home) + self.assertTrailerCount(message, "Tokens-Used: in=10 out=5", 1) + # ── 한 응답은 한 커밋에만 ───────────────────────────────────── def test_한_응답은_두_커밋에_중복_계상되지_않는다(self): @@ -422,15 +522,15 @@ 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") self.assertAccepted( self.commit("[feat] other ai tool", env={"AI_AGENT": "other-tool_1-0"}) ) message = self.head_message() - self.assertTrailerCount(message, "AI-Tool: other-tool", 1) + self.assertTrailerCount(message, "AI-Agent: other-tool/1.0 (gpt-5)", 1) self.assertTrailerCount( message, "Tokens-Used: unavailable (no-usage-channel)", 1 ) diff --git a/tests/test_trailer_ai_attribution.py b/tests/test_trailer_ai_attribution.py index 3d6bd47..749d9ec 100644 --- a/tests/test_trailer_ai_attribution.py +++ b/tests/test_trailer_ai_attribution.py @@ -1,9 +1,10 @@ -"""AI 도구 귀속 트레일러(AI-Tool/AI-Tool-Version/AI-Model)를 본다(decision-5). +"""AI 도구 귀속 트레일러 AI-Agent를 본다(decision-5, decision-19, GF-128). -구 robustness-post-commit.bats(GF-25, GF-97, GF-98)의 결함주입·슬러그 케이스. -AI-Model은 Claude Code 세션 트랜스크립트에서 읽으므로, 트랜스크립트를 못 읽는 -상황(JSON 손상, HOME 이상값)에서 커밋을 막지 않고 그 트레일러만 생략하는 -fail-open이 의도된 동작이다. +구 robustness-post-commit.bats(GF-25, GF-97, GF-98)의 결함주입·슬러그 케이스에서 +출발했다. GF-128에서 AI-Tool/AI-Tool-Version/AI-Model이 `AI-Agent: <도구>/<버전> +(<모델>)` 한 줄로 합쳐졌다. 모델은 Claude Code 세션 트랜스크립트에서 읽으므로, +트랜스크립트를 못 읽는 상황(JSON 손상, HOME 이상값)에서도 커밋을 막지 않는 fail-open이 +의도된 동작이다 — 다만 예전처럼 줄을 생략하지 않고 model-unavailable로 남긴다(AC #6). """ import unittest @@ -12,8 +13,8 @@ class TrailerAiAttributionTest(IsolatedRepoTestCase): - def test_트랜스크립트_JSON이_깨져도_AI_Model만_생략된다(self): - """[결함주입] Claude Code 트랜스크립트 JSON이 깨져도 AI-Model만 생략되고 커밋은 막히지 않는다""" + def test_트랜스크립트_JSON이_깨지면_모델이_model_unavailable이다(self): + """[결함주입/AC #6] 트랜스크립트 JSON이 깨져도 커밋은 막히지 않고 AI-Agent의 모델 자리가 model-unavailable이 된다""" home = self.fake_home() self.write_transcript( home, ["this is not valid json at all", "{ also not valid"] @@ -24,8 +25,10 @@ def test_트랜스크립트_JSON이_깨져도_AI_Model만_생략된다(self): self.commit("[feat] broken transcript", env=self.claude_env(home)) ) message = self.head_message() - self.assertTrailerKeyAbsent(message, "AI-Model") - self.assertTrailerCount(message, "AI-Tool: claude-code", 1) + self.assertTrailerCount( + message, "AI-Agent: claude-code/2.1.0 (model-unavailable)", 1 + ) + self.assertTrailerCount(message, "AI-Agent:", 1) def test_HOME이_존재하지_않아도_커밋은_막히지_않는다(self): """[결함주입] HOME이 존재하지 않는 경로여도 커밋은 막히지 않는다""" @@ -45,12 +48,12 @@ def test_사람_커밋에는_AI_트레일러가_전혀_붙지_않는다(self): self.git_ok("add", "a.txt") self.assertAccepted(self.commit("[feat] human commit no ai")) message = self.head_message() - self.assertTrailerKeyAbsent(message, "AI-Tool") + self.assertTrailerKeyAbsent(message, "AI-Agent") self.assertTrailerKeyAbsent(message, "Tokens-Used") self.assertTrailerKeyAbsent(message, "Tool-Calls") def test_밑줄과_점이_든_경로에서도_트랜스크립트를_찾는다(self): - """[GF-98] 저장소 경로에 밑줄/점이 있어도 Claude Code 실제 슬러그 규칙으로 트랜스크립트를 찾아 AI-Model/Tokens-Used/Tool-Calls가 채워진다""" + """[GF-98] 저장소 경로에 밑줄/점이 있어도 Claude Code 실제 슬러그 규칙으로 트랜스크립트를 찾아 AI-Agent의 모델과 Tokens-Used/Tool-Calls가 채워진다""" # Claude Code의 실제 규칙은 "영숫자가 아닌 모든 문자를 하이픈으로 치환"이다. # GF-98 이전에는 훅도 테스트도 `/`만 치환해 둘 다 틀렸으니 우연히 일치하며 # 버그를 못 잡았다. 저장소 최상위 디렉터리 이름 자체에 밑줄/점을 넣어(하위 @@ -92,10 +95,52 @@ def test_밑줄과_점이_든_경로에서도_트랜스크립트를_찾는다(se ) ) message = self.head_message(cwd=nested) - self.assertTrailerCount(message, "AI-Model: claude-slug-fix-test", 1) + self.assertTrailerCount( + message, "AI-Agent: claude-code/2.1.0 (claude-slug-fix-test)", 1 + ) self.assertTrailerCount(message, "Tokens-Used: in=10 out=5", 1) self.assertTrailerCount(message, "Tool-Calls: 1", 1) + # ── AI-Agent 조립과 축약(AC #5, #6) ───────────────────────────── + + def test_예전_AI_트레일러_세_종류는_붙지_않는다(self): + """[GF-128 AC #5] AI-Tool/AI-Tool-Version/AI-Model은 더 이상 붙지 않는다""" + home = self.fake_home() + self.write("a.txt", "hi\n") + self.git_ok("add", "a.txt") + self.assertAccepted(self.commit("[feat] merged agent line", env=self.claude_env(home))) + message = self.head_message() + for key in ("AI-Tool", "AI-Tool-Version", "AI-Model"): + self.assertTrailerKeyAbsent(message, key) + self.assertTrailerCount(message, "AI-Agent:", 1) + + def test_버전이_없으면_version_unavailable로_남긴다(self): + """[GF-128 AC #6] AI_AGENT에 버전 필드가 없어도 AI-Agent 줄은 version-unavailable로 남는다""" + home = self.fake_home() + self.write("a.txt", "hi\n") + self.git_ok("add", "a.txt") + env = {"HOME": str(home), "AI_AGENT": "claude-code"} + self.assertAccepted(self.commit("[feat] no version field", env=env)) + # 세션 ID도 없으므로 모델도 못 구한다 — 두 자리가 모두 비어도 줄은 남는다. + self.assertTrailerCount( + self.head_message(), + "AI-Agent: claude-code/version-unavailable (model-unavailable)", + 1, + ) + + def test_claude_code_외_도구는_gitformat_aiModel을_모델로_쓴다(self): + """[GF-128 AC #5] claude-code 외 도구는 게이트가 검증한 gitformat.aiModel이 모델 자리에 들어간다""" + self.git_ok("config", "gitformat.aiModel", "gpt-5") + self.write("a.txt", "hi\n") + self.git_ok("add", "a.txt") + self.assertAccepted( + self.commit("[feat] other agent", env={"AI_AGENT": "other-tool_1-2-3_agent"}) + ) + message = self.head_message() + self.assertTrailerCount(message, "AI-Agent: other-tool/1.2.3 (gpt-5)", 1) + # Co-Authored-By는 claude-code일 때만이다. + self.assertTrailerKeyAbsent(message, "Co-Authored-By") + if __name__ == "__main__": unittest.main() diff --git a/tests/test_trailer_hooks_commit.py b/tests/test_trailer_hooks_commit.py index 44f9248..0f2e164 100644 --- a/tests/test_trailer_hooks_commit.py +++ b/tests/test_trailer_hooks_commit.py @@ -1,56 +1,64 @@ -"""AI 여부와 무관하게 항상 붙는 트레일러(Hooks-Commit, Signed-off-by)를 본다. +"""Hooks-Commit과 Signed-off-by를 본다. -구 robustness-post-commit.bats(GF-25, GF-82)의 해당 케이스. 트레일러 삽입은 -`git commit --amend`로 이뤄지고 그 amend가 post-commit을 다시 발동시키므로, -_GITFORMAT_AMEND_GUARD가 재귀를 끊는지도 여기서 본다. +구 robustness-post-commit.bats(GF-25, GF-82)의 해당 케이스에서 출발했다. Hooks-Commit은 +AI 여부와 무관하게 항상 붙는다. Signed-off-by는 decision-30부터 AI 도구가 감지되지 않은 +커밋(사람 커밋)에만 붙는다 — DCO에서 이 트레일러는 사람의 인증이기 때문이다. + +GF-128 전에는 post-commit이 `git commit --amend`로 트레일러를 붙였고 그 amend가 +post-commit을 다시 발동시켜, 재귀 가드(_GITFORMAT_AMEND_GUARD)가 유한 시간 안에 +끝내는지도 여기서 봤다. 삽입이 prepare-commit-msg의 메시지 파일 쓰기로 바뀌어 amend도 +재귀도 없어졌으므로 그 케이스는 사라졌다. """ -import subprocess import unittest from isolated_repo import HOOKS_DIR, SIGNED_OFF_BY, IsolatedRepoTestCase class UnconditionalTrailerTest(IsolatedRepoTestCase): - def test_Signed_off_by가_커미터_정보로_삽입된다(self): - """[GF-82] 정상 커밋에 Signed-off-by가 커미터 정보로 자동 삽입된다""" + def setUp(self): + super().setUp() self.write("a.txt", "hi\n") self.git_ok("add", "a.txt") + + def test_사람_커밋에는_Signed_off_by가_커미터_정보로_붙는다(self): + """[GF-82/decision-30] AI_AGENT가 없는 커밋에 Signed-off-by가 커미터 정보로 한 번 붙는다""" self.assertAccepted(self.commit("[feat] signed off commit")) - self.assertTrailerCount( - self.head_message(), f"Signed-off-by: {SIGNED_OFF_BY}", 1 + message = self.head_message() + self.assertTrailerCount(message, f"Signed-off-by: {SIGNED_OFF_BY}", 1) + self.assertTrailerCount(message, "Signed-off-by:", 1) + + def test_에이전트_커밋에는_Signed_off_by가_붙지_않는다(self): + """[decision-30] AI_AGENT=claude-code_... 커밋에는 Signed-off-by가 붙지 않는다""" + home = self.fake_home() + self.assertAccepted( + self.commit("[feat] agent commit", env=self.claude_env(home)) + ) + message = self.head_message() + self.assertTrailerKeyAbsent(message, "Signed-off-by") + # 같은 신호로 AI-Agent가 붙었는지까지 봐야 "에이전트 커밋으로 판정됐다"가 증명된다. + self.assertTrailerCount(message, "AI-Agent:", 1) + + def test_에이전트_커밋에_사람이_쓴_Signed_off_by는_그대로_남는다(self): + """[decision-30] 운영자가 메시지에 직접 쓴 Signed-off-by는 에이전트 커밋에서도 보존된다""" + home = self.fake_home() + self.assertAccepted( + self.commit( + "[feat] agent commit signed by human\n\nSigned-off-by: Jane ", + env=self.claude_env(home), + ) ) + message = self.head_message() + self.assertTrailerCount(message, "Signed-off-by:", 1) + self.assertTrailerCount(message, "Signed-off-by: Jane ", 1) def test_Hooks_Commit이_훅_저장소의_HEAD로_한_번_붙는다(self): """Hooks-Commit이 이 커밋을 검증한 훅 코드의 커밋 해시로 정확히 한 번 붙는다 (decision-5)""" - # bats 판은 Hooks-Commit의 부재만 확인했고(python3 부재 케이스) 값이 맞는지는 - # 본 적이 없다. 개수까지 보는 단언으로 값을 직접 고정한다. expected = self.git_ok( "-C", HOOKS_DIR, "rev-parse", "--short", "HEAD" ).stdout.strip() - self.write("a.txt", "hi\n") - self.git_ok("add", "a.txt") self.assertAccepted(self.commit("[feat] hooks commit trailer")) - message = self.head_message() - self.assertTrailerCount(message, f"Hooks-Commit: {expected}", 1) - - def test_재귀_가드가_유한_시간_안에_끝낸다(self): - """[재귀가드] --no-verify + AI 트레일러가 붙는 커밋도 유한 시간 안에 끝난다""" - self.write("a.txt", "hi\n") - self.git_ok("add", "a.txt") - # GF-97 이후 이 커밋에는 AI-Tool: other-tool과 함께 Tokens-Used/Tool-Calls: - # unavailable (no-usage-channel)이 붙지만, 이 테스트의 관심사는 재귀 가드가 - # 유한 시간 안에 끝나는지이지 트레일러 값 자체가 아니다. - try: - result = self.commit( - "[feat] recursion guard check", - "--no-verify", - env={"AI_AGENT": "other-tool_1-0"}, - timeout=10, - ) - except subprocess.TimeoutExpired: - self.fail("post-commit이 10초 안에 끝나지 않았다 - 재귀 가드가 깨졌다") - self.assertAccepted(result) + self.assertTrailerCount(self.head_message(), f"Hooks-Commit: {expected}", 1) if __name__ == "__main__": diff --git a/tests/test_trailer_key_dedup.py b/tests/test_trailer_key_dedup.py new file mode 100644 index 0000000..d8de3b7 --- /dev/null +++ b/tests/test_trailer_key_dedup.py @@ -0,0 +1,98 @@ +"""트레일러 중복 판정이 키 단위로 이뤄지는지 본다(GF-128 AC #1~#4, decision-19). + +GF-128 전에는 post-commit이 `git interpret-trailers --parse` 출력에서 "키: 값"이 정확히 +같은 줄을 찾아 중복을 판정했다. --parse는 메시지 맨 끝의 연속된 트레일러 블록만 보므로 +빈 줄로 분리된 앞 문단의 Task-Id를 못 봤고, 값까지 비교하니 다른 값의 Co-Authored-By +옆에 정규 값이 또 붙었다(실측: 최근 40커밋 중 Co-Authored-By 40/40, Task-Id 12/40 중복). +지금은 메시지 원문의 모든 줄에서 "<키>:"로 시작하는 줄을 찾고(대소문자 무시), 있으면 그 +키를 건너뛴다. +""" + +import unittest + +from isolated_repo import IsolatedRepoTestCase + +CANONICAL_CO_AUTHOR = "Co-Authored-By: Claude " + + +class TrailerKeyDedupTest(IsolatedRepoTestCase): + def setUp(self): + super().setUp() + self.git_ok("checkout", "-q", "-b", "GF-7-dedup") + self.write("a.txt", "hi\n") + self.git_ok("add", "a.txt") + + def count_lines_with_key(self, message, key): + prefix = f"{key.lower()}:" + return sum(1 for line in message.split("\n") if line.lower().startswith(prefix)) + + def test_빈_줄로_분리된_앞_문단의_Task_Id가_있으면_한_줄만_남는다(self): + """[AC #3] 메시지에 빈 줄로 분리된 Task-Id 문단이 먼저 있어도 Task-Id가 한 줄만 남는다""" + self.commit_ok( + "[feat] 앞 문단 Task-Id\n\nTask-Id: GF-7\n\n그 뒤에 이어지는 본문 문단" + ) + message = self.head_message() + self.assertEqual(1, self.count_lines_with_key(message, "Task-Id"), message) + # 다른 트레일러는 정상으로 붙는다 — 훅이 아예 안 돈 것이 아니다. + self.assertTrailerCount(message, "Hooks-Commit:", 1) + + def test_다른_값의_Co_Authored_By는_보존되고_추가되지_않는다(self): + """[AC #4] 메시지에 다른 값의 Co-Authored-By가 있으면 원래 값만 남는다""" + home = self.fake_home() + self.commit_ok( + "[feat] 다른 공동저자\n\nCo-Authored-By: Claude Opus 5.5 ", + env=self.claude_env(home), + ) + message = self.head_message() + self.assertEqual( + 1, self.count_lines_with_key(message, "Co-Authored-By"), message + ) + self.assertIn( + "Co-Authored-By: Claude Opus 5.5 ", message + ) + self.assertNotIn(CANONICAL_CO_AUTHOR, message) + + def test_키는_대소문자를_구분하지_않는다(self): + """[AC #2] Co-authored-by처럼 대소문자만 다른 키도 같은 키로 보고 건너뛴다""" + home = self.fake_home() + self.commit_ok( + "[feat] 소문자 키\n\nCo-authored-by: Someone ", + env=self.claude_env(home), + ) + message = self.head_message() + self.assertEqual( + 1, self.count_lines_with_key(message, "Co-Authored-By"), message + ) + self.assertIn("Co-authored-by: Someone ", message) + + def test_사람이_쓴_트레일러_값을_덮어쓰지_않는다(self): + """[AC #2] 키가 있으면 값이 달라도 훅이 고치거나 옆에 붙이지 않는다""" + self.commit_ok("[feat] 직접 쓴 값\n\nHooks-Commit: deadbee") + message = self.head_message() + self.assertEqual(1, self.count_lines_with_key(message, "Hooks-Commit"), message) + self.assertIn("Hooks-Commit: deadbee", message) + + def test_amend_no_edit은_트레일러를_늘리지_않는다(self): + """[AC #1/doc-13 #7] --amend --no-edit을 반복해도 트레일러가 늘어나지 않는다""" + home = self.fake_home() + env = self.claude_env(home) + self.commit_ok("[feat] amend 대상", env=env) + before = self.head_message() + for _ in range(2): + self.write("a.txt", "changed\n") + self.git_ok("add", "a.txt") + self.assertAccepted(self.git("commit", "--amend", "--no-edit", env=env)) + self.assertEqual(before, self.head_message()) + self.assertEqual(1, self.commit_count()) + + def test_메시지_파일에_직접_써서_reflog에_amend가_남지_않는다(self): + """[AC #1] 트레일러를 붙이려고 커밋을 다시 쓰지 않는다 — 커밋 하나에 reflog 한 줄이다""" + self.commit_ok("[feat] 한 번에 최종 메시지") + reflog = self.git_ok("reflog", "--format=%gs").stdout.splitlines() + self.assertEqual(1, len(reflog), reflog) + self.assertNotIn("amend", reflog[0]) + self.assertTrailerCount(self.head_message(), "Task-Id: GF-7", 1) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_trailer_task_id.py b/tests/test_trailer_task_id.py index 31082e9..4a69686 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). +"""prepare-commit-msg가 브랜치명에서 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..e3d1aa5 100644 --- a/tests/test_verify_bypass_detection.py +++ b/tests/test_verify_bypass_detection.py @@ -1,63 +1,47 @@ -"""검증 마커와 --no-verify의 관계를 본다(decision-3, GF-31, GF-126, GF-135). +"""Verify-Bypassed 트레일러가 더 이상 붙지 않는지 본다(GF-128 AC #10, decision-18). -구 robustness-post-commit.bats의 상태전이 케이스. 마커를 쓰는 훅은 시작 시점에 남은 -마커를 지우고 끝에 다시 쓰고, post-commit은 그 마커의 존재만 보고 판정한 뒤 항상 -지운다 — 그래야 다음 커밋으로 스테일 마커가 새지 않는다. +decision-3 시절 post-commit은 검증 마커가 없으면 --no-verify 우회로 보고 +Verify-Bypassed: true를 붙였다. GF-126·GF-127에서 검증이 --no-verify로 건너뛸 수 없는 +prepare-commit-msg로 모이면서 탐지할 우회가 없어졌고, GF-128에서 post-commit과 함께 이 +트레일러를 없앴다. 파일 이름은 그 경위를 찾기 쉽게 그대로 둔다 — 지금 이 파일이 고정하는 +것은 "탐지"가 아니라 "그 트레일러가 다시 나타나지 않는다"는 사실이다. -**GF-126이 이 관계를 바꿨다.** 마커 기록이 pre-commit에서 prepare-commit-msg로 -옮겨왔고, prepare-commit-msg는 --no-verify로도 건너뛸 수 없다(doc-15). 그래서 마커는 ---no-verify 커밋에서도 항상 써지고, 마커의 부재로 우회를 판정하는 Verify-Bypassed는 -**사실상 도달 불가능**해졌다. +Verify-Bypassed의 판정 근거였던 검증마커($GIT_DIR/.gitformat-verified)도 GF-128에서 +함께 없앴다 — 그 마커를 읽던 것은 post-commit뿐이었다. -**GF-135는 그 마커가 무엇을 뜻하는지를 바꿨다.** 언어별 lint가 제거되며 마커가 gate할 -대상이 없어져, 이제 마커는 "검사를 통과했다"가 아니라 "prepare-commit-msg가 돌았다"만 -뜻한다. 그래도 조건 없이 계속 써야 한다 — 안 쓰면 아직 살아 있는 post-commit이 모든 -커밋에 Verify-Bypassed를 붙인다. 아래 두 테스트가 그 가교를 고정한다. 마커와 이 테스트 -파일은 post-commit과 함께 GF-128에서 사라진다. - -"사실상"인 이유는 재생 커밋 경로다(GF-126 실측). 면제가 먼저 걸려 마커가 없으니 -post-commit은 그 경로에서 Verify-Bypassed를 여전히 큐에 넣는데, 재생 중에는 amend 자체가 -실패해(archive DRAFT-18) 트레일러가 커밋에 남지 않는다 — 그 경로를 단언으로 고정하지 -않는 것은 지금 동작이 의도된 설계가 아니라 구 post-commit의 미해결 결함이기 때문이다. - -이 시점의 한계도 기록해 둔다: --no-verify는 아직 commit-msg를 건너뛰므로 '메시지 검증 -우회'는 여전히 가능하고, 그것을 기록하는 신호는 없다. 검증을 prepare-commit-msg로 옮기는 -GF-127이 그 한 태스크짜리 과도기를 닫는다. +--no-verify로 형식이 틀린 메시지를 넣을 수 없다는 사실은 test_message_format_enforced.py가 +고정한다. """ import unittest from isolated_repo import IsolatedRepoTestCase -MARKER_NAME = ".gitformat-verified" - -class VerifyBypassDetectionTest(IsolatedRepoTestCase): - def test_정상_커밋은_마커가_남지_않고_Verify_Bypassed도_없다(self): - """[상태전이] 정상 커밋은 마커가 결과적으로 남지 않고 Verify-Bypassed가 안 붙는다""" - marker = self.git_dir_file(MARKER_NAME) - self.assertFalse(marker.exists()) +class VerifyBypassedRemovedTest(IsolatedRepoTestCase): + def setUp(self): + super().setUp() self.write("a.txt", "hi\n") self.git_ok("add", "a.txt") + + def test_정상_커밋에_Verify_Bypassed가_없다(self): + """[GF-128] 정상 커밋에 Verify-Bypassed가 붙지 않는다""" self.assertAccepted(self.commit("[feat] normal commit")) - self.assertFalse(marker.exists(), "post-commit이 마커를 지우지 않았다") self.assertTrailerKeyAbsent(self.head_message(), "Verify-Bypassed") - def test_no_verify_커밋에도_Verify_Bypassed가_붙지_않는다(self): - """[상태전이] --no-verify로 커밋해도 마커가 써져 Verify-Bypassed가 붙지 않는다 - - GF-126까지는 반대였다 — pre-commit이 --no-verify로 스킵돼 마커가 없었고, - post-commit이 그 부재를 우회 증거로 읽어 Verify-Bypassed: true를 붙였다. 마커 - 기록이 --no-verify로 건너뛸 수 없는 prepare-commit-msg로 옮겨온 뒤에는 마커가 - 항상 써지므로 이 트레일러는 도달 불가능하다. post-commit과 함께 GF-128에서 - 사라질 신호다. - """ - self.write("a.txt", "hi\n") - self.git_ok("add", "a.txt") + def test_no_verify_커밋에도_Verify_Bypassed가_없다(self): + """[GF-128] --no-verify로 커밋해도 Verify-Bypassed가 붙지 않고 트레일러는 정상으로 붙는다""" self.assertAccepted(self.commit("[feat] bypass verification", "--no-verify")) - # post-commit이 판정 후 항상 지우므로 마커 자체는 남지 않는다. - self.assertFalse(self.git_dir_file(MARKER_NAME).exists()) - self.assertTrailerKeyAbsent(self.head_message(), "Verify-Bypassed") + message = self.head_message() + self.assertTrailerKeyAbsent(message, "Verify-Bypassed") + # --no-verify가 prepare-commit-msg를 건너뛰지 못한다는 것까지 본다 — 트레일러 + # 삽입도 이 훅이 하므로 Hooks-Commit이 붙었으면 훅이 돈 것이다. + self.assertTrailerCount(message, "Hooks-Commit:", 1) + + def test_검증마커를_더_이상_쓰지_않는다(self): + """[GF-128] 커밋 뒤 $GIT_DIR에 검증마커 파일이 생기지 않는다""" + self.assertAccepted(self.commit("[feat] no marker")) + self.assertFalse(self.git_dir_file(".gitformat-verified").exists()) if __name__ == "__main__":