git-deai 0.1.0__py3-none-any.whl

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.
deai/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """deai: find and remove AI co-author credits from git history."""
deai/cli.py ADDED
@@ -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,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
@@ -0,0 +1,7 @@
1
+ deai/__init__.py,sha256=grRPcM80gzRAjhRdM31PsmuTg0L2W6MDDalU8jp5sLk,67
2
+ deai/cli.py,sha256=dIRKbRULoNeIVI-xS8oiY_4KuiDs-BL01FeR4RMkuJc,15322
3
+ git_deai-0.1.0.dist-info/METADATA,sha256=9aeD1NxcxemwslrzlYqb0O33iX1fc8bXiVZd3v-0JBU,11374
4
+ git_deai-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
5
+ git_deai-0.1.0.dist-info/entry_points.txt,sha256=5pcNEvTgoCpa9tC0Lq82FMC7JqHwcHNHsEgp1G1wcM4,39
6
+ git_deai-0.1.0.dist-info/licenses/LICENSE,sha256=hMVbLViuzZqpmkib0hPsT09tXZ5PARWhGbDgDszcyVc,1067
7
+ git_deai-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ deai = deai.cli:main
@@ -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.