cd /news/developer-tools/unclaude-py · home topics developer-tools article
[ARTICLE · art-70133] src=gist.github.com ↗ pub= topic=developer-tools verified=true sentiment=· neutral

unclaude.py

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.

read13 min views2 publishedJul 21, 2026

| #!/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()) |
── more in #developer-tools 4 stories · sorted by recency
── more on @anthropic 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
Live at https://your-agent.zahid.host
Get free account → Pricing
from €0/mo · no card required
LIVE [news/unclaude-py] indexed:0 read:13min 2026-07-21 ·