| #!/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()) |