# unclaude.py

> Source: <https://gist.github.com/Garfield1002/1a3fe1f601f5c36257a1c98b414f7141>
> Published: 2026-07-21 12:57:52+00:00

| #!/usr/bin/env python3 | |
| # SPDX-License-Identifier: Unlicense | |
| # | |
| # Adds an impressive feature from VSCode and Vim to Claude Code: | |
| # the ability to edit code without signing the guest book in every commit. | |
| # | |
| # Usage: | |
| # Install as a repository-local commit-msg hook: | |
| # curl -fsSL https://gist.githubusercontent.com/Garfield1002/1a3fe1f601f5c36257a1c98b414f7141/raw/unclaude.py | install -m 0755 /dev/stdin .git/hooks/commit-msg | |
| # | |
| # Rewrite the most recent commit message: | |
| # unclaude.py --fix-previous | |
| # | |
| # Rewrite the last N commits on HEAD's first-parent history: | |
| # unclaude.py --fix-previous 5 | |
| # | |
| # When installed directly as a hook, invoke it like this: | |
| # .git/hooks/commit-msg --fix-previous 5 | |
| # | |
| # Warning: | |
| # --fix-previous rewrites Git history. Rewritten commits receive new object | |
| # IDs, and affected commit signatures are removed because they are no longer | |
| # valid. Do not rewrite published commits without coordinating a force-push. | |
| # | |
| # The script creates a backup under: | |
| # refs/strip-anthropic-backup/ | |
| # | |
| # A backup can be restored with: | |
| # git reset --keep refs/strip-anthropic-backup/<backup-name> | |
| # | |
| # This is free and unencumbered software released into the public domain. | |
| # | |
| # Anyone is free to copy, modify, publish, use, compile, sell, or distribute | |
| # this software, either in source code form or as a compiled binary, for any | |
| # purpose, commercial or non-commercial, and by any means. | |
| # | |
| # In jurisdictions that recognize copyright laws, the author or authors of | |
| # this software dedicate any and all copyright interest in the software to the | |
| # public domain. We make this dedication for the benefit of the public at large | |
| # and to the detriment of our heirs and successors. We intend this dedication | |
| # to be an overt act of relinquishment in perpetuity of all present and future | |
| # rights to this software under copyright law. | |
| # | |
| # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR | |
| # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, | |
| # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE | |
| # AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN | |
| # ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION | |
| # WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. | |
| # | |
| # For more information, please refer to <https://unlicense.org/>. | |
| from __future__ import annotations | |
| import argparse | |
| import re | |
| import subprocess | |
| import sys | |
| from dataclasses import dataclass | |
| from datetime import datetime, timezone | |
| from pathlib import Path | |
| from typing import Sequence | |
| ANTHROPIC_COAUTHOR = re.compile( | |
| rb""" | |
| ^[ \t]* | |
| co-authored-by[ \t]*:[ \t]* | |
| [^\r\n]* | |
| <[ \t]*noreply@anthropic\.com[ \t]*> | |
| [ \t]* | |
| (?:\r\n|\n|\r|$) | |
| """, | |
| re.IGNORECASE | re.MULTILINE | re.VERBOSE, | |
| ) | |
| SIGNATURE_HEADERS = { | |
| b"gpgsig", | |
| b"gpgsig-sha256", | |
| } | |
| class ScriptError(RuntimeError): | |
| """An expected, user-facing failure.""" | |
| @dataclass(frozen=True) | |
| class HeaderRecord: | |
| key: bytes | |
| lines: tuple[bytes, ...] | |
| def strip_anthropic_attribution(message: bytes) -> tuple[bytes, int]: | |
| """Remove Anthropic Co-Authored-By lines from a commit message.""" | |
| newline_match = re.search(rb"\r\n|\n|\r", message) | |
| newline = newline_match.group(0) if newline_match else b"\n" | |
| cleaned, count = ANTHROPIC_COAUTHOR.subn(b"", message) | |
| if count == 0: | |
| return message, 0 | |
| # Removing a final trailer commonly leaves an otherwise useless blank line. | |
| lines = cleaned.splitlines(keepends=True) | |
| while lines and not lines[-1].strip(b" \t\r\n"): | |
| lines.pop() | |
| cleaned = b"".join(lines).rstrip(b"\r\n") | |
| if cleaned: | |
| cleaned += newline | |
| return cleaned, count | |
| def run_git( | |
| args: Sequence[str], | |
| *, | |
| input_bytes: bytes | None = None, | |
| check: bool = True, | |
| ) -> subprocess.CompletedProcess[bytes]: | |
| try: | |
| result = subprocess.run( | |
| ["git", *args], | |
| input=input_bytes, | |
| stdout=subprocess.PIPE, | |
| stderr=subprocess.PIPE, | |
| check=False, | |
| ) | |
| except OSError as exc: | |
| raise ScriptError(f"could not execute Git: {exc}") from exc | |
| if check and result.returncode != 0: | |
| detail = result.stderr.decode("utf-8", errors="replace").strip() | |
| command = "git " + " ".join(args) | |
| raise ScriptError( | |
| f"{command} failed: {detail or 'unknown error'}" | |
| ) | |
| return result | |
| def ensure_git_repository() -> None: | |
| result = run_git(["rev-parse", "--git-dir"], check=False) | |
| if result.returncode != 0: | |
| raise ScriptError( | |
| "--fix-previous must be run inside a Git repository" | |
| ) | |
| def parse_commit( | |
| raw_commit: bytes, | |
| ) -> tuple[list[HeaderRecord], bytes]: | |
| try: | |
| raw_headers, message = raw_commit.split(b"\n\n", 1) | |
| except ValueError as exc: | |
| raise ScriptError( | |
| "encountered a malformed Git commit object" | |
| ) from exc | |
| records: list[HeaderRecord] = [] | |
| current: list[bytes] = [] | |
| for line in raw_headers.split(b"\n"): | |
| if line.startswith(b" "): | |
| if not current: | |
| raise ScriptError( | |
| "encountered a malformed continued commit header" | |
| ) | |
| current.append(line) | |
| continue | |
| if current: | |
| key = current[0].split(b" ", 1)[0] | |
| records.append( | |
| HeaderRecord(key=key, lines=tuple(current)) | |
| ) | |
| current = [line] | |
| if current: | |
| key = current[0].split(b" ", 1)[0] | |
| records.append( | |
| HeaderRecord(key=key, lines=tuple(current)) | |
| ) | |
| return records, message | |
| def parent_ids(records: Sequence[HeaderRecord]) -> list[str]: | |
| parents: list[str] = [] | |
| for record in records: | |
| if record.key != b"parent": | |
| continue | |
| try: | |
| _, value = record.lines[0].split(b" ", 1) | |
| except ValueError as exc: | |
| raise ScriptError( | |
| "encountered a malformed parent header" | |
| ) from exc | |
| parents.append(value.decode("ascii")) | |
| return parents | |
| def rebuild_commit( | |
| records: Sequence[HeaderRecord], | |
| message: bytes, | |
| rewritten_parents: dict[str, str], | |
| ) -> tuple[bytes, int]: | |
| output: list[bytes] = [] | |
| stripped_signatures = 0 | |
| for record in records: | |
| if record.key in SIGNATURE_HEADERS: | |
| stripped_signatures += 1 | |
| continue | |
| if record.key == b"parent": | |
| try: | |
| _, old_parent_bytes = record.lines[0].split(b" ", 1) | |
| except ValueError as exc: | |
| raise ScriptError( | |
| "encountered a malformed parent header" | |
| ) from exc | |
| old_parent = old_parent_bytes.decode("ascii") | |
| new_parent = rewritten_parents.get( | |
| old_parent, | |
| old_parent, | |
| ) | |
| output.append( | |
| b"parent " + new_parent.encode("ascii") | |
| ) | |
| continue | |
| output.extend(record.lines) | |
| raw_commit = b"\n".join(output) + b"\n\n" + message | |
| return raw_commit, stripped_signatures | |
| def write_commit_object(raw_commit: bytes) -> str: | |
| result = run_git( | |
| ["hash-object", "-t", "commit", "-w", "--stdin"], | |
| input_bytes=raw_commit, | |
| ) | |
| return result.stdout.decode("ascii").strip() | |
| def selected_commits(count: int) -> list[str]: | |
| result = run_git( | |
| [ | |
| "rev-list", | |
| "--first-parent", | |
| f"--max-count={count}", | |
| "HEAD", | |
| ] | |
| ) | |
| commits = result.stdout.decode("ascii").split() | |
| if not commits: | |
| raise ScriptError("HEAD does not refer to any commit") | |
| return commits | |
| def create_backup_ref(old_head: str) -> str: | |
| timestamp = datetime.now(timezone.utc).strftime( | |
| "%Y%m%dT%H%M%SZ" | |
| ) | |
| suffix = old_head[:12] | |
| base = ( | |
| f"refs/strip-anthropic-backup/" | |
| f"{timestamp}-{suffix}" | |
| ) | |
| candidate = base | |
| attempt = 1 | |
| while ( | |
| run_git( | |
| ["show-ref", "--verify", "--quiet", candidate], | |
| check=False, | |
| ).returncode | |
| == 0 | |
| ): | |
| attempt += 1 | |
| candidate = f"{base}-{attempt}" | |
| run_git(["update-ref", candidate, old_head]) | |
| return candidate | |
| def fix_previous(count: int) -> int: | |
| ensure_git_repository() | |
| old_head = ( | |
| run_git(["rev-parse", "--verify", "HEAD"]) | |
| .stdout.decode("ascii") | |
| .strip() | |
| ) | |
| commits_newest_first = selected_commits(count) | |
| parsed: dict[ | |
| str, | |
| tuple[list[HeaderRecord], bytes], | |
| ] = {} | |
| total_removed = 0 | |
| for commit_id in commits_newest_first: | |
| raw_commit = run_git( | |
| ["cat-file", "commit", commit_id] | |
| ).stdout | |
| records, message = parse_commit(raw_commit) | |
| parsed[commit_id] = records, message | |
| _, removed = strip_anthropic_attribution(message) | |
| total_removed += removed | |
| if total_removed == 0: | |
| print( | |
| "No matching Anthropic Co-Authored-By lines " | |
| f"found in {len(commits_newest_first)} commit(s)." | |
| ) | |
| return 0 | |
| backup_ref = create_backup_ref(old_head) | |
| rewritten: dict[str, str] = {} | |
| rewritten_commit_count = 0 | |
| stripped_signature_count = 0 | |
| # Git rev-list returned newest first. Rebuild oldest first so rewritten | |
| # parent object IDs are available when rebuilding their descendants. | |
| for commit_id in reversed(commits_newest_first): | |
| records, original_message = parsed[commit_id] | |
| cleaned_message, removed = ( | |
| strip_anthropic_attribution(original_message) | |
| ) | |
| old_parents = parent_ids(records) | |
| new_parents = [ | |
| rewritten.get(parent, parent) | |
| for parent in old_parents | |
| ] | |
| parent_changed = new_parents != old_parents | |
| if removed == 0 and not parent_changed: | |
| rewritten[commit_id] = commit_id | |
| continue | |
| raw_rewritten, stripped = rebuild_commit( | |
| records, | |
| cleaned_message, | |
| rewritten, | |
| ) | |
| new_commit_id = write_commit_object(raw_rewritten) | |
| rewritten[commit_id] = new_commit_id | |
| rewritten_commit_count += 1 | |
| stripped_signature_count += stripped | |
| new_head = rewritten.get(old_head, old_head) | |
| if new_head == old_head: | |
| raise ScriptError( | |
| "matching messages were found, but HEAD did not change" | |
| ) | |
| run_git( | |
| [ | |
| "update-ref", | |
| "-m", | |
| "strip Anthropic Co-Authored-By trailers", | |
| "HEAD", | |
| new_head, | |
| old_head, | |
| ] | |
| ) | |
| print( | |
| f"Removed {total_removed} attribution line(s) and " | |
| f"rewrote {rewritten_commit_count} commit(s)." | |
| ) | |
| print(f"Backup ref: {backup_ref}") | |
| if stripped_signature_count: | |
| print( | |
| "Removed " | |
| f"{stripped_signature_count} invalidated commit " | |
| "signature header(s).", | |
| file=sys.stderr, | |
| ) | |
| return 0 | |
| def clean_commit_message_file(path: Path) -> int: | |
| try: | |
| original = path.read_bytes() | |
| cleaned, removal_count = ( | |
| strip_anthropic_attribution(original) | |
| ) | |
| if removal_count: | |
| path.write_bytes(cleaned) | |
| return 0 | |
| except OSError as exc: | |
| raise ScriptError( | |
| f"failed to process commit message {path}: {exc}" | |
| ) from exc | |
| def positive_int(value: str) -> int: | |
| try: | |
| parsed = int(value) | |
| except ValueError as exc: | |
| raise argparse.ArgumentTypeError( | |
| "must be an integer" | |
| ) from exc | |
| if parsed < 1: | |
| raise argparse.ArgumentTypeError( | |
| "must be at least 1" | |
| ) | |
| return parsed | |
| def parse_args() -> argparse.Namespace: | |
| parser = argparse.ArgumentParser( | |
| description=( | |
| "Remove Anthropic Co-Authored-By lines from " | |
| "Git commit messages." | |
| ) | |
| ) | |
| parser.add_argument( | |
| "commit_message_file", | |
| nargs="?", | |
| type=Path, | |
| help=( | |
| "commit-message file supplied by Git to a " | |
| "commit-msg hook" | |
| ), | |
| ) | |
| parser.add_argument( | |
| "--fix-previous", | |
| nargs="?", | |
| const=1, | |
| type=positive_int, | |
| metavar="N", | |
| help=( | |
| "rewrite the latest N first-parent commits; " | |
| "defaults to 1" | |
| ), | |
| ) | |
| args = parser.parse_args() | |
| if ( | |
| args.fix_previous is not None | |
| and args.commit_message_file is not None | |
| ): | |
| parser.error( | |
| "commit_message_file cannot be combined with " | |
| "--fix-previous" | |
| ) | |
| if ( | |
| args.fix_previous is None | |
| and args.commit_message_file is None | |
| ): | |
| parser.error( | |
| "provide a commit-message file or use " | |
| "--fix-previous [N]" | |
| ) | |
| return args | |
| def main() -> int: | |
| try: | |
| args = parse_args() | |
| if args.fix_previous is not None: | |
| return fix_previous(args.fix_previous) | |
| return clean_commit_message_file( | |
| args.commit_message_file | |
| ) | |
| except ScriptError as exc: | |
| print(f"error: {exc}", file=sys.stderr) | |
| return 1 | |
| if __name__ == "__main__": | |
| raise SystemExit(main()) |
