Skip to content

Instantly share code, notes, and snippets.

@marcellodesales
Last active February 17, 2025 02:19
Show Gist options
  • Save marcellodesales/96f4e0cb327675aa340399c5e7ad400c to your computer and use it in GitHub Desktop.
Save marcellodesales/96f4e0cb327675aa340399c5e7ad400c to your computer and use it in GitHub Desktop.
Fix commitlint errors with Python providing the current branch and the base branch where it will merge to: This script fixes commit messages in a branch to adhere to commitlint rules and a maximum line width for the commit body.
#!/usr/bin/env python3
"""
fix_commit_messages.py
This script fixes commit messages in a branch to adhere to commitlint rules and a
maximum line width for the commit body.
It supports two modes:
1. Main mode:
Usage: python3 fix_commit_messages.py <current-branch> <base-branch> [max_line_length]
- Checks out the current branch.
- Determines the merge base with the base branch.
- Rewrites all commits from that merge base to HEAD using git filter-branch.
- The optional [max_line_length] sets the maximum width for the commit body (default is 100).
2. Message-filter mode:
When invoked with --msg-filter, the script reads a commit message from STDIN,
fixes the header by removing an unwanted leading 'L' (if any) and lowercasing the commit type,
reflows the commit body so that each line does not exceed the provided max_line_length,
and writes the modified commit message to STDOUT.
After running in main mode, review the changes and force-push your branch.
"""
import sys
import os
import re
import subprocess
import textwrap
def fix_header(header: str) -> str:
"""
Fixes the commit header by:
- Removing an optional leading 'L' or 'l'
- Lowercasing the commit type (the first word)
- Preserving an optional scope and the rest of the header
- Ensuring that if a closing parenthesis exists, it is immediately followed by a colon.
"""
header = header.strip()
def repl(match):
commit_type = match.group(1).lower() # force lowercase
scope = match.group(2) if match.group(2) else ""
rest = match.group(3) if match.group(3) else ""
return commit_type + scope + rest
# Remove optional leading 'L' (or 'l') and lowercase the commit type.
header = re.sub(r'^(?:[Ll])?([A-Za-z]+)(\([^)]*\))?(.*)$', repl, header)
# Ensure that a colon immediately follows a closing parenthesis.
header = re.sub(r'(\))([^:])', r'\1: \2', header)
return header
def fix_body(body: str, max_length: int) -> str:
"""
Reflows the commit body so that each paragraph is wrapped to the maximum width.
Paragraphs are assumed to be separated by one or more blank lines.
"""
paragraphs = body.split("\n\n")
fixed_paragraphs = []
for para in paragraphs:
# Remove extra spaces and join lines into a single paragraph.
lines = para.splitlines()
joined = " ".join(line.strip() for line in lines if line.strip())
if joined:
fixed_paragraphs.append(textwrap.fill(joined, width=max_length))
else:
fixed_paragraphs.append("")
return "\n\n".join(fixed_paragraphs)
def msg_filter():
"""
Reads a commit message from STDIN, fixes the header and reflows the body,
and writes the modified commit message to STDOUT.
"""
# Determine max line length: check if provided as a command-line argument
# after --msg-filter, or in the MAX_LINE_LENGTH environment variable.
max_length = 100 # default
if len(sys.argv) > 2:
try:
max_length = int(sys.argv[2])
except ValueError:
pass
elif "MAX_LINE_LENGTH" in os.environ:
try:
max_length = int(os.environ["MAX_LINE_LENGTH"])
except ValueError:
pass
commit_msg = sys.stdin.read()
lines = commit_msg.splitlines()
if lines:
header = lines[0]
rest = "\n".join(lines[1:]) if len(lines) > 1 else ""
fixed_header = fix_header(header)
fixed_body_msg = fix_body(rest, max_length) if rest.strip() else ""
if fixed_body_msg:
new_msg = fixed_header + "\n\n" + fixed_body_msg
else:
new_msg = fixed_header
sys.stdout.write(new_msg)
else:
sys.stdout.write(commit_msg)
def main():
# Usage: fix_commit_messages.py <current-branch> <base-branch> [max_line_length]
if len(sys.argv) < 3:
print(f"Usage: {sys.argv[0]} <current-branch> <base-branch> [max_line_length]")
sys.exit(1)
current_branch = sys.argv[1]
base_branch = sys.argv[2]
max_length = sys.argv[3] if len(sys.argv) > 3 else "100"
print(f"Checking out branch '{current_branch}'...")
subprocess.run(["git", "checkout", current_branch], check=True)
merge_base = subprocess.check_output(
["git", "merge-base", current_branch, base_branch]
).strip().decode("utf-8")
print(f"Merge base between '{current_branch}' and '{base_branch}': {merge_base}")
commit_range = f"{merge_base}..HEAD"
print(f"Rewriting commits in range: {commit_range}")
print("WARNING: This rewrites history; be sure you know what you're doing.")
# Get the full path to this script so that it can be used as the message filter.
script_path = os.path.realpath(__file__)
# Pass the max_line_length to the message filter mode.
msg_filter_cmd = f"python3 {script_path} --msg-filter {max_length}"
subprocess.run(
["git", "filter-branch", "-f", "--msg-filter", msg_filter_cmd, commit_range],
check=True,
)
print("Done rewriting commit messages.")
print("If everything looks good, remember to force-push your changes:")
print(" git push --force-with-lease")
if __name__ == "__main__":
if "--msg-filter" in sys.argv:
msg_filter()
else:
main()
$ python3 fix-commitlint-errors.py feature/dry-github-action-workflows main
Checking out branch 'feature/dry-github-action-workflows'...
Already on 'feature/dry-github-action-workflows'
Your branch is up to date with 'origin/feature/dry-github-action-workflows'.
Merge base between 'feature/dry-github-action-workflows' and 'main': 9b2a4c8a40fe740c0b3694ae6125003721edc701
Rewriting commits in range: 9b2a4c8a40fe740c0b3694ae6125003721edc701..HEAD
WARNING: This rewrites history; be sure you know what you're doing.
WARNING: git-filter-branch has a glut of gotchas generating mangled history
rewrites. Hit Ctrl-C before proceeding to abort, then use an
alternative filtering tool such as 'git filter-repo'
(https://github.com/newren/git-filter-repo/) instead. See the
filter-branch manual page for more details; to squelch this warning,
set FILTER_BRANCH_SQUELCH_WARNING=1.
Proceeding with filter-branch...
Rewrite 3e402b1dfaf8919b81fad7ef0826ba799e5d0325 (15/18) (1 seconds passed, remaining 0 predicted)
Ref 'refs/heads/feature/dry-github-action-workflows' was rewritten
Done rewriting commit messages.
If everything looks good, remember to force-push your changes:
git push --force-with-lease
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment