Last active
February 17, 2025 02:19
-
-
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.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/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() |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
$ 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