feature: initial implementation of squash functionality for git and hg; no enable/disable settings option yet

This commit is contained in:
ievgenii vdovenko 2025-07-02 17:05:00 +02:00
parent 5e3cc80c7b
commit 802e8a8119
8 changed files with 206 additions and 88 deletions

View file

@ -1498,6 +1498,10 @@ class RepoPullRequestsView(RepoAppView, DataGridAppView):
if merge_possible: if merge_possible:
log.debug("Pre-conditions checked, trying to merge.") log.debug("Pre-conditions checked, trying to merge.")
controls = peppercorn.parse(self.request.POST.items())
squash_before_merge = str2bool(controls.get("squash_before_merge", "false"))
extras = vcs_operation_context( extras = vcs_operation_context(
self.request.environ, self.request.environ,
repo_name=pull_request.target_repo.repo_name, repo_name=pull_request.target_repo.repo_name,
@ -1506,7 +1510,9 @@ class RepoPullRequestsView(RepoAppView, DataGridAppView):
scm=pull_request.target_repo.repo_type, scm=pull_request.target_repo.repo_type,
) )
with pull_request.set_state(PullRequest.STATE_UPDATING): with pull_request.set_state(PullRequest.STATE_UPDATING):
self._merge_pull_request(pull_request, self._rhodecode_db_user, extras) self._merge_pull_request(
pull_request, self._rhodecode_db_user, extras, squash_commits=squash_before_merge
)
else: else:
log.debug("Pre-conditions failed, NOT merging.") log.debug("Pre-conditions failed, NOT merging.")
@ -1518,9 +1524,9 @@ class RepoPullRequestsView(RepoAppView, DataGridAppView):
) )
) )
def _merge_pull_request(self, pull_request, user, extras): def _merge_pull_request(self, pull_request, user, extras, squash_commits=False):
_ = self.request.translate _ = self.request.translate
merge_resp = PullRequestModel().merge_repo(pull_request, user, extras=extras) merge_resp = PullRequestModel().merge_repo(pull_request, user, extras=extras, squash_commits=squash_commits)
if merge_resp.executed: if merge_resp.executed:
log.debug("The merge was successful, closing the pull request.") log.debug("The merge was successful, closing the pull request.")

View file

@ -1169,6 +1169,7 @@ class BaseRepository(object):
dry_run=False, dry_run=False,
use_rebase=False, use_rebase=False,
close_branch=False, close_branch=False,
squash_commits=False,
): ):
""" """
Merge the revisions specified in `source_ref` from `source_repo` Merge the revisions specified in `source_ref` from `source_repo`
@ -1222,6 +1223,7 @@ class BaseRepository(object):
dry_run=dry_run, dry_run=dry_run,
use_rebase=use_rebase, use_rebase=use_rebase,
close_branch=close_branch, close_branch=close_branch,
squash_commits=squash_commits,
) )
except RepositoryError as exc: except RepositoryError as exc:
log.exception("Unexpected failure when running merge, dry-run=%s", dry_run) log.exception("Unexpected failure when running merge, dry-run=%s", dry_run)
@ -1240,6 +1242,7 @@ class BaseRepository(object):
dry_run=False, dry_run=False,
use_rebase=False, use_rebase=False,
close_branch=False, close_branch=False,
squash_commits=False,
): ):
"""Internal implementation of merge.""" """Internal implementation of merge."""
raise NotImplementedError raise NotImplementedError

View file

@ -24,6 +24,7 @@ import logging
import os import os
import re import re
from celery.platforms import strargv
from zope.cachedescriptors.property import Lazy as LazyProperty from zope.cachedescriptors.property import Lazy as LazyProperty
from collections import OrderedDict from collections import OrderedDict
@ -842,7 +843,7 @@ class GitRepository(BaseRepository):
cmd.extend(["--no-tags", repository_path, branch_name]) cmd.extend(["--no-tags", repository_path, branch_name])
self.run_git_command(cmd, fail_on_stderr=False) self.run_git_command(cmd, fail_on_stderr=False)
def _local_merge(self, merge_message, user_name, user_email, heads): def _local_merge(self, merge_message, user_name, user_email, heads, squash_commits=False):
""" """
Merge the given head into the checked out branch. Merge the given head into the checked out branch.
@ -853,6 +854,7 @@ class GitRepository(BaseRepository):
:param merge_message: The message to use for the merge commit. :param merge_message: The message to use for the merge commit.
:param heads: the heads to merge. :param heads: the heads to merge.
:param squash_commits: Use squash strategy to merge.
""" """
if self.bare: if self.bare:
raise RepositoryError("Cannot merge into a bare git repository") raise RepositoryError("Cannot merge into a bare git repository")
@ -863,40 +865,68 @@ class GitRepository(BaseRepository):
if self.is_empty(): if self.is_empty():
# TODO(skreft): do something more robust in this case. # TODO(skreft): do something more robust in this case.
raise RepositoryError("Do not know how to merge into empty repositories yet") raise RepositoryError("Do not know how to merge into empty repositories yet")
unresolved = None
# N.B.(skreft): the --no-ff option is used to enforce the creation of a base_cmd = [
# commit message. We also specify the user who is doing the merge.
cmd = [
"-c", "-c",
f'user.name="{user_name}"', f'user.name="{user_name}"',
"-c", "-c",
f"user.email={user_email}", f"user.email={user_email}",
"merge",
"--no-ff",
"-m",
safe_str(merge_message),
] ]
merge_cmd = cmd + heads if squash_commits:
self._squash_merge(base_cmd, heads, merge_message)
else:
self._normal_merge(base_cmd, heads, merge_message)
def _normal_merge(self, base_cmd, heads, merge_message):
try: try:
# N.B.(skreft): the --no-ff option is used to enforce the creation of a
# commit message. We also specify the user who is doing the merge.
merge_cmd = base_cmd + ["merge", "--no-ff", "-m", safe_str(merge_message)] + heads
log.debug("Merge command: %s", merge_cmd)
self.run_git_command(merge_cmd, fail_on_stderr=False) self.run_git_command(merge_cmd, fail_on_stderr=False)
except RepositoryError: except RepositoryError as e:
files = self.run_git_command(["diff", "--name-only", "--diff-filter", "U"], fail_on_stderr=False)[ log.warning("Merge failed with error: %s", e)
0 self._abort_merge()
].splitlines()
# NOTE(marcink): we add U notation for consistent with HG backend output
unresolved = [f"U {f}" for f in files]
# Cleanup any merge leftovers def _squash_merge(self, base_cmd, heads, merge_message):
self._remote.invalidate_vcs_cache() try:
self.run_git_command(["merge", "--abort"], fail_on_stderr=False) squash_cmd = (
base_cmd
+ [
"merge",
"--squash",
]
+ heads
)
if unresolved: log.debug("Squash command: %s", squash_cmd)
raise UnresolvedFilesInRepo(unresolved) self.run_git_command(squash_cmd, fail_on_stderr=False)
else:
raise commit_cmd = base_cmd + ["commit", "-m", safe_str(merge_message)]
log.debug("Commit after squash command: %s", commit_cmd)
self.run_git_command(commit_cmd, fail_on_stderr=False)
except RepositoryError as e:
log.warning("Squash failed with error: %s", e)
self._abort_merge()
def _abort_merge(self):
files = self.run_git_command(["diff", "--name-only", "--diff-filter", "U"], fail_on_stderr=False)[
0
].splitlines()
# NOTE(marcink): we add U notation for consistent with HG backend output
unresolved = [f"U {f}" for f in files]
# Cleanup merge state
self._remote.invalidate_vcs_cache()
self.run_git_command(["merge", "--abort"], fail_on_stderr=False)
if unresolved:
raise UnresolvedFilesInRepo(unresolved)
raise RepositoryError("Merge failed without unresolved files")
def _local_push(self, source_branch, repository_path, target_branch, enable_hooks=False, rc_scm_data=None): def _local_push(self, source_branch, repository_path, target_branch, enable_hooks=False, rc_scm_data=None):
""" """
@ -961,10 +991,16 @@ class GitRepository(BaseRepository):
dry_run=False, dry_run=False,
use_rebase=False, use_rebase=False,
close_branch=False, close_branch=False,
squash_commits=False,
): ):
log.debug( if squash_commits:
"Executing merge_repo with %s strategy, dry_run mode:%s", "rebase" if use_rebase else "merge", dry_run strategy = "squash and merge"
) elif use_rebase:
strategy = "rebase"
else:
strategy = "merge"
log.debug("Executing merge_repo with '%s' strategy, dry_run mode: %s", strategy, dry_run)
from rhodecode.model.repo import RepoModel from rhodecode.model.repo import RepoModel
if target_ref.commit_id != self.branches[target_ref.name]: if target_ref.commit_id != self.branches[target_ref.name]:
@ -1026,7 +1062,9 @@ class GitRepository(BaseRepository):
merge_failure_reason = MergeFailureReason.NONE merge_failure_reason = MergeFailureReason.NONE
metadata = {} metadata = {}
try: try:
shadow_repo._local_merge(merge_message, merger_name, merger_email, [source_ref.commit_id]) shadow_repo._local_merge(
merge_message, merger_name, merger_email, [source_ref.commit_id], squash_commits=squash_commits
)
merge_possible = True merge_possible = True
# Need to invalidate the cache, or otherwise we # Need to invalidate the cache, or otherwise we

View file

@ -680,6 +680,7 @@ class MercurialRepository(BaseRepository):
use_rebase=False, use_rebase=False,
close_commit_id=None, close_commit_id=None,
dry_run=False, dry_run=False,
squash_commits=False,
): ):
""" """
Merge the given source_revision into the checked out revision. Merge the given source_revision into the checked out revision.
@ -711,11 +712,11 @@ class MercurialRepository(BaseRepository):
return source_ref_commit_id, True return source_ref_commit_id, True
unresolved = None unresolved = None
if use_rebase: if use_rebase or squash_commits:
try: try:
bookmark_name = f"rcbook{source_ref_commit_id}{target_ref_commit_id}" bookmark_name = f"rcbook{source_ref_commit_id}{target_ref_commit_id}"
self.bookmark(bookmark_name, revision=source_ref.commit_id) self.bookmark(bookmark_name, revision=source_ref.commit_id)
self._remote.rebase(source=source_ref_commit_id, dest=target_ref_commit_id) self._remote.rebase(source=source_ref_commit_id, dest=target_ref_commit_id, collapse=squash_commits)
self._remote.invalidate_vcs_cache() self._remote.invalidate_vcs_cache()
self._update(bookmark_name, clean=True) self._update(bookmark_name, clean=True)
return self._identify(), True return self._identify(), True
@ -809,10 +810,16 @@ class MercurialRepository(BaseRepository):
dry_run=False, dry_run=False,
use_rebase=False, use_rebase=False,
close_branch=False, close_branch=False,
squash_commits=False,
): ):
log.debug( if squash_commits:
"Executing merge_repo with %s strategy, dry_run mode:%s", "rebase" if use_rebase else "merge", dry_run strategy = "squash and merge"
) elif use_rebase:
strategy = "rebase"
else:
strategy = "merge"
log.debug("Executing merge_repo with '%s' strategy, dry_run mode: %s", strategy, dry_run)
from rhodecode.model.repo import RepoModel from rhodecode.model.repo import RepoModel
if target_ref.commit_id not in self._heads(): if target_ref.commit_id not in self._heads():
@ -899,6 +906,7 @@ class MercurialRepository(BaseRepository):
use_rebase=use_rebase, use_rebase=use_rebase,
close_commit_id=close_commit_id, close_commit_id=close_commit_id,
dry_run=dry_run, dry_run=dry_run,
squash_commits=squash_commits,
) )
merge_possible = True merge_possible = True

View file

@ -1011,12 +1011,12 @@ class PullRequestModel(BaseModel):
return commit_ids return commit_ids
def merge_repo(self, pull_request, user, extras): def merge_repo(self, pull_request, user, extras, squash_commits=False):
repo_type = pull_request.source_repo.repo_type repo_type = pull_request.source_repo.repo_type
log.debug("Merging pull request %s", pull_request) log.debug("Merging pull request %s", pull_request)
extras["user_agent"] = "{}/internal-merge".format(repo_type) extras["user_agent"] = "{}/internal-merge".format(repo_type)
merge_state = self._merge_pull_request(pull_request, user, extras) merge_state = self._merge_pull_request(pull_request, user, extras, squash_commits=squash_commits)
if merge_state.executed: if merge_state.executed:
log.debug("Merge was successful, updating the pull request comments.") log.debug("Merge was successful, updating the pull request comments.")
self._comment_and_close_pr(pull_request, user, merge_state) self._comment_and_close_pr(pull_request, user, merge_state)
@ -1027,7 +1027,7 @@ class PullRequestModel(BaseModel):
log.warning("Merge failed, not updating the pull request.") log.warning("Merge failed, not updating the pull request.")
return merge_state return merge_state
def _merge_pull_request(self, pull_request, user, extras, merge_msg=None): def _merge_pull_request(self, pull_request, user, extras, merge_msg=None, squash_commits=False):
target_vcs = pull_request.target_repo.scm_instance() target_vcs = pull_request.target_repo.scm_instance()
source_vcs = pull_request.source_repo.scm_instance() source_vcs = pull_request.source_repo.scm_instance()
@ -1067,6 +1067,7 @@ class PullRequestModel(BaseModel):
message=message, message=message,
use_rebase=use_rebase, use_rebase=use_rebase,
close_branch=close_branch, close_branch=close_branch,
squash_commits=squash_commits,
) )
return merge_state return merge_state

View file

@ -521,6 +521,15 @@ input[type="submit"] {
} }
} }
a[role="button"].btn {
&.disabled {
.border ( @border-thickness-buttons, @rcblue );
background-color: @rcblue;
color: white;
opacity: .5;
}
}
input[type="reset"] { input[type="reset"] {
&:extend(.btn-default); &:extend(.btn-default);

View file

@ -1,70 +1,100 @@
<div class="pull-request-wrap"> <div class="pull-request-wrap">
% if c.pr_merge_possible: % if c.pr_merge_possible:
<h2 class="merge-status"> <h2 class="merge-status">
<span class="merge-icon success"><i class="icon-ok"></i></span> <span class="merge-icon success"><i class="icon-ok"></i></span>
${_('This pull request can be merged automatically.')} ${_('This pull request can be merged automatically.')}
</h2> </h2>
% else: % else:
<h2 class="merge-status"> <h2 class="merge-status">
<span class="merge-icon warning"><i class="icon-false"></i></span> <span class="merge-icon warning"><i class="icon-false"></i></span>
${_('Merge is not currently possible because of below failed checks.')} ${_('Merge is not currently possible because of below failed checks.')}
</h2> </h2>
% endif % endif
% if c.pr_merge_errors.items(): % if c.pr_merge_errors.items():
<ul> <ul>
% for pr_check_key, pr_check_details in c.pr_merge_errors.items(): % for pr_check_key, pr_check_details in c.pr_merge_errors.items():
<% pr_check_type = pr_check_details['error_type'] %> <% pr_check_type = pr_check_details['error_type'] %>
<li> <li>
<div class="merge-message ${pr_check_type}" data-role="merge-message"> <div class="merge-message ${pr_check_type}" data-role="merge-message">
<span style="white-space: pre-line">- ${pr_check_details['message']}</span> <span style="white-space: pre-line">- ${pr_check_details['message']}</span>
% if pr_check_key == 'todo': % if pr_check_key == 'todo':
% for co in pr_check_details['details']: % for co in pr_check_details['details']:
<a class="permalink" href="#comment-${co.comment_id}" onclick="Rhodecode.comments.scrollToComment($('#comment-${co.comment_id}'), 0, ${h.str_json(co.outdated)})"> #${co.comment_id}</a>${'' if loop.last else ','} <a class="permalink" href="#comment-${co.comment_id}"
% endfor onclick="Rhodecode.comments.scrollToComment($('#comment-${co.comment_id}'), 0, ${h.str_json(co.outdated)})">
% endif #${co.comment_id}</a>${'' if loop.last else ','}
</div> % endfor
</li> % endif
</div>
</li>
% endfor % endfor
</ul> </ul>
% endif % endif
<div class="pull-request-merge-actions"> <div class="pull-request-merge-actions">
% if c.allowed_to_merge: % if c.allowed_to_merge:
## Merge info, show only if all errors are taken care of ## Merge info, show only if all errors are taken care of
% if not c.pr_merge_errors and c.pr_merge_info: % if not c.pr_merge_errors and c.pr_merge_info:
<div class="pull-request-merge-info"> <div class="pull-request-merge-info">
<ul> <ul>
% for pr_merge_key, pr_merge_details in c.pr_merge_info.items(): % for pr_merge_key, pr_merge_details in c.pr_merge_info.items():
<li> <li>
- ${pr_merge_details['message']} - ${pr_merge_details['message']}
</li> </li>
% endfor % endfor
</ul> </ul>
</div> </div>
% endif % endif
<div> <div>
${h.secure_form(h.route_path('pullrequest_merge', repo_name=c.repo_name, pull_request_id=c.pull_request.pull_request_id), id='merge_pull_request_form', request=request)} ${h.secure_form(h.route_path('pullrequest_merge', repo_name=c.repo_name,
<% merge_disabled = ' disabled' if c.pr_merge_possible is False else '' %> pull_request_id=c.pull_request.pull_request_id), id='merge_pull_request_form', request=request,
style='display: flex; justify-content: flex-end;')}
<% merge_disabled = ' disabled' if c.pr_merge_possible is False else '' %>
% if c.allowed_to_close: % if c.allowed_to_close:
## close PR action, injected later next to COMMENT button ## close PR action, injected later next to COMMENT button
% if c.pull_request_review_status == c.REVIEW_STATUS_APPROVED: % if c.pull_request_review_status == c.REVIEW_STATUS_APPROVED:
<a id="close-pull-request-action" class="btn btn-approved-status" href="#close-as-approved" onclick="closePullRequest('${c.REVIEW_STATUS_APPROVED}'); return false;"> <a id="close-pull-request-action" class="btn btn-approved-status" href="#close-as-approved"
${_('Close with status {}').format(h.commit_status_lbl(c.REVIEW_STATUS_APPROVED))} onclick="closePullRequest('${c.REVIEW_STATUS_APPROVED}'); return false;">
</a> ${_('Close with status {}').format(h.commit_status_lbl(c.REVIEW_STATUS_APPROVED))}
% else: </a>
<a id="close-pull-request-action" class="btn btn-rejected-status" href="#close-as-rejected" onclick="closePullRequest('${c.REVIEW_STATUS_REJECTED}'); return false;"> % else:
${_('Close with status {}').format(h.commit_status_lbl(c.REVIEW_STATUS_REJECTED))} <a id="close-pull-request-action" class="btn btn-rejected-status" href="#close-as-rejected"
</a> onclick="closePullRequest('${c.REVIEW_STATUS_REJECTED}'); return false;">
${_('Close with status {}').format(h.commit_status_lbl(c.REVIEW_STATUS_REJECTED))}
</a>
% endif
% endif % endif
% endif <div class="btn-group btn-group-actions" style="margin-right: 5px;">
<input type="submit" id="merge_pull_request" value="${_('Merge Pull Request')}"
class="btn${merge_disabled}" ${merge_disabled}>
<input type="submit" id="merge_pull_request" value="${_('Merge and close Pull Request')}" class="btn${merge_disabled}"${merge_disabled}> <a class="btn btn-primary btn-more-option${merge_disabled}" data-toggle="dropdown"
${h.end_form()} id="pull_request_more_options" aria-pressed="false" role="button" title="${_('More merge options')}">
<i class="icon-down"></i>
</a>
% if not merge_disabled and c.rhodecode_db_repo.repo_type in ['git', 'hg']:
<div class="btn-action-switcher-container">
<ul class="btn-action-switcher" role="menu" style="min-width: 230px;">
<li>
<a id="squash_merge_pull_request" href="#">
% if c.rhodecode_db_repo.repo_type == 'git':
${_('Squash commits and merge.')}
% elif c.rhodecode_db_repo.repo_type == 'hg':
${_('Collapse commits and merge.')}
% endif
</a>
<input type="hidden" id="squash_merge_pull_request_value" name="squash_before_merge"
value="false">
</li>
</ul>
</div>
% endif
</div>
${h.end_form()}
% if c.rhodecode_edition_id == 'EE': % if c.rhodecode_edition_id == 'EE':
<div class="pull-request-settings" <div class="pull-request-settings"
@ -87,16 +117,36 @@
</div> </div>
% elif c.rhodecode_user.username != h.DEFAULT_USER: % elif c.rhodecode_user.username != h.DEFAULT_USER:
<a class="btn" href="#" onclick="refreshMergeChecks(); return false;">${_('refresh checks')}</a> <a class="btn" href="#" onclick="refreshMergeChecks(); return false;">${_('refresh checks')}</a>
<input type="submit" value="${_('Merge and close Pull Request')}" class="btn disabled" disabled="disabled" title="${_('You are not allowed to merge this pull request.')}"> <input type="submit" value="${_('Merge and close Pull Request')}" class="btn disabled" disabled="disabled"
title="${_('You are not allowed to merge this pull request.')}">
% else: % else:
<input type="submit" value="${_('Login to Merge this Pull Request')}" class="btn disabled" disabled="disabled"> <input type="submit" value="${_('Login to Merge this Pull Request')}" class="btn disabled" disabled="disabled">
% endif % endif
</div> </div>
</div> </div>
<script> <script>
$('#squash_merge_pull_request').click(function (e) {
e.preventDefault();
$('#squash_merge_pull_request_value').val("true");
$('#merge_pull_request_form').submit();
});
$('#merge_pull_request_form').submit(function () {
if (!$('#merge_pull_request').attr('disabled')) {
$('#merge_pull_request').attr('disabled', 'disabled');
}
if (!$('#pull_request_more_options').hasClass('disabled')) {
$('#pull_request_more_options').addClass('disabled');
}
return true;
});
$("#close_branch_before_merging").on('change', function () { $("#close_branch_before_merging").on('change', function () {
const $checkbox = $(this); const $checkbox = $(this);
updateCloseBranchSetting( updateCloseBranchSetting(

View file

@ -975,6 +975,9 @@ window.setObserversData = ${c.pull_request_set_observers_data_json | n};
if (!$('#merge_pull_request').attr('disabled')) { if (!$('#merge_pull_request').attr('disabled')) {
$('#merge_pull_request').attr('disabled', 'disabled'); $('#merge_pull_request').attr('disabled', 'disabled');
} }
if (!$('#pull_request_more_options').hasClass('disabled')) {
$('#pull_request_more_options').addClass('disabled');
}
return true; return true;
}); });