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:
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(
self.request.environ,
repo_name=pull_request.target_repo.repo_name,
@ -1506,7 +1510,9 @@ class RepoPullRequestsView(RepoAppView, DataGridAppView):
scm=pull_request.target_repo.repo_type,
)
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:
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
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:
log.debug("The merge was successful, closing the pull request.")

View file

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

View file

@ -24,6 +24,7 @@ import logging
import os
import re
from celery.platforms import strargv
from zope.cachedescriptors.property import Lazy as LazyProperty
from collections import OrderedDict
@ -842,7 +843,7 @@ class GitRepository(BaseRepository):
cmd.extend(["--no-tags", repository_path, branch_name])
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.
@ -853,6 +854,7 @@ class GitRepository(BaseRepository):
:param merge_message: The message to use for the merge commit.
:param heads: the heads to merge.
:param squash_commits: Use squash strategy to merge.
"""
if self.bare:
raise RepositoryError("Cannot merge into a bare git repository")
@ -863,40 +865,68 @@ class GitRepository(BaseRepository):
if self.is_empty():
# TODO(skreft): do something more robust in this case.
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
# commit message. We also specify the user who is doing the merge.
cmd = [
base_cmd = [
"-c",
f'user.name="{user_name}"',
"-c",
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:
# 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)
except RepositoryError:
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]
except RepositoryError as e:
log.warning("Merge failed with error: %s", e)
self._abort_merge()
# Cleanup any merge leftovers
self._remote.invalidate_vcs_cache()
self.run_git_command(["merge", "--abort"], fail_on_stderr=False)
def _squash_merge(self, base_cmd, heads, merge_message):
try:
squash_cmd = (
base_cmd
+ [
"merge",
"--squash",
]
+ heads
)
if unresolved:
raise UnresolvedFilesInRepo(unresolved)
else:
raise
log.debug("Squash command: %s", squash_cmd)
self.run_git_command(squash_cmd, fail_on_stderr=False)
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):
"""
@ -961,10 +991,16 @@ class GitRepository(BaseRepository):
dry_run=False,
use_rebase=False,
close_branch=False,
squash_commits=False,
):
log.debug(
"Executing merge_repo with %s strategy, dry_run mode:%s", "rebase" if use_rebase else "merge", dry_run
)
if squash_commits:
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
if target_ref.commit_id != self.branches[target_ref.name]:
@ -1026,7 +1062,9 @@ class GitRepository(BaseRepository):
merge_failure_reason = MergeFailureReason.NONE
metadata = {}
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
# Need to invalidate the cache, or otherwise we

View file

@ -680,6 +680,7 @@ class MercurialRepository(BaseRepository):
use_rebase=False,
close_commit_id=None,
dry_run=False,
squash_commits=False,
):
"""
Merge the given source_revision into the checked out revision.
@ -711,11 +712,11 @@ class MercurialRepository(BaseRepository):
return source_ref_commit_id, True
unresolved = None
if use_rebase:
if use_rebase or squash_commits:
try:
bookmark_name = f"rcbook{source_ref_commit_id}{target_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._update(bookmark_name, clean=True)
return self._identify(), True
@ -809,10 +810,16 @@ class MercurialRepository(BaseRepository):
dry_run=False,
use_rebase=False,
close_branch=False,
squash_commits=False,
):
log.debug(
"Executing merge_repo with %s strategy, dry_run mode:%s", "rebase" if use_rebase else "merge", dry_run
)
if squash_commits:
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
if target_ref.commit_id not in self._heads():
@ -899,6 +906,7 @@ class MercurialRepository(BaseRepository):
use_rebase=use_rebase,
close_commit_id=close_commit_id,
dry_run=dry_run,
squash_commits=squash_commits,
)
merge_possible = True

View file

@ -1011,12 +1011,12 @@ class PullRequestModel(BaseModel):
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
log.debug("Merging pull request %s", pull_request)
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:
log.debug("Merge was successful, updating the pull request comments.")
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.")
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()
source_vcs = pull_request.source_repo.scm_instance()
@ -1067,6 +1067,7 @@ class PullRequestModel(BaseModel):
message=message,
use_rebase=use_rebase,
close_branch=close_branch,
squash_commits=squash_commits,
)
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"] {
&:extend(.btn-default);

View file

@ -1,70 +1,100 @@
<div class="pull-request-wrap">
% if c.pr_merge_possible:
<h2 class="merge-status">
<span class="merge-icon success"><i class="icon-ok"></i></span>
${_('This pull request can be merged automatically.')}
</h2>
<h2 class="merge-status">
<span class="merge-icon success"><i class="icon-ok"></i></span>
${_('This pull request can be merged automatically.')}
</h2>
% else:
<h2 class="merge-status">
<span class="merge-icon warning"><i class="icon-false"></i></span>
${_('Merge is not currently possible because of below failed checks.')}
</h2>
<h2 class="merge-status">
<span class="merge-icon warning"><i class="icon-false"></i></span>
${_('Merge is not currently possible because of below failed checks.')}
</h2>
% endif
% if c.pr_merge_errors.items():
<ul>
% for pr_check_key, pr_check_details in c.pr_merge_errors.items():
<% pr_check_type = pr_check_details['error_type'] %>
<li>
<div class="merge-message ${pr_check_type}" data-role="merge-message">
<span style="white-space: pre-line">- ${pr_check_details['message']}</span>
% if pr_check_key == 'todo':
% 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 ','}
% endfor
% endif
</div>
</li>
<% pr_check_type = pr_check_details['error_type'] %>
<li>
<div class="merge-message ${pr_check_type}" data-role="merge-message">
<span style="white-space: pre-line">- ${pr_check_details['message']}</span>
% if pr_check_key == 'todo':
% 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 ','}
% endfor
% endif
</div>
</li>
% endfor
</ul>
% endif
<div class="pull-request-merge-actions">
% if c.allowed_to_merge:
## Merge info, show only if all errors are taken care of
% if not c.pr_merge_errors and c.pr_merge_info:
<div class="pull-request-merge-info">
<ul>
% for pr_merge_key, pr_merge_details in c.pr_merge_info.items():
<li>
- ${pr_merge_details['message']}
</li>
% endfor
</ul>
</div>
% endif
## Merge info, show only if all errors are taken care of
% if not c.pr_merge_errors and c.pr_merge_info:
<div class="pull-request-merge-info">
<ul>
% for pr_merge_key, pr_merge_details in c.pr_merge_info.items():
<li>
- ${pr_merge_details['message']}
</li>
% endfor
</ul>
</div>
% endif
<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)}
<% merge_disabled = ' disabled' if c.pr_merge_possible is False else '' %>
${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,
style='display: flex; justify-content: flex-end;')}
<% merge_disabled = ' disabled' if c.pr_merge_possible is False else '' %>
% if c.allowed_to_close:
## close PR action, injected later next to COMMENT button
% 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;">
${_('Close with status {}').format(h.commit_status_lbl(c.REVIEW_STATUS_APPROVED))}
</a>
% else:
<a id="close-pull-request-action" class="btn btn-rejected-status" href="#close-as-rejected" onclick="closePullRequest('${c.REVIEW_STATUS_REJECTED}'); return false;">
${_('Close with status {}').format(h.commit_status_lbl(c.REVIEW_STATUS_REJECTED))}
</a>
% if c.allowed_to_close:
## close PR action, injected later next to COMMENT button
% 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;">
${_('Close with status {}').format(h.commit_status_lbl(c.REVIEW_STATUS_APPROVED))}
</a>
% else:
<a id="close-pull-request-action" class="btn btn-rejected-status" href="#close-as-rejected"
onclick="closePullRequest('${c.REVIEW_STATUS_REJECTED}'); return false;">
${_('Close with status {}').format(h.commit_status_lbl(c.REVIEW_STATUS_REJECTED))}
</a>
% 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}>
${h.end_form()}
<a class="btn btn-primary btn-more-option${merge_disabled}" data-toggle="dropdown"
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':
<div class="pull-request-settings"
@ -87,16 +117,36 @@
</div>
% elif c.rhodecode_user.username != h.DEFAULT_USER:
<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.')}">
<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.')}">
% 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
</div>
</div>
<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 () {
const $checkbox = $(this);
updateCloseBranchSetting(

View file

@ -975,6 +975,9 @@ window.setObserversData = ${c.pull_request_set_observers_data_json | n};
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;
});