Files
git-editor/edit.sh
T
Andy Dixon 40d9cb1087 Remove Claude/Codex attribution blocks and Claude-Session lines
Co-author cleanup now removes a Claude or Codex Co-authored-by line
together with the non-blank lines that follow it, removes any
Claude-Session trailer line, and collapses the blank lines left behind.
Other Co-authored-by lines are still removed individually.
2026-09-11 14:55:30 +01:00

213 lines
6.7 KiB
Bash
Executable File

#!/usr/bin/env bash
# Exit immediately if a command fails.
set -euo pipefail
# Print a small usage message.
usage() {
cat <<'EOF'
Usage:
./rewrite-history.sh "New Name" "[email protected]"
What it does:
- Rewrites reachable Git history in the current repository.
- Removes "Co-Authored" and "Claude-Session:" lines; Claude/Codex co-author blocks are removed whole.
- Sets author and committer name/email to the values you provide.
- Moves commit times to either:
00:00-07:30
19:00-23:59
- Tries to leave at least one hour between commits.
- Never makes a rewritten commit earlier than the previous rewritten commit.
Notes:
- This rewrites history destructively.
- Run it on a fresh clone or a throwaway copy first.
- You will need to force-push afterwards.
EOF
}
# Require exactly two arguments.
if [[ $# -ne 2 ]]; then
usage
exit 1
fi
# Store the new author name.
NEW_NAME="$1"
# Store the new author email.
NEW_EMAIL="$2"
# Make sure we are inside a Git working tree.
git rev-parse --is-inside-work-tree >/dev/null 2>&1
# Make sure git-filter-repo is installed.
if ! command -v git-filter-repo >/dev/null 2>&1; then
echo "Error: git-filter-repo is not installed." >&2
echo "Install it first, then re-run this script." >&2
exit 1
fi
# Make sure Python 3 is available, because the callbacks use Python.
if ! command -v python3 >/dev/null 2>&1; then
echo "Error: python3 is not installed." >&2
exit 1
fi
# Create a safety backup ref pointing at the current HEAD.
BACKUP_REF="refs/backup/pre-rewrite-$(date +%Y%m%d-%H%M%S)"
git update-ref "${BACKUP_REF}" HEAD
# Tell the user where the backup ref was stored.
echo "Created backup ref at ${BACKUP_REF}"
# Create a temporary file for the commit callback.
COMMIT_CALLBACK_FILE="$(mktemp)"
# Create a temporary file for the message callback.
MESSAGE_CALLBACK_FILE="$(mktemp)"
# Ensure the temporary files are removed when the script exits.
cleanup() {
rm -f "${COMMIT_CALLBACK_FILE}" "${MESSAGE_CALLBACK_FILE}"
}
trap cleanup EXIT
# Write the commit callback.
cat >"${COMMIT_CALLBACK_FILE}" <<'PY'
# Persist the previously assigned rewritten timestamp across callback calls.
if "prev_assigned_ts" not in globals():
prev_assigned_ts = None
# Read the requested replacement identity from the environment.
new_name = os.environ["REWRITE_NEW_NAME"].encode("utf-8")
new_email = os.environ["REWRITE_NEW_EMAIL"].encode("utf-8")
# Read the preferred minimum gap, in seconds, from the environment.
min_gap_seconds = int(os.environ.get("REWRITE_MIN_GAP_SECONDS", "3600"))
# Replace the author identity.
commit.author_name = new_name
commit.author_email = new_email
# Replace the committer identity as well, so both sides match.
commit.committer_name = new_name
commit.committer_email = new_email
# Use the original author date as the calendar anchor if present.
raw_date = commit.author_date or commit.committer_date
# Parse the fast-export style date string into a timezone-aware datetime.
original_dt = string_to_date(raw_date)
# Work with the original timezone attached to the commit.
tz = original_dt.tzinfo
# Keep the original calendar date as the first preference.
candidate_date = original_dt.date()
# Build a lower bound based on the previous rewritten commit.
earliest_allowed_dt = None
if prev_assigned_ts is not None:
earliest_allowed_dt = datetime.fromtimestamp(prev_assigned_ts + min_gap_seconds, tz)
# Build the two allowed windows for a given date.
def windows_for(day):
return [
(
datetime(day.year, day.month, day.day, 0, 0, 0, tzinfo=tz),
datetime(day.year, day.month, day.day, 7, 30, 0, tzinfo=tz),
),
(
datetime(day.year, day.month, day.day, 19, 0, 0, tzinfo=tz),
datetime(day.year, day.month, day.day, 23, 59, 0, tzinfo=tz),
),
]
# Choose the first valid timestamp on or after the lower bound.
chosen_dt = None
# Search forward until we find a valid slot.
while chosen_dt is None:
for window_start, window_end in windows_for(candidate_date):
# Start at the opening of the window.
slot = window_start
# If we already have a monotonic lower bound, honour it.
if earliest_allowed_dt is not None and slot < earliest_allowed_dt:
slot = earliest_allowed_dt
# If the original time is already inside an allowed window on this date,
# prefer to keep it, provided it does not violate monotonic ordering.
if candidate_date == original_dt.date() and window_start <= original_dt <= window_end:
if earliest_allowed_dt is None:
slot = original_dt
elif original_dt >= earliest_allowed_dt:
slot = original_dt
# Accept the slot if it still lands inside the current window.
if slot <= window_end:
chosen_dt = slot
break
# If there was no room on that date, move to the next calendar day.
if chosen_dt is None:
candidate_date = candidate_date + timedelta(days=1)
# Convert the chosen datetime back into fast-export's expected byte format.
chosen_raw = date_to_string(chosen_dt)
# Apply the same rewritten datetime to both author and committer dates.
commit.author_date = chosen_raw
commit.committer_date = chosen_raw
# Remember this timestamp so later commits are never earlier.
prev_assigned_ts = int(chosen_dt.timestamp())
PY
# Write the message callback.
cat >"${MESSAGE_CALLBACK_FILE}" <<'PY'
# Remove Claude/Codex co-author lines together with the non-blank lines that
# follow them, then any other "Co-Authored" or "Claude-Session:" lines.
message = re.sub(
br'(?im)^[ \t]*Co-Authored[^\r\n]*\b(?:claude|codex)\b[^\r\n]*(?:\r?\n[^\r\n]+)*(?:\r?\n|$)',
b'',
message,
)
message = re.sub(
br'(?im)^(?:[ \t]*Co-Authored|[^\r\n]*Claude-Session:)[^\r\n]*(?:\r?\n|$)',
b'',
message,
)
# Collapse runs of blank lines left behind and trim the end of the message.
message = re.sub(br'(?:\r?\n){3,}', b'\n\n', message)
# Return the rewritten message bytes.
return message
PY
# Export the new identity so the Python callbacks can read it.
export REWRITE_NEW_NAME="${NEW_NAME}"
export REWRITE_NEW_EMAIL="${NEW_EMAIL}"
# Ask for a one-hour preferred gap between rewritten commits.
export REWRITE_MIN_GAP_SECONDS="3600"
# Run the history rewrite in the current repository.
git filter-repo \
--force \
--message-callback "$(cat "${MESSAGE_CALLBACK_FILE}")" \
--commit-callback "$(cat "${COMMIT_CALLBACK_FILE}")"
# Tell the user the rewrite finished.
echo
echo "Rewrite complete."
echo "Backup ref: ${BACKUP_REF}"
echo
echo "Check the rewritten history with:"
echo " git log --pretty=fuller --date=iso"
echo
echo "If it looks right, force-push with:"
echo " git push --force --all"
echo " git push --force --tags"