Skip to content

Instantly share code, notes, and snippets.

@medmunds
Created May 9, 2026 00:30
Show Gist options
  • Select an option

  • Save medmunds/147ae85c0884731cd1ff52709a0899cb to your computer and use it in GitHub Desktop.

Select an option

Save medmunds/147ae85c0884731cd1ff52709a0899cb to your computer and use it in GitHub Desktop.
rename-email-providers
#!/usr/bin/env python
#
# Rename EMAIL_PROVIDERS to MAILERS (and related renaming) in all commits on
# the current branch.
#
# (With some assistance -- and more than a few false starts -- from GPT-5.5,
# plus many helpful "next edit suggestions" from PyCharm AI Assistant.)
#
# Strategy: apply the renaming within patch files, which limits the
# substitutions to the specific lines related to these changes.
# 1. Export patch files for all commits on the branch.
# 2. Edit the patch files to reflect the renaming in most diff lines.
# 3. Switch to a new branch, rewound to the base of this one.
# 4. Apply the modified patches in sequence to recreate
# the original commits with the new naming.
#
# This strategy should be effective for code -- including many docstrings and
# comments -- plus commit messages. Documentation and other long prose will
# require significant additional editing.
#
# The patches from steps 1 and 2 are left in patches/original and
# patches/renamed by default. Manual review of the diffs between those
# directories is recommended. (Run with `--dry-run` to stop after step 2.)
#
# Black, flake8, and other linters are deliberately *not* applied in step 4,
# to avoid conflicts with the unapplied patches. Those can be applied after
# with something like:
#
# git rebase -i main --exec \
# 'FILES=$(git diff-tree --no-commit-id --name-only -r HEAD); \
# [ -z "$FILES" ] || \
# (echo "$FILES" | xargs pre-commit run --files; \
# echo "$FILES" | xargs git add; \
# git diff --cached --quiet || git commit --amend --no-edit)'
#
# (Correct shell escaping and wrapping is left as an exercise for the reader.)
import argparse
import re
import subprocess
import sys
from pathlib import Path
from textwrap import dedent
replacements: list[tuple[re.Pattern, str]] = [
# Case-preserving substitutions, most to least specific.
# This takes advantage of the fact that plural forms will already have
# the "S" in the correct case, so we can just replace the singular portion.
(re.compile(r"EMAIL_PROVIDER"), "MAILER"),
(re.compile(r"EmailProvider"), "Mailer"),
(re.compile(r"Email\s+provider"), "Mailer"),
(re.compile(r"email\s+provider"), "mailer"),
(re.compile(r"email-provider"), "mailer"),
(re.compile(r"email_provider"), "mailer"),
(re.compile(r"Provider"), "Mailer"),
(re.compile(r"provider"), "mailer"),
]
def rewrite_text(text: str) -> str:
for pattern, replacement in replacements:
text = pattern.sub(replacement, text)
return text
def rewrite_patch(old_patch_path: Path, new_patch_path: Path) -> bool:
old = old_patch_path.read_text(encoding="utf-8")
lines = old.splitlines(keepends=True)
new_lines = []
in_headers = True
in_trailers = False
in_docs_diff = False
for line in lines:
new_line = line
if line.startswith("diff --git "):
in_headers = False
in_docs_diff = line.startswith("diff --git a/docs/")
if not in_headers and line == "-- \n":
in_trailers = True
if in_headers:
# The commit message and other metadata, formatted as an email
# message. Rewrite Subject and body text, but not other metadata.
# This is good enough but gets rewrites wrong if they span wrapped
# lines (including folded "headers"). It would be better to run
# email.parser on the entire patch; split the body into leader
# (commit message), diffs, and trailer; and then rewrite in the
# (unfolded) Subject header, the commit message with multiline
# matching, and the diffs with the line-by-line logic here.
if not line.startswith(("From ", "From:", "Date:")):
new_line = rewrite_text(line)
if not (in_headers or in_trailers):
# Inside the body of the patch. In general, we want to rewrite all
# diffs lines _including_ '-' lines (that were likely rewritten in
# earlier patches that created them). The exception is in the docs,
# where "provider" is often in the previous text, and rewriting '-'
# lines causes "patch does not apply, did you edit it by hand?"
if (
line.startswith(("+", "-", " "))
and not line.startswith(("+++ ", "--- "))
and not (in_docs_diff and line.startswith("-"))
):
new_line = line[0] + rewrite_text(line[1:])
new_lines.append(new_line)
new = "".join(new_lines)
new_patch_path.write_text(new, encoding="utf-8")
return new == old
def get_merge_base(old_branch: str) -> str:
return (
subprocess.check_output(["git", "merge-base", "main", old_branch])
.decode()
.strip()
)
def generate_patches(*, patch_dir: Path, old_branch: str, merge_base: str) -> Path:
commit_range = f"{merge_base}..{old_branch}"
old_patch_dir = patch_dir / "original"
old_patch_dir.mkdir(exist_ok=True, parents=True)
new_patch_dir = patch_dir / "renamed"
new_patch_dir.mkdir(exist_ok=True, parents=True)
run(["git", "format-patch", "--no-stat", commit_range, "-o", old_patch_dir])
for old_patch_path in old_patch_dir.glob("*.patch"):
new_patch_path = new_patch_dir / old_patch_path.name
rewrite_patch(old_patch_path, new_patch_path)
return new_patch_dir
def switch_to_new_branch(*, new_branch: str, merge_base: str):
# This will error if new_branch already exists.
run(["git", "switch", "-c", new_branch, merge_base])
def apply_patches(*, patch_dir: Path):
patch_paths = sorted(patch_dir.glob("*.patch"))
print(dedent("""\
=====
Recreating commits with renaming applied.
If `git am` stops, resolve any conflicts and then:
git add path/to/resolved/files
git am --continue
These commands may be helpful:
git am --show-current-patch=diff
git status
For "patch does not apply" errors, edit the patch file and fix
diff context and "-" lines to match the current content, then:
git am --continue
Or to quit so you can start over:
git am --abort
=====
"""))
run(["git", "am", "--3way", *patch_paths], check=False)
def run(args: list[str | Path], **kwargs):
print(" ".join(str(arg) for arg in args), flush=True)
kwargs.setdefault("check", True)
subprocess.run(args, **kwargs)
def exit_with_error(message: str):
print(message, file=sys.stderr)
sys.exit(1)
parser = argparse.ArgumentParser()
parser.add_argument("--patch-dir", default="patches", type=Path)
parser.add_argument("--old-branch", default="email-providers")
parser.add_argument("--new-branch", default="email-providers-rename")
parser.add_argument("--dry-run", "-n", action="store_true")
if __name__ == "__main__":
options = parser.parse_args()
if options.patch_dir.exists() and any(options.patch_dir.iterdir()):
exit_with_error(
f"Will not overwrite existing patch-dir '{options.patch_dir}'."
" Remove it first."
)
# Staged changes break `git am`.
# (Untracked changes, like a patch dir, are OK.)
if subprocess.check_output(["git", "diff"]).decode().strip():
exit_with_error(
"Working directory is not clean. Commit or stash changes before running."
)
base = get_merge_base(options.old_branch)
renamed_patch_dir = generate_patches(
patch_dir=options.patch_dir, old_branch=options.old_branch, merge_base=base
)
if options.dry_run:
print("Patches generated. To continue, run:")
print(f" git switch -c {options.new_branch} {base}")
print(f" git am --3way {renamed_patch_dir}/*.patch")
else:
switch_to_new_branch(new_branch=options.new_branch, merge_base=base)
apply_patches(patch_dir=renamed_patch_dir)
@medmunds

medmunds commented May 9, 2026

Copy link
Copy Markdown
Author

Script that renamed EMAIL_PROVIDERS to MAILERS in unmerged commits in django/django#21231.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment