Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions oca_port/migrate_addon.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@

import click

from oca_port.squash_bot_commits import SquashBotCommits

from .port_addon_pr import PortAddonPullRequest
from .utils import git as g
from .utils.misc import Output, bcolors as bc
Expand Down Expand Up @@ -161,6 +163,7 @@ def run(self):
# Check if the addon has commits that update neighboring addons to
# make it work properly
PortAddonPullRequest(self.app, push_branch=False).run()
SquashBotCommits(self.app).run()
self._print_tips(adapted=adapted)
return True, None

Expand Down
199 changes: 199 additions & 0 deletions oca_port/squash_bot_commits.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
import subprocess
from .utils.misc import Output, bcolors as bc
import click
import tempfile
import os
from .utils import git as g


class SquashBotCommits(Output):
"""
Interactive squash for these commits:
1) Bot commit: squashed into the "real" commit that generates them
2) Translation commit: squashed into commits that translate the same language and come from the same author
"""

def __init__(self, app) -> None:
self.app = app
self.all_commits, self.commits_by_sha = self._get_all_commits()
self.skipped_commits = []

def run(self):
if self.app.non_interactive or self.app.dry_run:
return False
click.echo(
click.style(
"🚀 Starting reducing number of commits...",
bold=True,
),
)
squashable_commits = self._get_squashable_commits()
while len(squashable_commits) > 0:
commit = squashable_commits.pop(0)
squashed_into_commits = self._get_squashed_into_commits(commit)
if not squashed_into_commits:
self.skipped_commits.append(commit)
continue
result = self.squash(commit, squashed_into_commits)
if not result:
confirm = "Skip this commit?"
if click.confirm(confirm):
self.skipped_commits.append(commit)
print(
f"\nSkipped {bc.OKCYAN}{commit.hexsha[:7]}{bc.ENDC} {commit.summary}\n"
)
# update to get new SHAs
self.all_commits, self.commits_by_sha = self._get_all_commits()
squashable_commits = self._get_squashable_commits()
print("\n")

def _get_squashed_into_commits(self, target_commit):
"""Return commits that target_commit can be squashed into"""
result = []
# find commits for the same language coming from the same author.
if target_commit._is_translation_commit():
valid_commits = []
valid_commits = [
c
for c in self.all_commits
if c._is_same_language(target_commit)
and c not in result
and c != target_commit
]
if valid_commits:
result.extend(valid_commits)

elif target_commit._is_bot_commit():
# traverse to find real commit that generates bot commits
parent_commit = target_commit.parents[0]
com = self.commits_by_sha.get(parent_commit, None)
while com:
if (
com
and not com._is_bot_commit()
and not com._is_translation_commit()
):
result.append(com)
break
parent_commit = com.parents[0]
com = self.commits_by_sha.get(parent_commit, None)
return result

def _get_all_commits(self):
"""Get commits from the local repository for current branch.
Return two data structures:
- a list of Commit objects `[Commit, ...]`
- a dict of Commits objects grouped by SHA `{SHA: Commit, ...}`
"""
commits = self.app.repo.iter_commits(f"{self.app.target_version}...HEAD")
commits_list = []
commits_by_sha = {}
for commit in commits:
com = g.Commit(
commit, addons_path=self.app.addons_rootdir, cache=self.app.cache
)
commits_list.append(com)
commits_by_sha[commit.hexsha] = com
return commits_list, commits_by_sha

def _get_squashable_commits(self):
result = [
commit
for commit in self.all_commits
if (commit._is_bot_commit() or commit._is_translation_commit)
and not self.is_skipped_commit(commit)
]
return result

def squash(self, commit, squashable_commits):
self._print(
f"Squashing {bc.OKCYAN}{commit.hexsha[:7]}{bc.ENDC} {commit.summary}"
)
available_commits = [c for c in squashable_commits if c.hexsha != commit.hexsha]
self._print(f"0) {bc.BOLD}Skip this commit{bc.END}")
for idx, c in enumerate(available_commits):
self._print(f"{idx + 1}) {bc.OKCYAN}{c.hexsha[:7]}{bc.ENDC} {c.summary}")

def is_valid(val):
try:
value = int(val)
except ValueError:
raise click.BadParameter("Please enter a valid number.")

if value < 0 or value > len(available_commits):
raise click.BadParameter("Please enter a valid number.")
return value

choice = click.prompt(
"Select a commit to squash into:",
default=0,
value_proc=is_valid,
)
if not choice: # if choice = 0
self.skipped_commits.append(commit)
return False
selected_commit = available_commits[choice - 1]
reorder = selected_commit.hexsha != commit.parents[0]
return self._squash(commit, selected_commit, reorder)

def _squash(self, commit, target_commit, reorder=False):
base_commit = target_commit.parents[0]
confirm = "\n".join(
[
"\nCommits to Squash:",
f"\t{bc.OKCYAN}{commit.hexsha[:7]}{bc.ENDC} {commit.summary}",
f"\t{bc.OKCYAN}{target_commit.hexsha[:7]}{bc.ENDC} {target_commit.summary}\n",
]
)
if not click.confirm(confirm):
return False
editor_script = ""
if reorder:
with tempfile.NamedTemporaryFile(delete=False, mode="w") as temp_file:
editor_script = temp_file.name
temp_file.write(
f"""#!/bin/bash
todo_file=".git/rebase-merge/git-rebase-todo"
tmp_file="$todo_file.tmp"

# Copy todo_file to a temporary file
cp "$todo_file" "$tmp_file"
printf "%s\\n" "/^pick {commit.hexsha[:7]}/ m1" "wq" | ed -s "$tmp_file"
printf "%s\\n" "/^pick {commit.hexsha[:7]} /s//squash {commit.hexsha[:7]} /" "wq" | ed -s "$tmp_file"
mv "$tmp_file" "$todo_file"
"""
)
os.chmod(editor_script, 0o755)
result = subprocess.run(
f"GIT_SEQUENCE_EDITOR='{editor_script}' GIT_EDITOR=true git rebase -i {base_commit}",
capture_output=True,
shell=True,
)
else:
command = f"GIT_SEQUENCE_EDITOR='sed -i \"s/^pick {commit.hexsha[:7]} /squash {commit.hexsha[:7]} /\"' GIT_EDITOR=true git rebase -i {base_commit}"
result = subprocess.run(command, capture_output=True, shell=True)
output = result.stdout.decode("utf-8")
if editor_script:
os.remove(editor_script)

if "CONFLICT" in output:
self._print(f"\n{bc.FAIL}ERROR: A conflict occurs{bc.ENDC}")
self._print(
"\n ⚠️You can't squash those commits together and they should be left as is"
)
self._abort_rebase()
return False
click.echo(
click.style(
"✨ Done! Successfully squashed.",
fg="green",
bold=True,
)
)
return True

def _abort_rebase(self):
self.app.repo.git.rebase("--abort")

def is_skipped_commit(self, commit):
return commit in self.skipped_commits
43 changes: 43 additions & 0 deletions oca_port/utils/git.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,21 @@
from .misc import bcolors as bc, pr_ref_from_url

PO_FILE_REGEX = re.compile(r".*i18n/.+\.pot?$")
TRANSLATION_SUMMARY = [
"Added translation using Weblate",
"Translated using Weblate",
]
SQUASHABLE_SUMMARY = [
"Update translation files",
]
SQUASHABLE_AUTHOR_EMAIL = [
"transbot@odoo-community.org",
"noreply@weblate.org",
"oca-git-bot@odoo-community.org",
"oca+oca-travis@odoo-community.org",
"oca-ci@odoo-community.org",
"shopinvader-git-bot@shopinvader.com",
]
Comment on lines +16 to +30
Copy link
Copy Markdown
Collaborator

@sebalix sebalix Jan 15, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These values are also declared in port_addon_pr.py module.
I would rename SQUASHABLE_AUTHOR_EMAIL to AUTHOR_EMAILS, then use them in your code below and in port_addon_pr.py with g.AUTHOR_EMAILS and g.TRANSLATION_SUMMARIES in replacement of AUTHOR_EMAILS_TO_SKIP and SUMMARY_TERMS_TO_SKIP



class Branch:
Expand Down Expand Up @@ -221,6 +236,34 @@ def diffs(self):
return self.raw_commit.diff(self.raw_commit.parents[0], R=True)
return self.raw_commit.diff(g.NULL_TREE)

def _is_bot_commit(self):
if (
any([msg in self.summary for msg in SQUASHABLE_SUMMARY])
or self.author_email in SQUASHABLE_AUTHOR_EMAIL
):
return True
return False

def _is_translation_commit(self):
return any([msg in self.summary for msg in TRANSLATION_SUMMARY])

def _is_same_language(self, other):
"""
Used for translation commit
Compare 2 commits whether they translate the same language and come from same author
"""
if not isinstance(other, Commit):
return False
lang = re.search(r"\(([^)]+)\)", self.summary)
lang_other = re.search(r"\(([^)]+)\)", other.summary)
if lang and lang_other:
lang = lang.group(1).strip()
lang_other = lang_other.group(1).strip()

if lang == lang_other and self.author_email == other.author_email:
return True
return False


@contextlib.contextmanager
def no_strict_commit_equality():
Expand Down