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.
- declaude-0.1.0/.gitignore +13 -0
- declaude-0.1.0/LICENSE +21 -0
- declaude-0.1.0/PKG-INFO +172 -0
- declaude-0.1.0/README.md +129 -0
- declaude-0.1.0/pyproject.toml +44 -0
- declaude-0.1.0/src/declaude/__init__.py +490 -0
- declaude-0.1.0/src/declaude/__main__.py +5 -0
- declaude-0.1.0/tests/test_scrub.py +85 -0
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.
|
declaude-0.1.0/PKG-INFO
ADDED
|
@@ -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
|
declaude-0.1.0/README.md
ADDED
|
@@ -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,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()
|