git-deai 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- git_deai-0.1.0/.github/workflows/publish.yml +19 -0
- git_deai-0.1.0/.gitignore +4 -0
- git_deai-0.1.0/CITATION.cff +20 -0
- git_deai-0.1.0/LICENSE +21 -0
- git_deai-0.1.0/PKG-INFO +266 -0
- git_deai-0.1.0/README.md +246 -0
- git_deai-0.1.0/pyproject.toml +34 -0
- git_deai-0.1.0/src/deai/__init__.py +1 -0
- git_deai-0.1.0/src/deai/cli.py +337 -0
- git_deai-0.1.0/tests/test_deai.py +97 -0
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
name: Publish to PyPI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
tags:
|
|
6
|
+
- 'v*'
|
|
7
|
+
|
|
8
|
+
jobs:
|
|
9
|
+
publish:
|
|
10
|
+
runs-on: ubuntu-latest
|
|
11
|
+
environment: release
|
|
12
|
+
permissions:
|
|
13
|
+
id-token: write # PyPI Trusted Publishing
|
|
14
|
+
contents: read
|
|
15
|
+
steps:
|
|
16
|
+
- uses: actions/checkout@v4
|
|
17
|
+
- uses: astral-sh/setup-uv@v5
|
|
18
|
+
- run: uv build
|
|
19
|
+
- uses: pypa/gh-action-pypi-publish@release/v1
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
cff-version: 1.2.0
|
|
2
|
+
message: "If you use this software, please cite it as below."
|
|
3
|
+
type: software
|
|
4
|
+
title: "deai: Find and remove AI co-author credits from git history"
|
|
5
|
+
authors:
|
|
6
|
+
- family-names: Yong
|
|
7
|
+
given-names: Isaac
|
|
8
|
+
email: isaacyong@naver.com
|
|
9
|
+
affiliation: "Sogang University"
|
|
10
|
+
repository-code: "https://github.com/NaNIsaac/deai"
|
|
11
|
+
abstract: "Command-line tool that finds AI co-author credits (Co-authored-by trailers and AI author/committer identities) in git repositories and rewrites history without them."
|
|
12
|
+
keywords:
|
|
13
|
+
- "git"
|
|
14
|
+
- "co-author"
|
|
15
|
+
- "ai"
|
|
16
|
+
- "history"
|
|
17
|
+
- "cli"
|
|
18
|
+
license: MIT
|
|
19
|
+
version: "0.1.0"
|
|
20
|
+
date-released: "2026-10-14"
|
git_deai-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Isaac Yong
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
git_deai-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: git-deai
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Find and remove AI co-author credits from git history
|
|
5
|
+
Project-URL: Homepage, https://github.com/NaNIsaac/deai
|
|
6
|
+
Project-URL: Repository, https://github.com/NaNIsaac/deai
|
|
7
|
+
Project-URL: Issues, https://github.com/NaNIsaac/deai/issues
|
|
8
|
+
Author-email: Isaac Yong <isaacyong@naver.com>
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: ai,cli,co-author,git,history
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Environment :: Console
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Topic :: Software Development :: Version Control :: Git
|
|
18
|
+
Requires-Python: >=3.10
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
|
|
21
|
+
# deai
|
|
22
|
+
|
|
23
|
+
**Remove AI co-author credits from your git history.**
|
|
24
|
+
**git 기록에서 AI 공동저자 표시를 지웁니다.**
|
|
25
|
+
|
|
26
|
+
[English](#english) · [한국어](#한국어)
|
|
27
|
+
|
|
28
|
+
---
|
|
29
|
+
|
|
30
|
+
## English
|
|
31
|
+
|
|
32
|
+
### Why
|
|
33
|
+
AI coding tools add a line like this to your commit messages:
|
|
34
|
+
|
|
35
|
+
```
|
|
36
|
+
Co-Authored-By: Claude <noreply@anthropic.com>
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
GitHub reads that line, so the tool then shows up in your repository's **Contributors** list. Copilot,
|
|
40
|
+
Cursor, Codex and others do the same. `deai` finds these credits in all your repositories and
|
|
41
|
+
rewrites the history without them. Your code does not change.
|
|
42
|
+
|
|
43
|
+
### Install
|
|
44
|
+
Requires Python 3.10+ and git. There are no other dependencies.
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
pip install git-deai
|
|
48
|
+
# or, as an isolated tool
|
|
49
|
+
uv tool install git-deai # recommended
|
|
50
|
+
pipx install git-deai
|
|
51
|
+
# or, from a local clone
|
|
52
|
+
uv tool install -e .
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
The package is `git-deai`; the command it installs is `deai`.
|
|
56
|
+
To try it without installing: `uvx --from git-deai deai --help`.
|
|
57
|
+
|
|
58
|
+
### Usage
|
|
59
|
+
|
|
60
|
+
**1. Scan (read-only).** Scanning only reports; it changes nothing.
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
deai scan # the repository in the current directory
|
|
64
|
+
deai scan path/to/repo # one repository
|
|
65
|
+
deai scan -r ~/code # every repository under a folder
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
```
|
|
69
|
+
AI credits /home/me/code/my-app (96 commits; trailers 51, AI author/committer 0)
|
|
70
|
+
trailer: faea929 e37c10a 2ce9a9c 7a5f1ab ...
|
|
71
|
+
clean /home/me/code/notes (8 commits; trailers 0, AI author/committer 0)
|
|
72
|
+
shared /home/me/code/xarray (6578 commits; trailers 96, AI author/committer 1)
|
|
73
|
+
by others: Deepak Cherian <...>, ...
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
| mark | meaning |
|
|
77
|
+
|---|---|
|
|
78
|
+
| `clean` | nothing to do |
|
|
79
|
+
| `AI credits` | your commits carry AI credits; `clean` will fix them |
|
|
80
|
+
| `shared` | someone else's commits carry AI credits (a fork or shared history); skipped by default |
|
|
81
|
+
|
|
82
|
+
**2. Clean (rewrites history).**
|
|
83
|
+
|
|
84
|
+
```bash
|
|
85
|
+
deai clean path/to/repo # shows the scan, asks y/N, backs up, rewrites
|
|
86
|
+
deai clean -r ~/code # every repository found (shared ones skipped)
|
|
87
|
+
deai clean -r ~/code --yes --push # no question; force-push afterwards
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
**3. Publish.** Without `--push`, nothing leaves your machine. When you are happy with the result,
|
|
91
|
+
run the same command again with `--push`. Do not use `git push --all`: it would also push stale
|
|
92
|
+
local branches over newer remote ones.
|
|
93
|
+
|
|
94
|
+
GitHub can take a while to refresh the Contributors list after the push.
|
|
95
|
+
|
|
96
|
+
### Options
|
|
97
|
+
|
|
98
|
+
| option | commands | what it does |
|
|
99
|
+
|---|---|---|
|
|
100
|
+
| `-r`, `--recursive` | scan, clean | find repositories below each path |
|
|
101
|
+
| `--me EMAIL` | scan, clean | another e-mail of yours (repeatable). `git config user.email` is always included. |
|
|
102
|
+
| `--yes` | clean | do not ask before rewriting |
|
|
103
|
+
| `--push` | clean | force-push branches (`--force-with-lease`) and tags afterwards |
|
|
104
|
+
| `--as 'Name <email>'` | clean | identity for commits authored by an AI (default: `git config user.*`) |
|
|
105
|
+
| `--include-shared` | clean | also rewrite `shared` repositories (rarely what you want) |
|
|
106
|
+
|
|
107
|
+
### What is removed
|
|
108
|
+
- `Co-authored-by:` trailers whose name or e-mail matches an AI tool. **Human co-authors stay.**
|
|
109
|
+
- "Generated with \<AI tool\>" lines, e.g. `🤖 Generated with [Claude Code](https://claude.com/claude-code)`.
|
|
110
|
+
- AI author and committer identities, which are re-attributed to you.
|
|
111
|
+
|
|
112
|
+
Matched tools: Claude/Anthropic, GitHub Copilot, Cursor, ChatGPT/OpenAI/Codex, Gemini, Devin, Aider,
|
|
113
|
+
Codeium/Windsurf, Tabnine, CodeWhisperer/Amazon Q, Jules, Sweep.
|
|
114
|
+
|
|
115
|
+
### Safety
|
|
116
|
+
- **A clean worktree is required.** `clean` refuses to run if tracked files have uncommitted changes.
|
|
117
|
+
- **A backup is made first.** Every branch and tag is saved to `.git/deai-backup-<time>.bundle`.
|
|
118
|
+
To restore it:
|
|
119
|
+
```bash
|
|
120
|
+
git fetch --force .git/deai-backup-20261014-222005.bundle 'refs/*:refs/*'
|
|
121
|
+
```
|
|
122
|
+
- **Pushes never lose remote commits.** `--push` fetches first.
|
|
123
|
+
- A local branch that is behind its remote is fast-forwarded before the rewrite.
|
|
124
|
+
- A branch that has diverged from its remote stops the run.
|
|
125
|
+
- Each branch is force-pushed with an explicit lease on the commit seen at fetch time.
|
|
126
|
+
- Only branches with an upstream are pushed; local-only branches stay local.
|
|
127
|
+
- **Forks are protected.** A repository whose flagged commits were written by other people is
|
|
128
|
+
marked `shared` and skipped. Rewriting it would split your fork from upstream.
|
|
129
|
+
|
|
130
|
+
### Things to know
|
|
131
|
+
- **Commit hashes change** from the first cleaned commit onward. Anyone else with a clone must
|
|
132
|
+
re-clone (or `git fetch && git reset --hard origin/<branch>`).
|
|
133
|
+
- **Open pull requests** from a rewritten branch need to be re-created.
|
|
134
|
+
- **GPG-signed commits** lose their signatures, because the signed content changed.
|
|
135
|
+
- **Protected branches** on GitHub may reject the force-push. Allow force-pushes temporarily, or push
|
|
136
|
+
to a new branch.
|
|
137
|
+
|
|
138
|
+
### Stop it from coming back
|
|
139
|
+
Turn the attribution off in your AI tool. In Claude Code, add this to `~/.claude/settings.json`
|
|
140
|
+
(an empty string removes that line; the older `includeCoAuthoredBy` is deprecated):
|
|
141
|
+
|
|
142
|
+
```json
|
|
143
|
+
{ "attribution": { "commit": "", "pr": "" } }
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
### How it works
|
|
147
|
+
`git fast-export --branches --tags` → message filter (stdlib Python) → `git fast-import --force`.
|
|
148
|
+
There is no git-filter-repo dependency. File contents are passed through untouched, so every tree is
|
|
149
|
+
byte-identical to the original.
|
|
150
|
+
|
|
151
|
+
### Test
|
|
152
|
+
```bash
|
|
153
|
+
python tests/test_deai.py
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
---
|
|
157
|
+
|
|
158
|
+
## 한국어
|
|
159
|
+
|
|
160
|
+
### 왜 필요한가요
|
|
161
|
+
AI 코딩 도구는 커밋 메시지 끝에 이런 줄을 붙입니다.
|
|
162
|
+
|
|
163
|
+
```
|
|
164
|
+
Co-Authored-By: Claude <noreply@anthropic.com>
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
GitHub는 이 줄을 읽어서 저장소의 **Contributors** 목록에 AI를 올립니다. Copilot, Cursor, Codex 등도
|
|
168
|
+
마찬가지입니다. `deai`는 내 저장소 전체에서 이런 표시를 찾아 git 기록에서 지웁니다. 코드는 그대로입니다.
|
|
169
|
+
|
|
170
|
+
### 설치
|
|
171
|
+
Python 3.10 이상과 git만 있으면 됩니다. 다른 의존성은 없습니다.
|
|
172
|
+
|
|
173
|
+
```bash
|
|
174
|
+
pip install git-deai
|
|
175
|
+
# 또는 격리된 도구로
|
|
176
|
+
uv tool install git-deai # 추천
|
|
177
|
+
pipx install git-deai
|
|
178
|
+
# 또는 내려받은 폴더에서
|
|
179
|
+
uv tool install -e .
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
패키지 이름은 `git-deai`이고, 설치되는 명령어는 `deai`입니다.
|
|
183
|
+
설치 없이 써 보려면: `uvx --from git-deai deai --help`
|
|
184
|
+
|
|
185
|
+
### 사용법
|
|
186
|
+
|
|
187
|
+
**1. 점검 (읽기만 함).** 결과만 보여주고 아무것도 바꾸지 않습니다.
|
|
188
|
+
|
|
189
|
+
```bash
|
|
190
|
+
deai scan # 현재 폴더의 저장소
|
|
191
|
+
deai scan 경로/저장소 # 저장소 하나
|
|
192
|
+
deai scan -r ~/code # 폴더 아래 모든 저장소
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
| 표시 | 뜻 |
|
|
196
|
+
|---|---|
|
|
197
|
+
| `clean` | 고칠 것 없음 |
|
|
198
|
+
| `AI credits` | 내 커밋에 AI 표시가 있음. `clean`으로 지울 수 있음 |
|
|
199
|
+
| `shared` | **다른 사람** 커밋에 AI 표시가 있음 (fork 등). 기본적으로 건너뜀 |
|
|
200
|
+
|
|
201
|
+
**2. 정리 (기록을 다시 씀).**
|
|
202
|
+
|
|
203
|
+
```bash
|
|
204
|
+
deai clean 경로/저장소 # 점검 결과 → y/N 확인 → 백업 → 다시 쓰기
|
|
205
|
+
deai clean -r ~/code # 찾은 저장소 전부 (shared는 제외)
|
|
206
|
+
deai clean -r ~/code --yes --push # 확인 없이, 끝나면 force-push까지
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
**3. GitHub에 반영.** `--push`를 붙이지 않으면 내 컴퓨터 밖으로 아무것도 나가지 않습니다. 결과를 확인한 뒤
|
|
210
|
+
같은 명령에 `--push`를 붙여 다시 실행하세요. `git push --all`은 쓰지 마세요. 원격보다 뒤처진 로컬 브랜치까지
|
|
211
|
+
덮어써서 원격의 최신 커밋이 사라질 수 있습니다.
|
|
212
|
+
|
|
213
|
+
push한 뒤 GitHub의 Contributors 목록이 갱신되기까지 시간이 좀 걸릴 수 있습니다.
|
|
214
|
+
|
|
215
|
+
### 옵션
|
|
216
|
+
|
|
217
|
+
| 옵션 | 명령 | 하는 일 |
|
|
218
|
+
|---|---|---|
|
|
219
|
+
| `-r`, `--recursive` | scan, clean | 경로 아래 저장소를 모두 찾음 |
|
|
220
|
+
| `--me EMAIL` | scan, clean | 내 다른 이메일 (여러 번 지정 가능). `git config user.email`은 항상 포함 |
|
|
221
|
+
| `--yes` | clean | 확인 질문 없이 진행 |
|
|
222
|
+
| `--push` | clean | 끝나면 브랜치(`--force-with-lease`)와 태그를 force-push |
|
|
223
|
+
| `--as '이름 <이메일>'` | clean | AI가 작성자로 된 커밋을 바꿀 이름 (기본값: `git config user.*`) |
|
|
224
|
+
| `--include-shared` | clean | `shared` 저장소도 다시 씀 (보통은 쓰지 마세요) |
|
|
225
|
+
|
|
226
|
+
### 지우는 것
|
|
227
|
+
- AI 도구 이름이나 이메일이 들어간 `Co-authored-by:` 줄. **사람 공동저자 줄은 남깁니다.**
|
|
228
|
+
- `🤖 Generated with [Claude Code](...)` 같은 "Generated with \<AI 도구\>" 줄
|
|
229
|
+
- AI가 작성자나 커미터로 된 커밋. 이 커밋들은 내 이름으로 바꿉니다.
|
|
230
|
+
|
|
231
|
+
### 안전장치
|
|
232
|
+
- **커밋 안 한 변경이 있으면 실행하지 않습니다.**
|
|
233
|
+
- **먼저 백업합니다.** 모든 브랜치와 태그를 `.git/deai-backup-<시각>.bundle`로 저장합니다. 되돌리려면:
|
|
234
|
+
```bash
|
|
235
|
+
git fetch --force .git/deai-backup-20261014-222005.bundle 'refs/*:refs/*'
|
|
236
|
+
```
|
|
237
|
+
- **원격 커밋을 잃지 않습니다.** `--push`를 붙이면 먼저 fetch합니다.
|
|
238
|
+
- 원격보다 뒤처진 로컬 브랜치는 먼저 fast-forward합니다.
|
|
239
|
+
- 원격과 갈라진 브랜치가 있으면 멈춥니다.
|
|
240
|
+
- 브랜치마다 fetch 시점의 커밋을 lease로 걸어 force-push합니다.
|
|
241
|
+
- upstream이 있는 브랜치만 push하고, 로컬 전용 브랜치는 그대로 둡니다.
|
|
242
|
+
- **fork는 보호합니다.** 다른 사람이 쓴 커밋이 걸린 저장소는 `shared`로 표시하고 건너뜁니다.
|
|
243
|
+
다시 쓰면 upstream과 기록이 갈라지기 때문입니다.
|
|
244
|
+
|
|
245
|
+
### 알아둘 점
|
|
246
|
+
- **커밋 해시가 바뀝니다.** 처음 고쳐진 커밋부터 그 뒤가 모두 바뀝니다. 다른 사람이 clone해 둔 저장소는
|
|
247
|
+
다시 clone하거나 `git fetch && git reset --hard origin/<브랜치>`를 해야 합니다.
|
|
248
|
+
- **열린 PR**이 다시 쓴 브랜치에 있으면 PR을 새로 만들어야 합니다.
|
|
249
|
+
- **GPG 서명**은 사라집니다. 서명한 내용이 바뀌었기 때문입니다.
|
|
250
|
+
- **보호된 브랜치**는 force-push가 거절될 수 있습니다. 잠깐 허용하거나 새 브랜치로 push하세요.
|
|
251
|
+
|
|
252
|
+
### 다시 생기지 않게 하기
|
|
253
|
+
AI 도구에서 공동저자 표시를 끄세요. Claude Code라면 `~/.claude/settings.json`에 다음을 넣습니다
|
|
254
|
+
(빈 문자열이면 그 줄을 넣지 않습니다. 예전 설정 `includeCoAuthoredBy`는 deprecated입니다).
|
|
255
|
+
|
|
256
|
+
```json
|
|
257
|
+
{ "attribution": { "commit": "", "pr": "" } }
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
### 동작 방식
|
|
261
|
+
`git fast-export --branches --tags` → 메시지 필터(표준 라이브러리 Python) → `git fast-import --force`.
|
|
262
|
+
git-filter-repo가 필요 없습니다. 파일 내용은 손대지 않으므로 모든 트리가 원래와 바이트 단위로 같습니다.
|
|
263
|
+
|
|
264
|
+
---
|
|
265
|
+
|
|
266
|
+
MIT License
|
git_deai-0.1.0/README.md
ADDED
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
# deai
|
|
2
|
+
|
|
3
|
+
**Remove AI co-author credits from your git history.**
|
|
4
|
+
**git 기록에서 AI 공동저자 표시를 지웁니다.**
|
|
5
|
+
|
|
6
|
+
[English](#english) · [한국어](#한국어)
|
|
7
|
+
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
## English
|
|
11
|
+
|
|
12
|
+
### Why
|
|
13
|
+
AI coding tools add a line like this to your commit messages:
|
|
14
|
+
|
|
15
|
+
```
|
|
16
|
+
Co-Authored-By: Claude <noreply@anthropic.com>
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
GitHub reads that line, so the tool then shows up in your repository's **Contributors** list. Copilot,
|
|
20
|
+
Cursor, Codex and others do the same. `deai` finds these credits in all your repositories and
|
|
21
|
+
rewrites the history without them. Your code does not change.
|
|
22
|
+
|
|
23
|
+
### Install
|
|
24
|
+
Requires Python 3.10+ and git. There are no other dependencies.
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
pip install git-deai
|
|
28
|
+
# or, as an isolated tool
|
|
29
|
+
uv tool install git-deai # recommended
|
|
30
|
+
pipx install git-deai
|
|
31
|
+
# or, from a local clone
|
|
32
|
+
uv tool install -e .
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
The package is `git-deai`; the command it installs is `deai`.
|
|
36
|
+
To try it without installing: `uvx --from git-deai deai --help`.
|
|
37
|
+
|
|
38
|
+
### Usage
|
|
39
|
+
|
|
40
|
+
**1. Scan (read-only).** Scanning only reports; it changes nothing.
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
deai scan # the repository in the current directory
|
|
44
|
+
deai scan path/to/repo # one repository
|
|
45
|
+
deai scan -r ~/code # every repository under a folder
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
```
|
|
49
|
+
AI credits /home/me/code/my-app (96 commits; trailers 51, AI author/committer 0)
|
|
50
|
+
trailer: faea929 e37c10a 2ce9a9c 7a5f1ab ...
|
|
51
|
+
clean /home/me/code/notes (8 commits; trailers 0, AI author/committer 0)
|
|
52
|
+
shared /home/me/code/xarray (6578 commits; trailers 96, AI author/committer 1)
|
|
53
|
+
by others: Deepak Cherian <...>, ...
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
| mark | meaning |
|
|
57
|
+
|---|---|
|
|
58
|
+
| `clean` | nothing to do |
|
|
59
|
+
| `AI credits` | your commits carry AI credits; `clean` will fix them |
|
|
60
|
+
| `shared` | someone else's commits carry AI credits (a fork or shared history); skipped by default |
|
|
61
|
+
|
|
62
|
+
**2. Clean (rewrites history).**
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
deai clean path/to/repo # shows the scan, asks y/N, backs up, rewrites
|
|
66
|
+
deai clean -r ~/code # every repository found (shared ones skipped)
|
|
67
|
+
deai clean -r ~/code --yes --push # no question; force-push afterwards
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
**3. Publish.** Without `--push`, nothing leaves your machine. When you are happy with the result,
|
|
71
|
+
run the same command again with `--push`. Do not use `git push --all`: it would also push stale
|
|
72
|
+
local branches over newer remote ones.
|
|
73
|
+
|
|
74
|
+
GitHub can take a while to refresh the Contributors list after the push.
|
|
75
|
+
|
|
76
|
+
### Options
|
|
77
|
+
|
|
78
|
+
| option | commands | what it does |
|
|
79
|
+
|---|---|---|
|
|
80
|
+
| `-r`, `--recursive` | scan, clean | find repositories below each path |
|
|
81
|
+
| `--me EMAIL` | scan, clean | another e-mail of yours (repeatable). `git config user.email` is always included. |
|
|
82
|
+
| `--yes` | clean | do not ask before rewriting |
|
|
83
|
+
| `--push` | clean | force-push branches (`--force-with-lease`) and tags afterwards |
|
|
84
|
+
| `--as 'Name <email>'` | clean | identity for commits authored by an AI (default: `git config user.*`) |
|
|
85
|
+
| `--include-shared` | clean | also rewrite `shared` repositories (rarely what you want) |
|
|
86
|
+
|
|
87
|
+
### What is removed
|
|
88
|
+
- `Co-authored-by:` trailers whose name or e-mail matches an AI tool. **Human co-authors stay.**
|
|
89
|
+
- "Generated with \<AI tool\>" lines, e.g. `🤖 Generated with [Claude Code](https://claude.com/claude-code)`.
|
|
90
|
+
- AI author and committer identities, which are re-attributed to you.
|
|
91
|
+
|
|
92
|
+
Matched tools: Claude/Anthropic, GitHub Copilot, Cursor, ChatGPT/OpenAI/Codex, Gemini, Devin, Aider,
|
|
93
|
+
Codeium/Windsurf, Tabnine, CodeWhisperer/Amazon Q, Jules, Sweep.
|
|
94
|
+
|
|
95
|
+
### Safety
|
|
96
|
+
- **A clean worktree is required.** `clean` refuses to run if tracked files have uncommitted changes.
|
|
97
|
+
- **A backup is made first.** Every branch and tag is saved to `.git/deai-backup-<time>.bundle`.
|
|
98
|
+
To restore it:
|
|
99
|
+
```bash
|
|
100
|
+
git fetch --force .git/deai-backup-20261014-222005.bundle 'refs/*:refs/*'
|
|
101
|
+
```
|
|
102
|
+
- **Pushes never lose remote commits.** `--push` fetches first.
|
|
103
|
+
- A local branch that is behind its remote is fast-forwarded before the rewrite.
|
|
104
|
+
- A branch that has diverged from its remote stops the run.
|
|
105
|
+
- Each branch is force-pushed with an explicit lease on the commit seen at fetch time.
|
|
106
|
+
- Only branches with an upstream are pushed; local-only branches stay local.
|
|
107
|
+
- **Forks are protected.** A repository whose flagged commits were written by other people is
|
|
108
|
+
marked `shared` and skipped. Rewriting it would split your fork from upstream.
|
|
109
|
+
|
|
110
|
+
### Things to know
|
|
111
|
+
- **Commit hashes change** from the first cleaned commit onward. Anyone else with a clone must
|
|
112
|
+
re-clone (or `git fetch && git reset --hard origin/<branch>`).
|
|
113
|
+
- **Open pull requests** from a rewritten branch need to be re-created.
|
|
114
|
+
- **GPG-signed commits** lose their signatures, because the signed content changed.
|
|
115
|
+
- **Protected branches** on GitHub may reject the force-push. Allow force-pushes temporarily, or push
|
|
116
|
+
to a new branch.
|
|
117
|
+
|
|
118
|
+
### Stop it from coming back
|
|
119
|
+
Turn the attribution off in your AI tool. In Claude Code, add this to `~/.claude/settings.json`
|
|
120
|
+
(an empty string removes that line; the older `includeCoAuthoredBy` is deprecated):
|
|
121
|
+
|
|
122
|
+
```json
|
|
123
|
+
{ "attribution": { "commit": "", "pr": "" } }
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
### How it works
|
|
127
|
+
`git fast-export --branches --tags` → message filter (stdlib Python) → `git fast-import --force`.
|
|
128
|
+
There is no git-filter-repo dependency. File contents are passed through untouched, so every tree is
|
|
129
|
+
byte-identical to the original.
|
|
130
|
+
|
|
131
|
+
### Test
|
|
132
|
+
```bash
|
|
133
|
+
python tests/test_deai.py
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
---
|
|
137
|
+
|
|
138
|
+
## 한국어
|
|
139
|
+
|
|
140
|
+
### 왜 필요한가요
|
|
141
|
+
AI 코딩 도구는 커밋 메시지 끝에 이런 줄을 붙입니다.
|
|
142
|
+
|
|
143
|
+
```
|
|
144
|
+
Co-Authored-By: Claude <noreply@anthropic.com>
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
GitHub는 이 줄을 읽어서 저장소의 **Contributors** 목록에 AI를 올립니다. Copilot, Cursor, Codex 등도
|
|
148
|
+
마찬가지입니다. `deai`는 내 저장소 전체에서 이런 표시를 찾아 git 기록에서 지웁니다. 코드는 그대로입니다.
|
|
149
|
+
|
|
150
|
+
### 설치
|
|
151
|
+
Python 3.10 이상과 git만 있으면 됩니다. 다른 의존성은 없습니다.
|
|
152
|
+
|
|
153
|
+
```bash
|
|
154
|
+
pip install git-deai
|
|
155
|
+
# 또는 격리된 도구로
|
|
156
|
+
uv tool install git-deai # 추천
|
|
157
|
+
pipx install git-deai
|
|
158
|
+
# 또는 내려받은 폴더에서
|
|
159
|
+
uv tool install -e .
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
패키지 이름은 `git-deai`이고, 설치되는 명령어는 `deai`입니다.
|
|
163
|
+
설치 없이 써 보려면: `uvx --from git-deai deai --help`
|
|
164
|
+
|
|
165
|
+
### 사용법
|
|
166
|
+
|
|
167
|
+
**1. 점검 (읽기만 함).** 결과만 보여주고 아무것도 바꾸지 않습니다.
|
|
168
|
+
|
|
169
|
+
```bash
|
|
170
|
+
deai scan # 현재 폴더의 저장소
|
|
171
|
+
deai scan 경로/저장소 # 저장소 하나
|
|
172
|
+
deai scan -r ~/code # 폴더 아래 모든 저장소
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
| 표시 | 뜻 |
|
|
176
|
+
|---|---|
|
|
177
|
+
| `clean` | 고칠 것 없음 |
|
|
178
|
+
| `AI credits` | 내 커밋에 AI 표시가 있음. `clean`으로 지울 수 있음 |
|
|
179
|
+
| `shared` | **다른 사람** 커밋에 AI 표시가 있음 (fork 등). 기본적으로 건너뜀 |
|
|
180
|
+
|
|
181
|
+
**2. 정리 (기록을 다시 씀).**
|
|
182
|
+
|
|
183
|
+
```bash
|
|
184
|
+
deai clean 경로/저장소 # 점검 결과 → y/N 확인 → 백업 → 다시 쓰기
|
|
185
|
+
deai clean -r ~/code # 찾은 저장소 전부 (shared는 제외)
|
|
186
|
+
deai clean -r ~/code --yes --push # 확인 없이, 끝나면 force-push까지
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
**3. GitHub에 반영.** `--push`를 붙이지 않으면 내 컴퓨터 밖으로 아무것도 나가지 않습니다. 결과를 확인한 뒤
|
|
190
|
+
같은 명령에 `--push`를 붙여 다시 실행하세요. `git push --all`은 쓰지 마세요. 원격보다 뒤처진 로컬 브랜치까지
|
|
191
|
+
덮어써서 원격의 최신 커밋이 사라질 수 있습니다.
|
|
192
|
+
|
|
193
|
+
push한 뒤 GitHub의 Contributors 목록이 갱신되기까지 시간이 좀 걸릴 수 있습니다.
|
|
194
|
+
|
|
195
|
+
### 옵션
|
|
196
|
+
|
|
197
|
+
| 옵션 | 명령 | 하는 일 |
|
|
198
|
+
|---|---|---|
|
|
199
|
+
| `-r`, `--recursive` | scan, clean | 경로 아래 저장소를 모두 찾음 |
|
|
200
|
+
| `--me EMAIL` | scan, clean | 내 다른 이메일 (여러 번 지정 가능). `git config user.email`은 항상 포함 |
|
|
201
|
+
| `--yes` | clean | 확인 질문 없이 진행 |
|
|
202
|
+
| `--push` | clean | 끝나면 브랜치(`--force-with-lease`)와 태그를 force-push |
|
|
203
|
+
| `--as '이름 <이메일>'` | clean | AI가 작성자로 된 커밋을 바꿀 이름 (기본값: `git config user.*`) |
|
|
204
|
+
| `--include-shared` | clean | `shared` 저장소도 다시 씀 (보통은 쓰지 마세요) |
|
|
205
|
+
|
|
206
|
+
### 지우는 것
|
|
207
|
+
- AI 도구 이름이나 이메일이 들어간 `Co-authored-by:` 줄. **사람 공동저자 줄은 남깁니다.**
|
|
208
|
+
- `🤖 Generated with [Claude Code](...)` 같은 "Generated with \<AI 도구\>" 줄
|
|
209
|
+
- AI가 작성자나 커미터로 된 커밋. 이 커밋들은 내 이름으로 바꿉니다.
|
|
210
|
+
|
|
211
|
+
### 안전장치
|
|
212
|
+
- **커밋 안 한 변경이 있으면 실행하지 않습니다.**
|
|
213
|
+
- **먼저 백업합니다.** 모든 브랜치와 태그를 `.git/deai-backup-<시각>.bundle`로 저장합니다. 되돌리려면:
|
|
214
|
+
```bash
|
|
215
|
+
git fetch --force .git/deai-backup-20261014-222005.bundle 'refs/*:refs/*'
|
|
216
|
+
```
|
|
217
|
+
- **원격 커밋을 잃지 않습니다.** `--push`를 붙이면 먼저 fetch합니다.
|
|
218
|
+
- 원격보다 뒤처진 로컬 브랜치는 먼저 fast-forward합니다.
|
|
219
|
+
- 원격과 갈라진 브랜치가 있으면 멈춥니다.
|
|
220
|
+
- 브랜치마다 fetch 시점의 커밋을 lease로 걸어 force-push합니다.
|
|
221
|
+
- upstream이 있는 브랜치만 push하고, 로컬 전용 브랜치는 그대로 둡니다.
|
|
222
|
+
- **fork는 보호합니다.** 다른 사람이 쓴 커밋이 걸린 저장소는 `shared`로 표시하고 건너뜁니다.
|
|
223
|
+
다시 쓰면 upstream과 기록이 갈라지기 때문입니다.
|
|
224
|
+
|
|
225
|
+
### 알아둘 점
|
|
226
|
+
- **커밋 해시가 바뀝니다.** 처음 고쳐진 커밋부터 그 뒤가 모두 바뀝니다. 다른 사람이 clone해 둔 저장소는
|
|
227
|
+
다시 clone하거나 `git fetch && git reset --hard origin/<브랜치>`를 해야 합니다.
|
|
228
|
+
- **열린 PR**이 다시 쓴 브랜치에 있으면 PR을 새로 만들어야 합니다.
|
|
229
|
+
- **GPG 서명**은 사라집니다. 서명한 내용이 바뀌었기 때문입니다.
|
|
230
|
+
- **보호된 브랜치**는 force-push가 거절될 수 있습니다. 잠깐 허용하거나 새 브랜치로 push하세요.
|
|
231
|
+
|
|
232
|
+
### 다시 생기지 않게 하기
|
|
233
|
+
AI 도구에서 공동저자 표시를 끄세요. Claude Code라면 `~/.claude/settings.json`에 다음을 넣습니다
|
|
234
|
+
(빈 문자열이면 그 줄을 넣지 않습니다. 예전 설정 `includeCoAuthoredBy`는 deprecated입니다).
|
|
235
|
+
|
|
236
|
+
```json
|
|
237
|
+
{ "attribution": { "commit": "", "pr": "" } }
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
### 동작 방식
|
|
241
|
+
`git fast-export --branches --tags` → 메시지 필터(표준 라이브러리 Python) → `git fast-import --force`.
|
|
242
|
+
git-filter-repo가 필요 없습니다. 파일 내용은 손대지 않으므로 모든 트리가 원래와 바이트 단위로 같습니다.
|
|
243
|
+
|
|
244
|
+
---
|
|
245
|
+
|
|
246
|
+
MIT License
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "git-deai"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Find and remove AI co-author credits from git history"
|
|
5
|
+
authors = [{ name = "Isaac Yong", email = "isaacyong@naver.com" }]
|
|
6
|
+
readme = "README.md"
|
|
7
|
+
requires-python = ">=3.10"
|
|
8
|
+
license = "MIT"
|
|
9
|
+
license-files = ["LICENSE*"]
|
|
10
|
+
dependencies = []
|
|
11
|
+
keywords = ["git", "co-author", "ai", "history", "cli"]
|
|
12
|
+
classifiers = [
|
|
13
|
+
"Development Status :: 3 - Alpha",
|
|
14
|
+
"Environment :: Console",
|
|
15
|
+
"Intended Audience :: Developers",
|
|
16
|
+
"Operating System :: OS Independent",
|
|
17
|
+
"Programming Language :: Python :: 3",
|
|
18
|
+
"Topic :: Software Development :: Version Control :: Git",
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
[project.urls]
|
|
22
|
+
Homepage = "https://github.com/NaNIsaac/deai"
|
|
23
|
+
Repository = "https://github.com/NaNIsaac/deai"
|
|
24
|
+
Issues = "https://github.com/NaNIsaac/deai/issues"
|
|
25
|
+
|
|
26
|
+
[project.scripts]
|
|
27
|
+
deai = "deai.cli:main"
|
|
28
|
+
|
|
29
|
+
[build-system]
|
|
30
|
+
requires = ["hatchling>=1.27"]
|
|
31
|
+
build-backend = "hatchling.build"
|
|
32
|
+
|
|
33
|
+
[tool.hatch.build.targets.wheel]
|
|
34
|
+
packages = ["src/deai"]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""deai: find and remove AI co-author credits from git history."""
|
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
"""deai: find and remove AI co-author credits from git history.
|
|
2
|
+
|
|
3
|
+
AI coding tools append trailers such as ``Co-Authored-By: Claude <noreply@anthropic.com>``
|
|
4
|
+
to commit messages. GitHub reads those trailers and lists the tool as a contributor.
|
|
5
|
+
deai finds them, and on request rewrites the history without them.
|
|
6
|
+
|
|
7
|
+
deai scan [PATH ...] [-r] report, change nothing
|
|
8
|
+
deai clean [PATH ...] [-r] [--yes] [--push]
|
|
9
|
+
|
|
10
|
+
What ``clean`` removes from every commit message:
|
|
11
|
+
- ``Co-authored-by:`` trailers whose name or e-mail matches a known AI tool;
|
|
12
|
+
- "Generated with <AI tool>" lines (e.g. "🤖 Generated with [Claude Code](...)").
|
|
13
|
+
Human co-authors stay. Commits *authored* or *committed* by an AI identity are re-attributed
|
|
14
|
+
to the repository's ``git config user.name / user.email`` (or ``--as``).
|
|
15
|
+
|
|
16
|
+
Safety:
|
|
17
|
+
- the worktree must have no uncommitted changes to tracked files;
|
|
18
|
+
- a full backup bundle is written to ``.git/deai-backup-<time>.bundle`` first;
|
|
19
|
+
- only local branches and tags are rewritten. Remote-tracking refs keep the old history,
|
|
20
|
+
so ``--push`` can use ``--force-with-lease`` against what the remote really has;
|
|
21
|
+
- nothing is pushed unless ``--push`` is given.
|
|
22
|
+
|
|
23
|
+
Rewriting changes every commit hash from the first cleaned commit on. Anyone else with a
|
|
24
|
+
clone must re-clone or rebase afterwards.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import argparse
|
|
30
|
+
import os
|
|
31
|
+
import re
|
|
32
|
+
import subprocess
|
|
33
|
+
import sys
|
|
34
|
+
import time
|
|
35
|
+
from dataclasses import dataclass, field
|
|
36
|
+
from pathlib import Path
|
|
37
|
+
|
|
38
|
+
AI = re.compile(
|
|
39
|
+
r"claude|anthropic|copilot|cursor(agent)?\b|chatgpt|openai|\bcodex\b|gemini|\bdevin\b|"
|
|
40
|
+
r"\baider\b|codeium|windsurf|tabnine|codewhisperer|amazon q\b|\bjules\b|sweep-?ai",
|
|
41
|
+
re.IGNORECASE,
|
|
42
|
+
)
|
|
43
|
+
COAUTHOR = re.compile(r"^[ \t]*co-authored-by:[ \t]*(?P<who>.*)$", re.IGNORECASE)
|
|
44
|
+
GENERATED = re.compile(r"^.*\bgenerated (with|by)\b(?P<who>.*)$", re.IGNORECASE)
|
|
45
|
+
IDENT = re.compile(rb"^(?P<kind>author|committer) (?P<name>.*?) <(?P<email>[^>]*)> (?P<when>.*)$")
|
|
46
|
+
SKIP_DIRS = {".git", "node_modules", ".venv", "venv", "__pycache__", ".tox", "dist", "build"}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def is_ai(text: str) -> bool:
|
|
50
|
+
return bool(AI.search(text))
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def clean_message(msg: str) -> str:
|
|
54
|
+
"""Drop AI co-author trailers and "generated with <AI>" lines; keep everything else."""
|
|
55
|
+
kept = []
|
|
56
|
+
for line in msg.splitlines():
|
|
57
|
+
m = COAUTHOR.match(line) or GENERATED.match(line)
|
|
58
|
+
if m and is_ai(m["who"]):
|
|
59
|
+
continue
|
|
60
|
+
kept.append(line)
|
|
61
|
+
while kept and not kept[-1].strip(): # the trailer block often leaves a blank tail
|
|
62
|
+
kept.pop()
|
|
63
|
+
return "\n".join(kept) + ("\n" if msg.endswith("\n") or kept else "")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
# ───────────── git plumbing ─────────────
|
|
67
|
+
|
|
68
|
+
def git(repo: Path, *args: str, check: bool = True) -> str:
|
|
69
|
+
r = subprocess.run(["git", "-C", str(repo), *args], capture_output=True, text=True,
|
|
70
|
+
encoding="utf-8", errors="replace")
|
|
71
|
+
if check and r.returncode:
|
|
72
|
+
raise RuntimeError(f"git {' '.join(args)} failed in {repo}:\n{r.stderr.strip()}")
|
|
73
|
+
return r.stdout
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def find_repos(paths: list[str], recursive: bool) -> list[Path]:
|
|
77
|
+
out = []
|
|
78
|
+
for p in map(Path, paths or ["."]):
|
|
79
|
+
p = p.resolve()
|
|
80
|
+
if (p / ".git").exists():
|
|
81
|
+
out.append(p)
|
|
82
|
+
continue
|
|
83
|
+
if not recursive:
|
|
84
|
+
print(f"skip {p}: not a git repository (use -r to search below it)", file=sys.stderr)
|
|
85
|
+
continue
|
|
86
|
+
for root, dirs, _ in os.walk(p):
|
|
87
|
+
if ".git" in dirs or (Path(root) / ".git").is_file():
|
|
88
|
+
out.append(Path(root))
|
|
89
|
+
dirs[:] = [] # do not descend into a repository
|
|
90
|
+
continue
|
|
91
|
+
dirs[:] = [d for d in dirs if d not in SKIP_DIRS and not d.startswith(".")]
|
|
92
|
+
return sorted(set(out))
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@dataclass
|
|
96
|
+
class Report:
|
|
97
|
+
repo: Path
|
|
98
|
+
commits: int = 0
|
|
99
|
+
trailers: list[str] = field(default_factory=list) # short hashes with an AI trailer/line
|
|
100
|
+
idents: list[str] = field(default_factory=list) # short hashes with an AI author/committer
|
|
101
|
+
others: set[str] = field(default_factory=set) # human authors of flagged commits who are not you
|
|
102
|
+
|
|
103
|
+
@property
|
|
104
|
+
def dirty(self) -> bool:
|
|
105
|
+
return bool(self.trailers or self.idents)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def my_emails(repo: Path, extra: list[str]) -> set[str]:
|
|
109
|
+
me = git(repo, "config", "user.email", check=False).strip()
|
|
110
|
+
return {e.lower() for e in [me, *extra] if e}
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def scan(repo: Path, me: list[str] | None = None) -> Report:
|
|
114
|
+
"""Flag commits with AI credits. A flagged commit by another human means shared history
|
|
115
|
+
(a fork, a vendored project): rewriting it would split you from upstream."""
|
|
116
|
+
r = Report(repo)
|
|
117
|
+
if not git(repo, "rev-parse", "--verify", "-q", "HEAD", check=False).strip():
|
|
118
|
+
return r # empty repository
|
|
119
|
+
mine = my_emails(repo, me or [])
|
|
120
|
+
log = git(repo, "log", "--branches", "--tags", "--format=%h%x00%an <%ae>%x00%ae%x00%cn <%ce>%x00%B%x1e")
|
|
121
|
+
for rec in filter(str.strip, log.split("\x1e")):
|
|
122
|
+
h, author, email, committer, body = rec.strip("\n").split("\x00", 4)
|
|
123
|
+
r.commits += 1
|
|
124
|
+
flagged = False
|
|
125
|
+
if clean_message(body) != clean_message_noop(body):
|
|
126
|
+
r.trailers.append(h)
|
|
127
|
+
flagged = True
|
|
128
|
+
if is_ai(author) or is_ai(committer):
|
|
129
|
+
r.idents.append(h)
|
|
130
|
+
flagged = True
|
|
131
|
+
if flagged and not is_ai(author) and email.lower() not in mine:
|
|
132
|
+
r.others.add(author)
|
|
133
|
+
return r
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def clean_message_noop(msg: str) -> str:
|
|
137
|
+
"""The same normalisation as clean_message without removing anything (fair comparison)."""
|
|
138
|
+
kept = msg.splitlines()
|
|
139
|
+
while kept and not kept[-1].strip():
|
|
140
|
+
kept.pop()
|
|
141
|
+
return "\n".join(kept) + ("\n" if msg.endswith("\n") or kept else "")
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
# ───────────── rewrite ─────────────
|
|
145
|
+
|
|
146
|
+
def rewrite_stream(src, dst, ident: bytes) -> int:
|
|
147
|
+
"""Copy a ``git fast-export`` stream, cleaning commit messages and AI identities.
|
|
148
|
+
|
|
149
|
+
Returns the number of commits changed. Blob and tag payloads pass through untouched;
|
|
150
|
+
only the first ``data`` block after a ``commit`` command is a commit message.
|
|
151
|
+
"""
|
|
152
|
+
changed, in_commit, msg_next, touched = 0, False, False, False
|
|
153
|
+
while line := src.readline():
|
|
154
|
+
if line.startswith(b"commit "):
|
|
155
|
+
in_commit, msg_next, touched = True, True, False
|
|
156
|
+
elif line.startswith((b"blob", b"tag ", b"reset ")):
|
|
157
|
+
in_commit, msg_next = False, False
|
|
158
|
+
elif in_commit and (m := IDENT.match(line.rstrip(b"\n"))):
|
|
159
|
+
who = (m["name"] + b" <" + m["email"] + b">").decode("utf-8", "surrogateescape")
|
|
160
|
+
if is_ai(who):
|
|
161
|
+
line = m["kind"] + b" " + ident + b" " + m["when"] + b"\n"
|
|
162
|
+
touched = True
|
|
163
|
+
elif line.startswith(b"data "):
|
|
164
|
+
payload = src.read(int(line[5:]))
|
|
165
|
+
if msg_next:
|
|
166
|
+
msg_next = False
|
|
167
|
+
text = payload.decode("utf-8", "surrogateescape")
|
|
168
|
+
new = clean_message(text)
|
|
169
|
+
if new != clean_message_noop(text):
|
|
170
|
+
payload, touched = new.encode("utf-8", "surrogateescape"), True
|
|
171
|
+
changed += touched
|
|
172
|
+
dst.write(b"data %d\n" % len(payload) + payload)
|
|
173
|
+
continue
|
|
174
|
+
dst.write(line)
|
|
175
|
+
return changed
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
@dataclass
|
|
179
|
+
class Tracked:
|
|
180
|
+
branch: str # local branch name
|
|
181
|
+
remote: str # e.g. origin
|
|
182
|
+
remote_ref: str # e.g. refs/heads/main on the remote
|
|
183
|
+
upstream: str # e.g. refs/remotes/origin/main
|
|
184
|
+
oid: str = "" # upstream commit at fetch time: the lease for the force-push
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def tracked_branches(repo: Path) -> list[Tracked]:
|
|
188
|
+
fmt = "%(refname:short)%00%(upstream:remotename)%00%(upstream:remoteref)%00%(upstream)"
|
|
189
|
+
out = []
|
|
190
|
+
for line in git(repo, "for-each-ref", f"--format={fmt}", "refs/heads").splitlines():
|
|
191
|
+
b, remote, rref, up = line.split("\x00")
|
|
192
|
+
oid = git(repo, "rev-parse", "-q", "--verify", up, check=False).strip() if up else ""
|
|
193
|
+
if remote and oid: # upstream configured and present after fetch
|
|
194
|
+
out.append(Tracked(b, remote, rref, up, oid))
|
|
195
|
+
return out
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def sync_with_remote(repo: Path) -> list[Tracked]:
|
|
199
|
+
"""Fetch, and make sure no local branch is behind its upstream before rewriting.
|
|
200
|
+
|
|
201
|
+
A force-push of a branch that is *behind* the remote would throw the remote's newer
|
|
202
|
+
commits away, so a branch that is strictly behind is fast-forwarded first, and a branch
|
|
203
|
+
that has diverged stops the run.
|
|
204
|
+
"""
|
|
205
|
+
git(repo, "fetch", "--all", "--quiet")
|
|
206
|
+
current = git(repo, "branch", "--show-current").strip()
|
|
207
|
+
tracked = tracked_branches(repo)
|
|
208
|
+
def is_ancestor(a: str, b: str) -> bool:
|
|
209
|
+
return subprocess.run(["git", "-C", str(repo), "merge-base", "--is-ancestor", a, b]).returncode == 0
|
|
210
|
+
|
|
211
|
+
for t in tracked:
|
|
212
|
+
if is_ancestor(t.upstream, t.branch):
|
|
213
|
+
continue # local contains the remote: fine
|
|
214
|
+
if not is_ancestor(t.branch, t.upstream):
|
|
215
|
+
raise RuntimeError(f"branch {t.branch} has diverged from {t.upstream}; reconcile it first")
|
|
216
|
+
if t.branch == current:
|
|
217
|
+
git(repo, "merge", "-q", "--ff-only", t.upstream)
|
|
218
|
+
else:
|
|
219
|
+
git(repo, "branch", "-q", "-f", t.branch, t.upstream)
|
|
220
|
+
print(f" fast-forwarded {t.branch} to {t.upstream} (it was behind the remote)")
|
|
221
|
+
return tracked
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def push_rewritten(repo: Path, tracked: list[Tracked]) -> None:
|
|
225
|
+
"""Force-push each tracked branch with an explicit lease on the commit seen at fetch time."""
|
|
226
|
+
if not tracked:
|
|
227
|
+
print(" no branch has an upstream; nothing to push")
|
|
228
|
+
return
|
|
229
|
+
for t in tracked:
|
|
230
|
+
git(repo, "push", "--quiet", f"--force-with-lease={t.remote_ref}:{t.oid}", t.remote,
|
|
231
|
+
f"refs/heads/{t.branch}:{t.remote_ref}")
|
|
232
|
+
print(f" pushed {t.branch} -> {t.remote}/{t.remote_ref.removeprefix('refs/heads/')}")
|
|
233
|
+
for remote in sorted({t.remote for t in tracked}):
|
|
234
|
+
git(repo, "push", "--quiet", "--force", "--tags", remote)
|
|
235
|
+
print(f" pushed tags -> {remote}")
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def clean(repo: Path, as_ident: str | None, push: bool) -> int:
|
|
239
|
+
if git(repo, "status", "--porcelain", "--untracked-files=no").strip():
|
|
240
|
+
raise RuntimeError("uncommitted changes to tracked files; commit or stash them first")
|
|
241
|
+
tracked = sync_with_remote(repo) if push else []
|
|
242
|
+
if as_ident is None:
|
|
243
|
+
name, email = git(repo, "config", "user.name").strip(), git(repo, "config", "user.email").strip()
|
|
244
|
+
as_ident = f"{name} <{email}>"
|
|
245
|
+
if not re.fullmatch(r"[^<>]+ <[^<>]+>", as_ident) or is_ai(as_ident):
|
|
246
|
+
raise RuntimeError(f"--as must be a human 'Name <email>', got {as_ident!r}")
|
|
247
|
+
|
|
248
|
+
backup = repo / ".git" / f"deai-backup-{time.strftime('%Y%m%d-%H%M%S')}.bundle"
|
|
249
|
+
git(repo, "bundle", "create", str(backup), "--branches", "--tags")
|
|
250
|
+
|
|
251
|
+
exp = subprocess.Popen(["git", "-C", str(repo), "fast-export", "--branches", "--tags",
|
|
252
|
+
"--signed-tags=strip", "--use-done-feature"], stdout=subprocess.PIPE)
|
|
253
|
+
imp = subprocess.Popen(["git", "-C", str(repo), "fast-import", "--force", "--quiet"],
|
|
254
|
+
stdin=subprocess.PIPE)
|
|
255
|
+
assert exp.stdout and imp.stdin
|
|
256
|
+
changed = rewrite_stream(exp.stdout, imp.stdin, as_ident.encode("utf-8"))
|
|
257
|
+
imp.stdin.close()
|
|
258
|
+
if exp.wait() or imp.wait():
|
|
259
|
+
raise RuntimeError(f"fast-export/fast-import failed; history is unchanged or restorable from {backup}")
|
|
260
|
+
git(repo, "reset", "-q", "--keep", "HEAD") # trees are identical; refresh the index only
|
|
261
|
+
print(f" rewrote {changed} commit(s); backup: {backup}")
|
|
262
|
+
|
|
263
|
+
if push:
|
|
264
|
+
push_rewritten(repo, tracked)
|
|
265
|
+
else:
|
|
266
|
+
print(" not pushed. Run again with --push to publish (it fetches first and never pushes a stale branch).")
|
|
267
|
+
return changed
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
# ───────────── CLI ─────────────
|
|
271
|
+
|
|
272
|
+
def main(argv: list[str] | None = None) -> int:
|
|
273
|
+
ap = argparse.ArgumentParser(prog="deai", description=(__doc__ or "").split("\n\n")[0])
|
|
274
|
+
sub = ap.add_subparsers(dest="cmd", required=True)
|
|
275
|
+
for name, help_ in (("scan", "report AI credits, change nothing"),
|
|
276
|
+
("clean", "rewrite history without AI credits")):
|
|
277
|
+
p = sub.add_parser(name, help=help_)
|
|
278
|
+
p.add_argument("paths", nargs="*", help="repositories (default: current directory)")
|
|
279
|
+
p.add_argument("-r", "--recursive", action="store_true", help="find repositories below PATH")
|
|
280
|
+
p.add_argument("--me", action="append", default=[], metavar="EMAIL",
|
|
281
|
+
help="another e-mail of yours (repeatable; git config user.email is always included)")
|
|
282
|
+
c = sub.choices["clean"]
|
|
283
|
+
c.add_argument("--yes", action="store_true", help="do not ask before rewriting")
|
|
284
|
+
c.add_argument("--push", action="store_true", help="force-push branches and tags afterwards")
|
|
285
|
+
c.add_argument("--as", dest="as_ident", metavar="'NAME <EMAIL>'",
|
|
286
|
+
help="identity for AI-authored commits (default: git config user.*)")
|
|
287
|
+
c.add_argument("--include-shared", action="store_true",
|
|
288
|
+
help="also rewrite repositories where other people's commits carry AI credits")
|
|
289
|
+
a = ap.parse_args(argv)
|
|
290
|
+
|
|
291
|
+
repos = find_repos(a.paths, a.recursive)
|
|
292
|
+
if not repos:
|
|
293
|
+
print("no repositories found", file=sys.stderr)
|
|
294
|
+
return 1
|
|
295
|
+
reports = [scan(r, a.me) for r in repos]
|
|
296
|
+
for rp in reports:
|
|
297
|
+
mark = "shared" if rp.others else "AI credits" if rp.dirty else "clean"
|
|
298
|
+
print(f"{mark:>10} {rp.repo} ({rp.commits} commits; trailers {len(rp.trailers)}, "
|
|
299
|
+
f"AI author/committer {len(rp.idents)})")
|
|
300
|
+
for kind, hs in (("trailer", rp.trailers), ("identity", rp.idents)):
|
|
301
|
+
if hs:
|
|
302
|
+
print(f" {kind}: {' '.join(hs[:8])}{' ...' if len(hs) > 8 else ''}")
|
|
303
|
+
if rp.others:
|
|
304
|
+
names = sorted(rp.others)
|
|
305
|
+
print(f" by others: {', '.join(names[:4])}{' ...' if len(names) > 4 else ''}")
|
|
306
|
+
if a.cmd == "scan":
|
|
307
|
+
if any(rp.others for rp in reports):
|
|
308
|
+
print("\n'shared': other people's commits are flagged (fork or shared history). "
|
|
309
|
+
"clean skips these unless --include-shared; add --me for your other e-mails.")
|
|
310
|
+
return 0
|
|
311
|
+
|
|
312
|
+
todo = [rp for rp in reports if rp.dirty and (a.include_shared or not rp.others)]
|
|
313
|
+
for rp in reports:
|
|
314
|
+
if rp.dirty and rp.others and not a.include_shared:
|
|
315
|
+
print(f"skip {rp.repo}: shared history (see --include-shared)")
|
|
316
|
+
if not todo:
|
|
317
|
+
print("nothing to clean")
|
|
318
|
+
return 0
|
|
319
|
+
if not a.yes:
|
|
320
|
+
ans = input(f"Rewrite history of {len(todo)} repositor{'y' if len(todo) == 1 else 'ies'}? "
|
|
321
|
+
"Commit hashes will change. [y/N] ")
|
|
322
|
+
if ans.strip().lower() not in ("y", "yes"):
|
|
323
|
+
print("aborted")
|
|
324
|
+
return 1
|
|
325
|
+
failed = 0
|
|
326
|
+
for rp in todo:
|
|
327
|
+
print(f"cleaning {rp.repo}")
|
|
328
|
+
try:
|
|
329
|
+
clean(rp.repo, a.as_ident, a.push)
|
|
330
|
+
except RuntimeError as e:
|
|
331
|
+
failed += 1
|
|
332
|
+
print(f" error: {e}", file=sys.stderr)
|
|
333
|
+
return 1 if failed else 0
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
if __name__ == "__main__":
|
|
337
|
+
sys.exit(main())
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""End-to-end check on a throwaway repository: python tests/test_deai.py (or pytest)."""
|
|
2
|
+
|
|
3
|
+
import subprocess
|
|
4
|
+
import sys
|
|
5
|
+
import tempfile
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
|
|
9
|
+
from deai.cli import clean, clean_message, scan # noqa: E402
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def git(repo, *args, env=None):
|
|
13
|
+
return subprocess.run(["git", "-C", str(repo), *args], check=True, capture_output=True, text=True,
|
|
14
|
+
encoding="utf-8", env=env).stdout
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def test_messages():
|
|
18
|
+
msg = ("Fix the solver\n\nBody.\n\nCo-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>\n"
|
|
19
|
+
"Co-authored-by: Jane Doe <jane@uni.kr>\n")
|
|
20
|
+
assert clean_message(msg) == "Fix the solver\n\nBody.\n\nCo-authored-by: Jane Doe <jane@uni.kr>\n"
|
|
21
|
+
pr = "Summary\n\n🤖 Generated with [Claude Code](https://claude.com/claude-code)\n"
|
|
22
|
+
assert clean_message(pr) == "Summary\n"
|
|
23
|
+
human = "Generated with numpy, by hand\n"
|
|
24
|
+
assert clean_message(human) == human # "generated with" alone is not an AI credit
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def test_rewrite():
|
|
28
|
+
import os
|
|
29
|
+
with tempfile.TemporaryDirectory() as d:
|
|
30
|
+
repo = Path(d)
|
|
31
|
+
git(repo, "init", "-q", "-b", "main")
|
|
32
|
+
git(repo, "config", "user.name", "Isaac Yong")
|
|
33
|
+
git(repo, "config", "user.email", "isaac@example.com")
|
|
34
|
+
(repo / "a.txt").write_text("one\n")
|
|
35
|
+
git(repo, "add", "a.txt")
|
|
36
|
+
git(repo, "commit", "-q", "-m", "first\n\nCo-Authored-By: Claude <noreply@anthropic.com>")
|
|
37
|
+
(repo / "a.txt").write_text("two\n")
|
|
38
|
+
git(repo, "commit", "-q", "-am", "second\n\nCo-authored-by: Jane Doe <jane@uni.kr>")
|
|
39
|
+
env = dict(os.environ, GIT_AUTHOR_NAME="Claude", GIT_AUTHOR_EMAIL="noreply@anthropic.com")
|
|
40
|
+
(repo / "a.txt").write_text("three\n")
|
|
41
|
+
git(repo, "commit", "-q", "-am", "third", env=env)
|
|
42
|
+
git(repo, "tag", "v1")
|
|
43
|
+
tree = git(repo, "rev-parse", "HEAD^{tree}")
|
|
44
|
+
|
|
45
|
+
r = scan(repo)
|
|
46
|
+
assert r.commits == 3 and len(r.trailers) == 1 and len(r.idents) == 1, r
|
|
47
|
+
|
|
48
|
+
assert clean(repo, None, push=False) == 2
|
|
49
|
+
assert not scan(repo).dirty
|
|
50
|
+
log = git(repo, "log", "--format=%an <%ae>|%B%x1e")
|
|
51
|
+
assert "anthropic" not in log.lower() and "claude" not in log.lower()
|
|
52
|
+
assert "Jane Doe" in log # human co-author kept
|
|
53
|
+
assert git(repo, "rev-parse", "HEAD^{tree}") == tree # content untouched
|
|
54
|
+
assert git(repo, "rev-parse", "v1") == git(repo, "rev-parse", "HEAD") # tag moved along
|
|
55
|
+
assert list((repo / ".git").glob("deai-backup-*.bundle"))
|
|
56
|
+
assert not git(repo, "status", "--porcelain")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def test_push_never_loses_remote_commits():
|
|
60
|
+
"""A local branch behind its remote must be fast-forwarded, not force-pushed backward."""
|
|
61
|
+
with tempfile.TemporaryDirectory() as d:
|
|
62
|
+
d = Path(d)
|
|
63
|
+
git(d, "init", "-q", "--bare", "-b", "main", "remote.git")
|
|
64
|
+
for name in ("a", "b"):
|
|
65
|
+
git(d, "clone", "-q", str(d / "remote.git"), name)
|
|
66
|
+
git(d / name, "config", "user.name", "Isaac Yong")
|
|
67
|
+
git(d / name, "config", "user.email", "isaac@example.com")
|
|
68
|
+
a, b = d / "a", d / "b"
|
|
69
|
+
(a / "f").write_text("1\n")
|
|
70
|
+
git(a, "add", "f")
|
|
71
|
+
git(a, "commit", "-q", "-m", "one\n\nCo-Authored-By: Claude <noreply@anthropic.com>")
|
|
72
|
+
git(a, "push", "-q", "origin", "main")
|
|
73
|
+
git(b, "pull", "-q", "origin", "main") # b moves ahead; a stays behind
|
|
74
|
+
(b / "f").write_text("2\n")
|
|
75
|
+
git(b, "commit", "-q", "-am", "two (only on the remote)")
|
|
76
|
+
git(b, "push", "-q", "origin", "main")
|
|
77
|
+
|
|
78
|
+
assert clean(a, None, push=True) == 1
|
|
79
|
+
remote_log = git(d / "remote.git", "log", "--format=%s%n%b", "main")
|
|
80
|
+
assert "two (only on the remote)" in remote_log, remote_log # not thrown away
|
|
81
|
+
assert "anthropic" not in remote_log.lower()
|
|
82
|
+
|
|
83
|
+
(b / "f").write_text("3\n") # diverge: b has stale history + a new commit
|
|
84
|
+
git(b, "commit", "-q", "-am", "three\n\nCo-Authored-By: Claude <noreply@anthropic.com>")
|
|
85
|
+
try:
|
|
86
|
+
clean(b, None, push=True)
|
|
87
|
+
except RuntimeError as e:
|
|
88
|
+
assert "diverged" in str(e)
|
|
89
|
+
else:
|
|
90
|
+
raise AssertionError("a diverged branch must stop the run")
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
if __name__ == "__main__":
|
|
94
|
+
test_messages()
|
|
95
|
+
test_rewrite()
|
|
96
|
+
test_push_never_loses_remote_commits()
|
|
97
|
+
print("deai: all checks passed")
|