declaude 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.
@@ -0,0 +1,13 @@
1
+ __pycache__/
2
+ *.pyc
3
+ .declaude-backups/
4
+ *.bundle
5
+ *.tar.gz
6
+
7
+ # build / packaging artifacts
8
+ build/
9
+ dist/
10
+ *.egg-info/
11
+ .eggs/
12
+ .pytest_cache/
13
+ .venv/
declaude-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 ediiloupatty
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.
@@ -0,0 +1,172 @@
1
+ Metadata-Version: 2.4
2
+ Name: declaude
3
+ Version: 0.1.0
4
+ Summary: Remove Claude/AI attribution from a GitHub repo: clean history, force-push, and refresh the Contributors graph.
5
+ Project-URL: Homepage, https://github.com/ediiloupatty/declaude
6
+ Project-URL: Repository, https://github.com/ediiloupatty/declaude
7
+ Project-URL: Issues, https://github.com/ediiloupatty/declaude/issues
8
+ Author: ediiloupatty
9
+ License: MIT License
10
+
11
+ Copyright (c) 2026 ediiloupatty
12
+
13
+ Permission is hereby granted, free of charge, to any person obtaining a copy
14
+ of this software and associated documentation files (the "Software"), to deal
15
+ in the Software without restriction, including without limitation the rights
16
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
17
+ copies of the Software, and to permit persons to whom the Software is
18
+ furnished to do so, subject to the following conditions:
19
+
20
+ The above copyright notice and this permission notice shall be included in all
21
+ copies or substantial portions of the Software.
22
+
23
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
24
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
25
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
26
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
27
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
28
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
29
+ SOFTWARE.
30
+ License-File: LICENSE
31
+ Keywords: attribution,claude,co-authored-by,filter-repo,git,github
32
+ Classifier: Development Status :: 4 - Beta
33
+ Classifier: Environment :: Console
34
+ Classifier: Intended Audience :: Developers
35
+ Classifier: License :: OSI Approved :: MIT License
36
+ Classifier: Programming Language :: Python :: 3
37
+ Classifier: Topic :: Software Development :: Version Control :: Git
38
+ Requires-Python: >=3.8
39
+ Requires-Dist: git-filter-repo>=2.38
40
+ Provides-Extra: dev
41
+ Requires-Dist: pytest>=7; extra == 'dev'
42
+ Description-Content-Type: text/markdown
43
+
44
+ # declaude
45
+
46
+ **Remove Claude/AI attribution from a GitHub repo — in one command.**
47
+
48
+ ```bash
49
+ declaude OWNER/REPO
50
+ ```
51
+
52
+ It clones the repo, strips Claude/AI traces from your **entire commit history**
53
+ (e.g. `Co-Authored-By: Claude <noreply@anthropic.com>` or _"Generated with
54
+ Claude Code"_), force-pushes the cleaned branches, and **refreshes GitHub's
55
+ Contributors graph** so `@claude` actually disappears — all without touching your
56
+ code or your commit authorship.
57
+
58
+ ## Why `@claude` won't go away by itself
59
+
60
+ AI tools append a `Co-Authored-By: Claude …` trailer to commits, and GitHub's
61
+ **Insights → Contributors graph** counts those co-authors. That graph is a
62
+ **cached, background-computed view** — a force-push (or a flush, or a commit)
63
+ *on its own* doesn't reliably update it, so `@claude` lingers even after the
64
+ history and the REST API are clean.
65
+
66
+ What actually works is a specific **order**:
67
+
68
+ > **remove Claude → flush (rename the default branch) → push a fresh commit**
69
+
70
+ The flush resets GitHub's cached graph; the following commit triggers a
71
+ recompute against the now-clean history. `declaude` does all three for you (the
72
+ refresh commit is `chore: refresh GitHub contributors`, reusing your latest
73
+ commit's author so no new identity appears). It runs **even when the history is
74
+ already clean**, which is exactly what a previously-cleaned repo needs.
75
+
76
+ ## Install
77
+
78
+ ```bash
79
+ pip install declaude # installs the `declaude` command + git-filter-repo
80
+ ```
81
+
82
+ Not yet on PyPI? Install straight from GitHub:
83
+
84
+ ```bash
85
+ pip install git+https://github.com/ediiloupatty/declaude
86
+ # or, from a local clone:
87
+ pip install .
88
+ ```
89
+
90
+ `pip` puts a `declaude` command on your PATH and pulls in `git-filter-repo`
91
+ automatically — no manual setup. The one prerequisite pip can't install is the
92
+ **GitHub CLI**, used to flush GitHub's Contributors-graph cache:
93
+
94
+ ```bash
95
+ # install gh from https://cli.github.com, then log in:
96
+ gh auth login
97
+ ```
98
+
99
+ declaude checks for `gh` and a valid login up front and tells you exactly what's
100
+ missing before it touches anything.
101
+
102
+ ## Usage
103
+
104
+ ```bash
105
+ declaude ediiloupatty/my-repo # OWNER/REPO slug
106
+ declaude https://github.com/ediiloupatty/my-repo # full GitHub URL
107
+
108
+ declaude my-repo --dry-run # show the plan, change nothing
109
+ declaude my-repo -y # skip the confirmation prompt
110
+ declaude my-repo --no-refresh # clean + push only, skip the refresh commit
111
+ declaude my-repo --no-backup # skip the restorable backup bundle (not recommended)
112
+
113
+ declaude prevent # turn off Claude Code attribution going forward
114
+ declaude --version # print the installed version
115
+ ```
116
+
117
+ The repo is cloned to a temp dir, cleaned, force-pushed, refreshed, then
118
+ discarded — you never clone by hand. Before any rewrite, a **backup bundle** is
119
+ written to `~/.declaude-backups/` and is fully restorable:
120
+
121
+ ```bash
122
+ git -C <repo> fetch ~/.declaude-backups/<name>.bundle '*:*'
123
+ ```
124
+
125
+ ## Honest caveats
126
+
127
+ - **Claude authorship (rare).** If a commit's _author_ is Claude (not just a
128
+ co-author), `declaude` warns but does not change it — use
129
+ `git filter-repo --mailmap` to rewrite authorship.
130
+ - **The refresh commit.** declaude leaves one `chore: refresh GitHub contributors`
131
+ empty commit on the default branch (authored as you). It's harmless; drop it
132
+ later with `git rebase` if you like. Skip the whole refresh with `--no-refresh`.
133
+ - **Shared repos.** The flush renames the default branch and back. Collaborators
134
+ with a local clone may see GitHub's "default branch renamed" notice. Use
135
+ `--no-refresh` if that's a problem.
136
+ - **Graph lag.** Even after the flush + refresh push, the Contributors graph can
137
+ take a few minutes to update. Recheck in Incognito.
138
+ - **Closed pull requests.** GitHub keeps old commits in `refs/pull/N/head`, which
139
+ users can't delete. The Contributors graph is computed from the default branch
140
+ (clean + refreshed), so `@claude` should still drop; if it persists, only
141
+ GitHub Support can purge the PR-ref cache.
142
+
143
+ ## Commands
144
+
145
+ | Command | Purpose |
146
+ |---|---|
147
+ | `declaude TARGET [-y] [--dry-run] [--no-refresh] [--no-backup]` | Clean history + force-push + refresh contributors graph. `TARGET` = GitHub URL or `OWNER/REPO`. |
148
+ | `declaude prevent` | Set `includeCoAuthoredBy:false` in `~/.claude/settings.json`. |
149
+ | `declaude --version` | Print the installed version. |
150
+
151
+ ## Requirements
152
+
153
+ - Python 3.8+ and `pip`
154
+ - `git`
155
+ - `gh` (GitHub CLI, logged in) — install separately from <https://cli.github.com>
156
+ - `git-filter-repo` — installed automatically as a pip dependency
157
+
158
+ **Windows:** works in PowerShell and Windows Terminal (ANSI colors are enabled
159
+ automatically; set `NO_COLOR=1` to disable). `git`, `gh`, and Python must be on
160
+ your `PATH`.
161
+
162
+ ## Development
163
+
164
+ ```bash
165
+ pip install -e ".[dev]" # editable install with test deps
166
+ python -m pytest # run the scrubber tests
167
+ python -m declaude --help # run without installing the console script
168
+ ```
169
+
170
+ ## License
171
+
172
+ [MIT](LICENSE) © ediiloupatty
@@ -0,0 +1,129 @@
1
+ # declaude
2
+
3
+ **Remove Claude/AI attribution from a GitHub repo — in one command.**
4
+
5
+ ```bash
6
+ declaude OWNER/REPO
7
+ ```
8
+
9
+ It clones the repo, strips Claude/AI traces from your **entire commit history**
10
+ (e.g. `Co-Authored-By: Claude <noreply@anthropic.com>` or _"Generated with
11
+ Claude Code"_), force-pushes the cleaned branches, and **refreshes GitHub's
12
+ Contributors graph** so `@claude` actually disappears — all without touching your
13
+ code or your commit authorship.
14
+
15
+ ## Why `@claude` won't go away by itself
16
+
17
+ AI tools append a `Co-Authored-By: Claude …` trailer to commits, and GitHub's
18
+ **Insights → Contributors graph** counts those co-authors. That graph is a
19
+ **cached, background-computed view** — a force-push (or a flush, or a commit)
20
+ *on its own* doesn't reliably update it, so `@claude` lingers even after the
21
+ history and the REST API are clean.
22
+
23
+ What actually works is a specific **order**:
24
+
25
+ > **remove Claude → flush (rename the default branch) → push a fresh commit**
26
+
27
+ The flush resets GitHub's cached graph; the following commit triggers a
28
+ recompute against the now-clean history. `declaude` does all three for you (the
29
+ refresh commit is `chore: refresh GitHub contributors`, reusing your latest
30
+ commit's author so no new identity appears). It runs **even when the history is
31
+ already clean**, which is exactly what a previously-cleaned repo needs.
32
+
33
+ ## Install
34
+
35
+ ```bash
36
+ pip install declaude # installs the `declaude` command + git-filter-repo
37
+ ```
38
+
39
+ Not yet on PyPI? Install straight from GitHub:
40
+
41
+ ```bash
42
+ pip install git+https://github.com/ediiloupatty/declaude
43
+ # or, from a local clone:
44
+ pip install .
45
+ ```
46
+
47
+ `pip` puts a `declaude` command on your PATH and pulls in `git-filter-repo`
48
+ automatically — no manual setup. The one prerequisite pip can't install is the
49
+ **GitHub CLI**, used to flush GitHub's Contributors-graph cache:
50
+
51
+ ```bash
52
+ # install gh from https://cli.github.com, then log in:
53
+ gh auth login
54
+ ```
55
+
56
+ declaude checks for `gh` and a valid login up front and tells you exactly what's
57
+ missing before it touches anything.
58
+
59
+ ## Usage
60
+
61
+ ```bash
62
+ declaude ediiloupatty/my-repo # OWNER/REPO slug
63
+ declaude https://github.com/ediiloupatty/my-repo # full GitHub URL
64
+
65
+ declaude my-repo --dry-run # show the plan, change nothing
66
+ declaude my-repo -y # skip the confirmation prompt
67
+ declaude my-repo --no-refresh # clean + push only, skip the refresh commit
68
+ declaude my-repo --no-backup # skip the restorable backup bundle (not recommended)
69
+
70
+ declaude prevent # turn off Claude Code attribution going forward
71
+ declaude --version # print the installed version
72
+ ```
73
+
74
+ The repo is cloned to a temp dir, cleaned, force-pushed, refreshed, then
75
+ discarded — you never clone by hand. Before any rewrite, a **backup bundle** is
76
+ written to `~/.declaude-backups/` and is fully restorable:
77
+
78
+ ```bash
79
+ git -C <repo> fetch ~/.declaude-backups/<name>.bundle '*:*'
80
+ ```
81
+
82
+ ## Honest caveats
83
+
84
+ - **Claude authorship (rare).** If a commit's _author_ is Claude (not just a
85
+ co-author), `declaude` warns but does not change it — use
86
+ `git filter-repo --mailmap` to rewrite authorship.
87
+ - **The refresh commit.** declaude leaves one `chore: refresh GitHub contributors`
88
+ empty commit on the default branch (authored as you). It's harmless; drop it
89
+ later with `git rebase` if you like. Skip the whole refresh with `--no-refresh`.
90
+ - **Shared repos.** The flush renames the default branch and back. Collaborators
91
+ with a local clone may see GitHub's "default branch renamed" notice. Use
92
+ `--no-refresh` if that's a problem.
93
+ - **Graph lag.** Even after the flush + refresh push, the Contributors graph can
94
+ take a few minutes to update. Recheck in Incognito.
95
+ - **Closed pull requests.** GitHub keeps old commits in `refs/pull/N/head`, which
96
+ users can't delete. The Contributors graph is computed from the default branch
97
+ (clean + refreshed), so `@claude` should still drop; if it persists, only
98
+ GitHub Support can purge the PR-ref cache.
99
+
100
+ ## Commands
101
+
102
+ | Command | Purpose |
103
+ |---|---|
104
+ | `declaude TARGET [-y] [--dry-run] [--no-refresh] [--no-backup]` | Clean history + force-push + refresh contributors graph. `TARGET` = GitHub URL or `OWNER/REPO`. |
105
+ | `declaude prevent` | Set `includeCoAuthoredBy:false` in `~/.claude/settings.json`. |
106
+ | `declaude --version` | Print the installed version. |
107
+
108
+ ## Requirements
109
+
110
+ - Python 3.8+ and `pip`
111
+ - `git`
112
+ - `gh` (GitHub CLI, logged in) — install separately from <https://cli.github.com>
113
+ - `git-filter-repo` — installed automatically as a pip dependency
114
+
115
+ **Windows:** works in PowerShell and Windows Terminal (ANSI colors are enabled
116
+ automatically; set `NO_COLOR=1` to disable). `git`, `gh`, and Python must be on
117
+ your `PATH`.
118
+
119
+ ## Development
120
+
121
+ ```bash
122
+ pip install -e ".[dev]" # editable install with test deps
123
+ python -m pytest # run the scrubber tests
124
+ python -m declaude --help # run without installing the console script
125
+ ```
126
+
127
+ ## License
128
+
129
+ [MIT](LICENSE) © ediiloupatty
@@ -0,0 +1,44 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "declaude"
7
+ dynamic = ["version"]
8
+ description = "Remove Claude/AI attribution from a GitHub repo: clean history, force-push, and refresh the Contributors graph."
9
+ readme = "README.md"
10
+ requires-python = ">=3.8"
11
+ license = { file = "LICENSE" }
12
+ authors = [{ name = "ediiloupatty" }]
13
+ keywords = ["git", "github", "claude", "attribution", "co-authored-by", "filter-repo"]
14
+ classifiers = [
15
+ "Development Status :: 4 - Beta",
16
+ "Environment :: Console",
17
+ "Intended Audience :: Developers",
18
+ "License :: OSI Approved :: MIT License",
19
+ "Programming Language :: Python :: 3",
20
+ "Topic :: Software Development :: Version Control :: Git",
21
+ ]
22
+ dependencies = [
23
+ "git-filter-repo>=2.38",
24
+ ]
25
+
26
+ [project.optional-dependencies]
27
+ dev = ["pytest>=7"]
28
+
29
+ [project.urls]
30
+ Homepage = "https://github.com/ediiloupatty/declaude"
31
+ Repository = "https://github.com/ediiloupatty/declaude"
32
+ Issues = "https://github.com/ediiloupatty/declaude/issues"
33
+
34
+ [project.scripts]
35
+ declaude = "declaude:_entry"
36
+
37
+ [tool.hatch.version]
38
+ path = "src/declaude/__init__.py"
39
+
40
+ [tool.hatch.build.targets.wheel]
41
+ packages = ["src/declaude"]
42
+
43
+ [tool.hatch.build.targets.sdist]
44
+ include = ["src/declaude", "README.md", "LICENSE", "tests"]
@@ -0,0 +1,490 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ declaude — remove Claude/AI attribution from a GitHub repository.
4
+
5
+ One command does everything needed to get @claude off a repo:
6
+
7
+ declaude OWNER/REPO # or a full GitHub URL
8
+
9
+ It clones the repo, strips Claude/AI traces from the ENTIRE commit history
10
+ (e.g. "Co-Authored-By: Claude <noreply@anthropic.com>" or "Generated with
11
+ Claude Code"), force-pushes the cleaned branches, then refreshes GitHub's
12
+ Contributors graph in the order that actually works:
13
+
14
+ remove Claude → FLUSH (rename the default branch) → push a fresh commit
15
+
16
+ Neither the flush nor the commit alone updates the cached graph; doing the flush
17
+ first (to reset the cache) and THEN pushing a commit (to trigger the recompute
18
+ against the clean history) is what makes @claude finally drop.
19
+
20
+ It runs even when the history is already clean: in that case it just does the
21
+ flush + refresh commit, which is exactly what a previously-cleaned repo needs.
22
+
23
+ Also:
24
+ declaude prevent # stop Claude Code adding the trailer going forward
25
+
26
+ Requires: git and gh (GitHub CLI, logged in). git-filter-repo ships as a
27
+ dependency, so a plain `pip install declaude` is enough.
28
+ """
29
+ from __future__ import annotations
30
+
31
+ import argparse
32
+ import inspect
33
+ import os
34
+ import re
35
+ import shutil
36
+ import subprocess
37
+ import sys
38
+ import tempfile
39
+ import time
40
+ from pathlib import Path
41
+
42
+ __version__ = "0.1.0"
43
+
44
+ # Commit-message text considered a "Claude trace".
45
+ DETECT_RE = re.compile(
46
+ r"(co-authored-by:.*(claude|anthropic))|(generated with claude)|(noreply@anthropic)",
47
+ re.IGNORECASE,
48
+ )
49
+
50
+
51
+ def scrub_message(message: bytes) -> bytes:
52
+ """Strip Claude/AI traces from a single commit message (bytes -> bytes).
53
+
54
+ Drops Claude/anthropic co-author lines and "Generated with Claude Code"
55
+ lines (whether on their own line or appended inline to another line), then
56
+ collapses extra blank lines. Kept consistent with DETECT_RE, which matches
57
+ traces anywhere. This is the single source of truth: it is unit-tested
58
+ directly AND its source is reused verbatim as the git-filter-repo callback
59
+ (see SCRUB_CALLBACK), so the two can never drift apart.
60
+ """
61
+ import re
62
+ text = message.decode("utf-8", "replace")
63
+ out = []
64
+ for line in text.split("\n"):
65
+ low = line.strip().lower()
66
+ if low.startswith("co-authored-by:") and ("claude" in low or "anthropic" in low):
67
+ continue
68
+ if low.startswith("🤖 generated with") or low.startswith("generated with claude"):
69
+ continue
70
+ line = re.sub(r"(?i)\s*co-authored-by:\s*[^\n]*(?:claude|anthropic)[^\n]*$", "", line)
71
+ line = re.sub(r"(?i)\s*🤖?\s*generated with claude(?: code)?[^\n]*", "", line)
72
+ line = re.sub(r"(?i)\s*<?noreply@anthropic\.com>?", "", line)
73
+ if low and not line.strip():
74
+ continue
75
+ out.append(line)
76
+ result = re.sub(r"\n{3,}", "\n\n", "\n".join(out)).rstrip("\n") + "\n"
77
+ return result.encode("utf-8")
78
+
79
+
80
+ # Body for `git filter-repo --message-callback`. Reuses scrub_message's exact
81
+ # source (so the tested function and the callback are byte-for-byte identical),
82
+ # then calls it on the `message` the callback receives.
83
+ SCRUB_CALLBACK = inspect.getsource(scrub_message) + "return scrub_message(message)\n"
84
+
85
+ BACKUP_DIR = os.path.expanduser("~/.declaude-backups")
86
+
87
+ # ── tiny color helpers ────────────────────────────────────────────────────────
88
+ if sys.platform == "win32":
89
+ os.system("") # enable ANSI escape processing on Windows 10+ terminals
90
+
91
+ C = {
92
+ "g": "\033[32m", "r": "\033[31m", "y": "\033[33m",
93
+ "c": "\033[36m", "d": "\033[90m", "b": "\033[1m", "x": "\033[0m",
94
+ }
95
+ if not sys.stdout.isatty() or os.getenv("NO_COLOR"):
96
+ C = {k: "" for k in C}
97
+
98
+
99
+ def col(s: str, k: str) -> str:
100
+ return f"{C[k]}{s}{C['x']}"
101
+
102
+
103
+ def info(msg: str) -> None:
104
+ print(f" {msg}")
105
+
106
+
107
+ def die(msg: str, code: int = 1):
108
+ print(col(f"✗ {msg}", "r"), file=sys.stderr)
109
+ sys.exit(code)
110
+
111
+
112
+ # ── process helpers ───────────────────────────────────────────────────────────
113
+ def run(cmd, cwd=None, capture=True):
114
+ """Run a command; return (rc, stdout). Never raises."""
115
+ res = subprocess.run(
116
+ cmd, cwd=cwd, text=True,
117
+ stdout=subprocess.PIPE if capture else None,
118
+ stderr=subprocess.STDOUT if capture else None,
119
+ )
120
+ return res.returncode, (res.stdout or "")
121
+
122
+
123
+ def have(cmd: str) -> bool:
124
+ return shutil.which(cmd) is not None
125
+
126
+
127
+ def gh_authed() -> bool:
128
+ """True if `gh` is installed and logged in to github.com."""
129
+ if not have("gh"):
130
+ return False
131
+ rc, _ = run(["gh", "auth", "status"])
132
+ return rc == 0
133
+
134
+
135
+ def git(repo: str, *args, capture=True):
136
+ return run(["git", "-C", repo, *args], capture=capture)
137
+
138
+
139
+ # ── trace detection ───────────────────────────────────────────────────────────
140
+ def count_hits(repo: str, ref: str = "--all") -> int:
141
+ """Commits containing a Claude trace on `ref` (or all refs)."""
142
+ rc, out = git(
143
+ repo, "log", ref, "-i", "-E",
144
+ "--grep=co-authored-by:.*(claude|anthropic)",
145
+ "--grep=generated with claude", "--format=%H",
146
+ )
147
+ return len([h for h in out.split("\n") if h.strip()]) if rc == 0 else 0
148
+
149
+
150
+ def author_hits(repo: str) -> int:
151
+ """Commits whose AUTHOR/COMMITTER is Claude/anthropic (rare, more serious)."""
152
+ rc, out = git(repo, "log", "--all", "--format=%an <%ae>|%cn <%ce>")
153
+ if rc != 0:
154
+ return 0
155
+ return len([ln for ln in out.split("\n") if re.search(r"claude|anthropic", ln, re.I)])
156
+
157
+
158
+ def local_branches(repo: str) -> list[str]:
159
+ rc, out = git(repo, "for-each-ref", "--format=%(refname:short)", "refs/heads")
160
+ return [b for b in out.split("\n") if b.strip()] if rc == 0 else []
161
+
162
+
163
+ def remote_branches(repo: str) -> list[str]:
164
+ rc, out = git(repo, "ls-remote", "--heads", "origin")
165
+ if rc != 0:
166
+ return []
167
+ return [ln.split("refs/heads/", 1)[1] for ln in out.split("\n") if "refs/heads/" in ln]
168
+
169
+
170
+ # ── target → clone ────────────────────────────────────────────────────────────
171
+ def normalize_remote(target: str) -> tuple[str | None, str | None]:
172
+ """Return (clone_url, slug) for a GitHub URL or OWNER/REPO slug, else (None, None)."""
173
+ t = target.strip().rstrip("/")
174
+ if re.match(r"^(https?://|git@|ssh://)", t):
175
+ m = re.search(r"github\.com[/:]([\w.-]+/[\w.-]+?)(?:\.git)?$", t)
176
+ return t, (m.group(1) if m else None)
177
+ if re.match(r"^[\w-][\w.-]*/[\w.-]+$", t):
178
+ return f"https://github.com/{t}.git", t
179
+ return None, None
180
+
181
+
182
+ def materialize_branches(repo: str) -> None:
183
+ """Create a local branch for every remote head so clean + push cover them all."""
184
+ _, cur = git(repo, "rev-parse", "--abbrev-ref", "HEAD")
185
+ cur = cur.strip()
186
+ for b in remote_branches(repo):
187
+ if b != cur:
188
+ git(repo, "branch", "--force", b, f"origin/{b}")
189
+
190
+
191
+ def clone_target(url: str, slug: str | None) -> tuple[str, str]:
192
+ """Clone a remote into a temp dir. Returns (repo_path, tmpdir)."""
193
+ tmp = tempfile.mkdtemp(prefix="declaude-")
194
+ dest = os.path.join(tmp, (slug or "repo").split("/")[-1])
195
+ info(f"cloning {col(slug or url, 'c')} …")
196
+ if have("gh") and slug:
197
+ rc, out = run(["gh", "repo", "clone", slug, dest, "--", "--no-single-branch"])
198
+ else:
199
+ rc, out = run(["git", "clone", "--no-single-branch", url, dest])
200
+ if rc != 0:
201
+ shutil.rmtree(tmp, ignore_errors=True)
202
+ die(f"clone failed:\n{out}")
203
+ materialize_branches(dest)
204
+ return dest, tmp
205
+
206
+
207
+ def backup_bundle(repo: str, name: str) -> str:
208
+ """Bundle all refs to BACKUP_DIR (fully restorable). Returns the path."""
209
+ os.makedirs(BACKUP_DIR, exist_ok=True)
210
+ ts = time.strftime("%Y%m%d-%H%M%S")
211
+ bundle = os.path.join(BACKUP_DIR, f"{name}-{ts}.bundle")
212
+ rc, out = git(repo, "bundle", "create", bundle, "--all")
213
+ if rc != 0:
214
+ die(f"failed to create backup bundle:\n{out}")
215
+ return bundle
216
+
217
+
218
+ # ── server-side (gh) ──────────────────────────────────────────────────────────
219
+ def server_hits(slug: str) -> int:
220
+ """Total traced commits across all branches of a repo (via gh)."""
221
+ rc, out = run(["gh", "api", f"repos/{slug}/branches", "--jq", ".[].name"])
222
+ if rc != 0:
223
+ return -1
224
+ total = 0
225
+ for b in [x for x in out.split("\n") if x.strip()]:
226
+ rc, o = run(["gh", "api", f"repos/{slug}/commits?sha={b}", "--paginate",
227
+ "--jq", '[.[]|select(.commit.message|test("(?i)co-authored-by:.*(claude|anthropic)"))]|length'])
228
+ if rc == 0:
229
+ total += sum(int(x) for x in o.split("\n") if x.strip().isdigit())
230
+ return total
231
+
232
+
233
+ def _branch_rename(slug: str, old: str, new: str) -> tuple[int, str]:
234
+ return run(["gh", "api", "-X", "POST",
235
+ f"repos/{slug}/branches/{old}/rename", "-f", f"new_name={new}"])
236
+
237
+
238
+ def _branch_exists(slug: str, name: str) -> bool:
239
+ rc, _ = run(["gh", "api", f"repos/{slug}/branches/{name}"])
240
+ return rc == 0
241
+
242
+
243
+ def _default_branch(slug: str) -> str | None:
244
+ rc, out = run(["gh", "api", f"repos/{slug}", "--jq", ".default_branch"])
245
+ return out.strip() if rc == 0 and out.strip() else None
246
+
247
+
248
+ def flush_cache(slug: str) -> bool:
249
+ """Flush GitHub's cached Contributors graph by renaming the default branch
250
+ away and back. On its own this isn't enough — but doing it BEFORE pushing a
251
+ fresh commit is what makes the recompute pick up the cleaned history (the
252
+ flush resets the cache, the following commit triggers the rebuild).
253
+ Non-destructive: the branch ends up with its original name. Needs gh.
254
+ """
255
+ if not have("gh"):
256
+ info("(install `gh` to flush GitHub's contributor cache)")
257
+ return False
258
+ default = _default_branch(slug)
259
+ if not default:
260
+ info(col("could not read default branch — skipping cache flush.", "y"))
261
+ return False
262
+ tmp = f"{default}-cflushtmp"
263
+ info(f"flushing contributor cache (rename {default} → {tmp} → {default})…")
264
+
265
+ # Clear any leftover temp branch from a previous interrupted run.
266
+ if _branch_exists(slug, tmp):
267
+ run(["gh", "api", "-X", "DELETE", f"repos/{slug}/git/refs/heads/{tmp}"])
268
+ time.sleep(1)
269
+
270
+ rc, out = _branch_rename(slug, default, tmp)
271
+ if rc != 0:
272
+ info(col(f" rename to temp failed: {out.strip()[:140]}", "y"))
273
+ return False
274
+ # Let GitHub's branch index settle, then rename back (retry through the lag
275
+ # that otherwise yields a spurious 422 "branch already exists").
276
+ time.sleep(2)
277
+ for _ in range(5):
278
+ rc, _o = _branch_rename(slug, tmp, default)
279
+ if rc == 0:
280
+ break
281
+ time.sleep(2)
282
+
283
+ # Reconcile to the invariant {default name is the default branch, tmp gone}.
284
+ # Idempotent ops that don't hit the rename race, run unconditionally.
285
+ if _branch_exists(slug, tmp) and not _branch_exists(slug, default):
286
+ _branch_rename(slug, tmp, default)
287
+ run(["gh", "api", "-X", "PATCH", f"repos/{slug}", "-f", f"default_branch={default}"])
288
+ run(["gh", "api", "-X", "DELETE", f"repos/{slug}/git/refs/heads/{tmp}"])
289
+
290
+ for _ in range(5):
291
+ if _default_branch(slug) == default:
292
+ info(col("contributor cache flushed.", "g"))
293
+ return True
294
+ time.sleep(2)
295
+ info(col(f" ⚠ flush left an inconsistent state. Fix manually:\n"
296
+ f" gh api -X PATCH repos/{slug} -f default_branch={default}\n"
297
+ f" gh api -X DELETE repos/{slug}/git/refs/heads/{tmp}", "y"))
298
+ return False
299
+
300
+
301
+ def refresh_contributors(repo: str) -> bool:
302
+ """Push an empty commit to the default branch. Run AFTER flush_cache: the
303
+ flush resets GitHub's cached Contributors graph, and this fresh push is what
304
+ triggers the recompute against the cleaned history (so @claude drops).
305
+ The commit reuses the latest commit's author, so no new identity appears.
306
+ """
307
+ _, branch = git(repo, "rev-parse", "--abbrev-ref", "HEAD")
308
+ branch = branch.strip()
309
+ _, an = git(repo, "log", "-1", "--format=%an")
310
+ _, ae = git(repo, "log", "-1", "--format=%ae")
311
+ info(f"refreshing contributors graph (empty commit on {branch})…")
312
+ rc, out = git(repo, "-c", f"user.name={an.strip()}", "-c", f"user.email={ae.strip()}",
313
+ "commit", "--allow-empty", "-m", "chore: refresh GitHub contributors")
314
+ if rc != 0:
315
+ info(col(f" could not create refresh commit:\n{out.strip()[:160]}", "y"))
316
+ return False
317
+ rc, out = git(repo, "push", "origin", branch)
318
+ if rc != 0:
319
+ info(col(f" push failed:\n{out.strip()[:160]}", "y"))
320
+ return False
321
+ info(col("contributors-graph refresh pushed — updates shortly.", "g"))
322
+ return True
323
+
324
+
325
+ # ── main action ───────────────────────────────────────────────────────────────
326
+ def declaude(target: str, *, yes: bool, dry_run: bool, no_refresh: bool, no_backup: bool):
327
+ """Clone TARGET, strip Claude traces, force-push, and refresh the graph."""
328
+ if not have("git"):
329
+ die("git not found in PATH.")
330
+ if not have("git-filter-repo"):
331
+ die("git-filter-repo is not installed. Reinstall declaude (pip install "
332
+ "declaude) or run: pipx install git-filter-repo")
333
+ # Preflight: gh drives the clone, the cache flush and the server-side check.
334
+ # Fail early with a clear message instead of cryptic errors mid-run.
335
+ if have("gh"):
336
+ if not gh_authed():
337
+ die("GitHub CLI 'gh' is installed but not logged in.\n"
338
+ " Run: gh auth login")
339
+ else:
340
+ info(col("note: GitHub CLI 'gh' not found — private-repo clone may prompt "
341
+ "for credentials and the Contributors-graph flush will be skipped.\n"
342
+ " Install it from https://cli.github.com and run 'gh auth login'.", "y"))
343
+
344
+ url, slug = normalize_remote(target)
345
+ if not url:
346
+ die(f"need a GitHub URL or OWNER/REPO slug (got: {target})")
347
+ if not slug:
348
+ die("only github.com repositories are supported.")
349
+
350
+ repo, tmp = clone_target(url, slug)
351
+ try:
352
+ hits = count_hits(repo)
353
+ ah = author_hits(repo)
354
+ affected = [b for b in local_branches(repo) if count_hits(repo, b)]
355
+ rbranches = set(remote_branches(repo))
356
+
357
+ print(col(f"\nRepo : {slug}", "b"))
358
+ print(f" traces : {col(str(hits), 'y')} co-author commit(s)"
359
+ + (f", {col(str(ah), 'y')} Claude author/committer" if ah else ""))
360
+ if hits:
361
+ print(f" affected branches: {', '.join(affected) or '-'}")
362
+ else:
363
+ print(col(" history already clean — will refresh GitHub's graph only.", "c"))
364
+ if ah:
365
+ print(col(" ⚠ some commits have a Claude AUTHOR — declaude only cleans "
366
+ "message trailers, NOT authorship. Use git-filter-repo --mailmap for that.", "y"))
367
+
368
+ if dry_run:
369
+ print(col("\n[dry-run] nothing changed. Drop --dry-run to execute.", "c"))
370
+ return
371
+ if not yes:
372
+ act = "REWRITES history, FORCE-PUSHES, " if hits else ""
373
+ print(col(f"\nThis {act}flushes GitHub's contributor cache (renames the "
374
+ "default branch) and pushes an empty refresh commit.", "y"))
375
+ if input(" Continue? type 'yes': ").strip().lower() not in ("yes", "y"):
376
+ die("aborted.", 0)
377
+
378
+ # 1) rewrite + push (only if there are traces to strip)
379
+ if hits:
380
+ if no_backup:
381
+ info(col("⚠ --no-backup: skipping backup bundle (no restore point).", "y"))
382
+ else:
383
+ bundle = backup_bundle(repo, slug.split("/")[-1])
384
+ info(f"backup bundle: {col(bundle, 'd')}")
385
+ _, origin_url = git(repo, "remote", "get-url", "origin")
386
+ origin_url = origin_url.strip()
387
+
388
+ info("rewriting history (git filter-repo)…")
389
+ rc, out = run(["git", "filter-repo", "--force",
390
+ "--message-callback", SCRUB_CALLBACK], cwd=repo)
391
+ if rc != 0:
392
+ restore = "" if no_backup else (
393
+ f"\n\nRestore from bundle:\n git -C <repo> fetch {bundle} '*:*'")
394
+ die(f"filter-repo failed:\n{out}{restore}")
395
+ if origin_url:
396
+ git(repo, "remote", "remove", "origin")
397
+ git(repo, "remote", "add", "origin", origin_url)
398
+
399
+ left = count_hits(repo)
400
+ if left:
401
+ die(f"still {left} trace(s) after rewrite — check manually.")
402
+ info(col("local history is clean (0 traces).", "g"))
403
+
404
+ push_branches = [b for b in affected if b in rbranches] or \
405
+ [b for b in local_branches(repo) if b in rbranches]
406
+ info(f"force-pushing branches: {', '.join(push_branches)}")
407
+ failed = []
408
+ for b in push_branches:
409
+ rc, out = git(repo, "push", "origin", b, "--force")
410
+ ok = rc == 0
411
+ print(f" {col('✓', 'g') if ok else col('✗', 'r')} {b}")
412
+ if not ok:
413
+ failed.append(b)
414
+ info(col(f" {out.strip()[:160]}", "y"))
415
+ if failed:
416
+ die(f"force-push failed for: {', '.join(failed)} "
417
+ "(branch protection?). Local history is clean; fix and retry.")
418
+ if have("gh"):
419
+ n = server_hits(slug)
420
+ print(col(f"\nServer {slug}: {n} traced commit(s) across all branches.",
421
+ "g" if n == 0 else "y"))
422
+
423
+ # 2) refresh the contributors graph: FLUSH first (rename the default
424
+ # branch to reset GitHub's cache), THEN push a fresh commit (which makes
425
+ # the cache recompute against the clean history). Neither step alone is
426
+ # enough — the order flush → commit is what actually drops @claude.
427
+ if no_refresh:
428
+ info("skipped contributors-graph refresh (--no-refresh).")
429
+ else:
430
+ flush_cache(slug)
431
+ refresh_contributors(repo)
432
+
433
+ print(col("\nDone. Recheck the Contributors graph in Incognito.", "g"))
434
+ finally:
435
+ shutil.rmtree(tmp, ignore_errors=True)
436
+
437
+
438
+ # ── prevent ───────────────────────────────────────────────────────────────────
439
+ def cmd_prevent():
440
+ import json
441
+ path = Path(os.path.expanduser("~/.claude/settings.json"))
442
+ data = {}
443
+ if path.exists():
444
+ try:
445
+ data = json.loads(path.read_text())
446
+ except Exception:
447
+ die(f"failed to read {path} (invalid JSON).")
448
+ if data.get("includeCoAuthoredBy") is False:
449
+ info(col("Already set: includeCoAuthoredBy=false.", "g"))
450
+ return
451
+ data["includeCoAuthoredBy"] = False
452
+ path.parent.mkdir(parents=True, exist_ok=True)
453
+ path.write_text(json.dumps(data, indent=2) + "\n")
454
+ info(col(f"Set includeCoAuthoredBy=false in {path}.", "g"))
455
+ info("Future Claude Code commits/PRs won't add an attribution trailer.")
456
+
457
+
458
+ # ── cli ───────────────────────────────────────────────────────────────────────
459
+ def main():
460
+ argv = sys.argv[1:]
461
+ if argv and argv[0] == "prevent":
462
+ return cmd_prevent()
463
+
464
+ p = argparse.ArgumentParser(
465
+ prog="declaude",
466
+ description="Remove Claude/AI attribution from a GitHub repo "
467
+ "(clean history + force-push + refresh Contributors graph).",
468
+ epilog="Other: `declaude prevent` turns off Claude Code attribution going forward.")
469
+ p.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
470
+ p.add_argument("target", help="GitHub URL or OWNER/REPO slug")
471
+ p.add_argument("-y", "--yes", action="store_true", help="skip confirmation")
472
+ p.add_argument("--dry-run", action="store_true", help="show the plan only")
473
+ p.add_argument("--no-refresh", action="store_true",
474
+ help="don't push the empty commit that refreshes the contributors graph")
475
+ p.add_argument("--no-backup", action="store_true",
476
+ help="skip the restorable backup bundle before rewriting (not recommended)")
477
+ args = p.parse_args(argv)
478
+ declaude(args.target, yes=args.yes, dry_run=args.dry_run,
479
+ no_refresh=args.no_refresh, no_backup=args.no_backup)
480
+
481
+
482
+ def _entry():
483
+ try:
484
+ main()
485
+ except KeyboardInterrupt:
486
+ die("aborted.", 130)
487
+
488
+
489
+ if __name__ == "__main__":
490
+ _entry()
@@ -0,0 +1,5 @@
1
+ """Enable `python -m declaude`."""
2
+ from declaude import _entry
3
+
4
+ if __name__ == "__main__":
5
+ _entry()
@@ -0,0 +1,85 @@
1
+ """Unit tests for the core scrubber (declaude.scrub_message).
2
+
3
+ This is declaude's riskiest logic: a wrong regex could either leave a Claude
4
+ trace behind or corrupt a legitimate commit message. The same function source
5
+ is reused verbatim as the git-filter-repo callback, so testing it here covers
6
+ the real rewrite path too.
7
+
8
+ Run: python -m pytest (or) python tests/test_scrub.py
9
+ """
10
+ import os
11
+ import sys
12
+ import unittest
13
+
14
+ sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src"))
15
+
16
+ from declaude import DETECT_RE, scrub_message # noqa: E402
17
+
18
+
19
+ def scrub(text: str) -> str:
20
+ return scrub_message(text.encode("utf-8")).decode("utf-8")
21
+
22
+
23
+ class ScrubMessageTests(unittest.TestCase):
24
+ def test_drops_claude_coauthor_trailer(self):
25
+ msg = (
26
+ "fix: thing\n\n"
27
+ "Co-Authored-By: Claude <noreply@anthropic.com>\n"
28
+ )
29
+ out = scrub(msg)
30
+ self.assertNotRegex(out, r"(?i)claude")
31
+ self.assertNotRegex(out, r"(?i)anthropic")
32
+ self.assertIn("fix: thing", out)
33
+
34
+ def test_drops_generated_with_claude_code_line(self):
35
+ msg = (
36
+ "feat: add feature\n\n"
37
+ "🤖 Generated with [Claude Code](https://claude.com/claude-code)\n\n"
38
+ "Co-Authored-By: Claude <noreply@anthropic.com>\n"
39
+ )
40
+ out = scrub(msg)
41
+ self.assertNotIn("Generated with", out)
42
+ self.assertNotRegex(out, r"(?i)claude")
43
+ self.assertTrue(out.startswith("feat: add feature"))
44
+
45
+ def test_keeps_human_coauthor(self):
46
+ msg = (
47
+ "chore: pairing\n\n"
48
+ "Co-Authored-By: Alice <alice@example.com>\n"
49
+ "Co-Authored-By: Claude <noreply@anthropic.com>\n"
50
+ )
51
+ out = scrub(msg)
52
+ self.assertIn("Alice <alice@example.com>", out)
53
+ self.assertNotRegex(out, r"(?i)claude")
54
+
55
+ def test_leaves_clean_message_unchanged(self):
56
+ msg = "refactor: rename helper\n\nMakes the API clearer.\n"
57
+ self.assertEqual(scrub(msg), msg)
58
+
59
+ def test_collapses_blank_lines_after_removal(self):
60
+ msg = (
61
+ "subject\n\n"
62
+ "body line\n\n"
63
+ "Co-Authored-By: Claude <noreply@anthropic.com>\n"
64
+ )
65
+ out = scrub(msg)
66
+ self.assertNotIn("\n\n\n", out)
67
+ self.assertIn("body line", out)
68
+
69
+ def test_strips_inline_noreply_email(self):
70
+ msg = "hack by claude noreply@anthropic.com here\n"
71
+ out = scrub(msg)
72
+ self.assertNotIn("anthropic.com", out)
73
+
74
+ def test_subject_only_message_survives(self):
75
+ self.assertEqual(scrub("just a subject\n"), "just a subject\n")
76
+
77
+ def test_detect_re_matches_what_scrub_removes(self):
78
+ # Anything DETECT_RE flags should be gone after scrubbing.
79
+ traced = "x\n\nCo-Authored-By: Claude <noreply@anthropic.com>\n"
80
+ self.assertTrue(DETECT_RE.search(traced))
81
+ self.assertFalse(DETECT_RE.search(scrub(traced)))
82
+
83
+
84
+ if __name__ == "__main__":
85
+ unittest.main()