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 +1 -0
- deai/cli.py +337 -0
- git_deai-0.1.0.dist-info/METADATA +266 -0
- git_deai-0.1.0.dist-info/RECORD +7 -0
- git_deai-0.1.0.dist-info/WHEEL +4 -0
- git_deai-0.1.0.dist-info/entry_points.txt +2 -0
- git_deai-0.1.0.dist-info/licenses/LICENSE +21 -0
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,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.
|