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