From 8a8d18c33e5061fbcfd54d0012b377a8668773fa Mon Sep 17 00:00:00 2001 From: Thomas Zerr Date: Tue, 19 Aug 2025 11:48:54 +0200 Subject: [PATCH] Add Fast Forward Only Strategy --- gradle/changelog/fast-forward-only.yaml | 2 + .../api/FastForwardNotPossible.java | 40 +++++++++++++++++ .../scm/repository/api/MergeStrategy.java | 1 + .../spi/FastForwardFallbackStrategy.java | 22 +++++++++ .../spi/GitFastForwardIfPossible.java | 19 ++++++-- .../scm/repository/spi/GitMergeCommand.java | 5 ++- .../scm/repository/spi/GitMergeRebase.java | 2 +- .../repository/spi/GitMergeCommandTest.java | 45 +++++++++++++++++++ .../main/resources/locales/de/plugins.json | 4 ++ .../main/resources/locales/en/plugins.json | 4 ++ 10 files changed, 138 insertions(+), 6 deletions(-) create mode 100644 gradle/changelog/fast-forward-only.yaml create mode 100644 scm-core/src/main/java/sonia/scm/repository/api/FastForwardNotPossible.java create mode 100644 scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/FastForwardFallbackStrategy.java diff --git a/gradle/changelog/fast-forward-only.yaml b/gradle/changelog/fast-forward-only.yaml new file mode 100644 index 0000000000..030a96b9fe --- /dev/null +++ b/gradle/changelog/fast-forward-only.yaml @@ -0,0 +1,2 @@ +- type: added + description: Merge strategy fast forward only, which either fast forwards the commits if possible or throws an error diff --git a/scm-core/src/main/java/sonia/scm/repository/api/FastForwardNotPossible.java b/scm-core/src/main/java/sonia/scm/repository/api/FastForwardNotPossible.java new file mode 100644 index 0000000000..ec8793e354 --- /dev/null +++ b/scm-core/src/main/java/sonia/scm/repository/api/FastForwardNotPossible.java @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2020 - present Cloudogu GmbH + * + * This program is free software: you can redistribute it and/or modify it under + * the terms of the GNU Affero General Public License as published by the Free + * Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS + * FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more + * details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +package sonia.scm.repository.api; + +import sonia.scm.BadRequestException; +import sonia.scm.repository.Repository; + +import static sonia.scm.ContextEntry.ContextBuilder.entity; + +public class FastForwardNotPossible extends BadRequestException { + + private static final String CODE = "Re6hF5g14U"; + + public FastForwardNotPossible(Repository repository, String source, String target) { + super(entity(repository).build(), createMessage(source, target)); + } + + @Override + public String getCode() { + return CODE; + } + + private static String createMessage(String source, String target) { + return String.format("Fast forward not possible with source revision %s and target revision %s", source, target); + } +} diff --git a/scm-core/src/main/java/sonia/scm/repository/api/MergeStrategy.java b/scm-core/src/main/java/sonia/scm/repository/api/MergeStrategy.java index c454ec778a..396caf8006 100644 --- a/scm-core/src/main/java/sonia/scm/repository/api/MergeStrategy.java +++ b/scm-core/src/main/java/sonia/scm/repository/api/MergeStrategy.java @@ -19,6 +19,7 @@ package sonia.scm.repository.api; public enum MergeStrategy { MERGE_COMMIT(true), FAST_FORWARD_IF_POSSIBLE(true), + FAST_FORWARD_ONLY(false), SQUASH(true), REBASE(false); diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/FastForwardFallbackStrategy.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/FastForwardFallbackStrategy.java new file mode 100644 index 0000000000..d19b49b598 --- /dev/null +++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/FastForwardFallbackStrategy.java @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2020 - present Cloudogu GmbH + * + * This program is free software: you can redistribute it and/or modify it under + * the terms of the GNU Affero General Public License as published by the Free + * Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS + * FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more + * details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +package sonia.scm.repository.spi; + +public enum FastForwardFallbackStrategy { + MERGE_COMMIT, + THROW_EXCEPTION +} diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitFastForwardIfPossible.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitFastForwardIfPossible.java index d6ea523239..dc34fee102 100644 --- a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitFastForwardIfPossible.java +++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitFastForwardIfPossible.java @@ -20,6 +20,7 @@ import lombok.extern.slf4j.Slf4j; import org.eclipse.jgit.lib.ObjectId; import sonia.scm.repository.Repository; import sonia.scm.repository.RepositoryManager; +import sonia.scm.repository.api.FastForwardNotPossible; import sonia.scm.repository.api.MergeCommandResult; @Slf4j @@ -30,9 +31,11 @@ class GitFastForwardIfPossible { private final GitMergeCommit fallbackMerge; private final CommitHelper commitHelper; private final Repository repository; + private final FastForwardFallbackStrategy fastForwardStrategy; - GitFastForwardIfPossible(MergeCommandRequest request, GitContext context, RepositoryManager repositoryManager, GitRepositoryHookEventFactory eventFactory) { + GitFastForwardIfPossible(MergeCommandRequest request, GitContext context, RepositoryManager repositoryManager, GitRepositoryHookEventFactory eventFactory, FastForwardFallbackStrategy fastForwardStrategy) { this.request = request; + this.fastForwardStrategy = fastForwardStrategy; this.mergeHelper = new MergeHelper(context, request, repositoryManager, eventFactory); this.fallbackMerge = new GitMergeCommit(request, context, repositoryManager, eventFactory); this.commitHelper = new CommitHelper(context, repositoryManager, eventFactory); @@ -48,9 +51,17 @@ class GitFastForwardIfPossible { log.trace("fast forward branch {} onto {}", request.getBranchToMerge(), request.getTargetBranch()); commitHelper.updateBranch(request.getTargetBranch(), sourceRevision, targetRevision); return MergeCommandResult.success(targetRevision.name(), mergeHelper.getRevisionToMerge().name(), sourceRevision.name()); - } else { - log.trace("fast forward is not possible, fallback to merge"); - return fallbackMerge.run(); } + + return switch (fastForwardStrategy) { + case MERGE_COMMIT -> { + log.trace("fast forward is not possible, fallback to merge"); + yield fallbackMerge.run(); + } + case THROW_EXCEPTION -> { + log.trace("fast forward is not possible, fallback to exception"); + throw new FastForwardNotPossible(repository, request.getBranchToMerge(), request.getTargetBranch()); + } + }; } } diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitMergeCommand.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitMergeCommand.java index 3b68a08004..b38efa65c1 100644 --- a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitMergeCommand.java +++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitMergeCommand.java @@ -63,6 +63,7 @@ public class GitMergeCommand extends AbstractGitCommand implements MergeCommand private static final Set STRATEGIES = Set.of( MergeStrategy.MERGE_COMMIT, MergeStrategy.FAST_FORWARD_IF_POSSIBLE, + MergeStrategy.FAST_FORWARD_ONLY, MergeStrategy.SQUASH, MergeStrategy.REBASE ); @@ -102,7 +103,9 @@ public class GitMergeCommand extends AbstractGitCommand implements MergeCommand return switch (request.getMergeStrategy()) { case SQUASH -> new GitMergeWithSquash(request, context, repositoryManager, eventFactory).run(); case FAST_FORWARD_IF_POSSIBLE -> - new GitFastForwardIfPossible(request, context, repositoryManager, eventFactory).run(); + new GitFastForwardIfPossible(request, context, repositoryManager, eventFactory, FastForwardFallbackStrategy.MERGE_COMMIT).run(); + case FAST_FORWARD_ONLY -> + new GitFastForwardIfPossible(request, context, repositoryManager, eventFactory, FastForwardFallbackStrategy.THROW_EXCEPTION).run(); case MERGE_COMMIT -> new GitMergeCommit(request, context, repositoryManager, eventFactory).run(); case REBASE -> new GitMergeRebase(request, context, repositoryManager, eventFactory).run(); default -> throw new MergeStrategyNotSupportedException(repository, request.getMergeStrategy()); diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitMergeRebase.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitMergeRebase.java index a2a2cd6614..a4afef5025 100644 --- a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitMergeRebase.java +++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitMergeRebase.java @@ -53,7 +53,7 @@ class GitMergeRebase { this.context = context; this.mergeHelper = new MergeHelper(context, request, repositoryManager, eventFactory); this.commitHelper = new CommitHelper(context, repositoryManager, eventFactory); - this.fastForwardMerge = new GitFastForwardIfPossible(request, context, repositoryManager, eventFactory); + this.fastForwardMerge = new GitFastForwardIfPossible(request, context, repositoryManager, eventFactory, FastForwardFallbackStrategy.THROW_EXCEPTION); } MergeCommandResult run() { diff --git a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitMergeCommandTest.java b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitMergeCommandTest.java index f716bad6c8..a4c86491b2 100644 --- a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitMergeCommandTest.java +++ b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitMergeCommandTest.java @@ -45,6 +45,7 @@ import sonia.scm.repository.GitWorkingCopyFactory; import sonia.scm.repository.Person; import sonia.scm.repository.RepositoryHookEvent; import sonia.scm.repository.RepositoryManager; +import sonia.scm.repository.api.FastForwardNotPossible; import sonia.scm.repository.api.MergeCommandResult; import sonia.scm.repository.api.MergeDryRunCommandResult; import sonia.scm.repository.api.MergePreventReason; @@ -445,6 +446,38 @@ public class GitMergeCommandTest extends AbstractGitCommandTestBase { } } + @Test + public void shouldMergeWithFastForwardOnly() throws IOException, GitAPIException { + try (GitContext context = createContext(); Repository repository = context.open()) { + + ObjectId featureBranchHead = new Git(repository).log().add(repository.resolve("squash")).setMaxCount(1).call().iterator().next().getId(); + + GitMergeCommand command = createCommand(); + MergeCommandRequest request = new MergeCommandRequest(); + request.setBranchToMerge("squash"); + request.setTargetBranch("master"); + request.setMergeStrategy(MergeStrategy.FAST_FORWARD_ONLY); + request.setAuthor(new Person("Dirk Gently", "dirk@holistic.det")); + + MergeCommandResult mergeCommandResult = command.merge(request); + assertThat(mergeCommandResult.getNewHeadRevision()).isEqualTo("35597e9e98fe53167266583848bfef985c2adb27"); + assertThat(mergeCommandResult.getRevisionToMerge()).isEqualTo("35597e9e98fe53167266583848bfef985c2adb27"); + assertThat(mergeCommandResult.getTargetRevision()).isEqualTo("fcd0ef1831e4002ac43ea539f4094334c79ea9ec"); + + assertThat(mergeCommandResult.isSuccess()).isTrue(); + + Iterable commits = new Git(repository).log().add(repository.resolve("master")).setMaxCount(1).call(); + RevCommit mergeCommit = commits.iterator().next(); + assertThat(mergeCommit.getParentCount()).isEqualTo(1); + PersonIdent mergeAuthor = mergeCommit.getAuthorIdent(); + PersonIdent mergeCommitter = mergeCommit.getCommitterIdent(); + + assertThat(mergeAuthor.getName()).isEqualTo("Philip J Fry"); + assertThat(mergeCommitter.getName()).isEqualTo("Eduard Heimbuch"); + assertThat(mergeCommit.getId()).isEqualTo(featureBranchHead); + } + } + @Test public void shouldDoMergeCommitIfFastForwardIsNotPossible() throws IOException, GitAPIException { GitMergeCommand command = createCommand(); @@ -470,6 +503,18 @@ public class GitMergeCommandTest extends AbstractGitCommandTestBase { } } + @Test(expected = FastForwardNotPossible.class) + public void shouldThrowErrorBecauseFastForwardNotPossible() throws IOException, GitAPIException { + GitMergeCommand command = createCommand(); + MergeCommandRequest request = new MergeCommandRequest(); + request.setTargetBranch("master"); + request.setBranchToMerge("mergeable"); + request.setMergeStrategy(MergeStrategy.FAST_FORWARD_ONLY); + request.setAuthor(new Person("Dirk Gently", "dirk@holistic.det")); + + MergeCommandResult mergeCommandResult = command.merge(request); + } + @Test(expected = NotFoundException.class) public void shouldHandleNotExistingSourceBranchInMerge() { GitMergeCommand command = createCommand(); diff --git a/scm-webapp/src/main/resources/locales/de/plugins.json b/scm-webapp/src/main/resources/locales/de/plugins.json index 39fe234bdc..d963f7757f 100644 --- a/scm-webapp/src/main/resources/locales/de/plugins.json +++ b/scm-webapp/src/main/resources/locales/de/plugins.json @@ -289,6 +289,10 @@ "displayName": "Nicht unterstützte Merge-Strategie", "description": "Die gewählte Merge-Strategie wird von dem Repository nicht unterstützt." }, + "Re6hF5g14U": { + "displayName": "Fast-Forward fehlgeschlagen", + "description": "Die beiden Branches können nicht per Fast-Forward miteinander gemerged werden" + }, "78RhWxTIw1": { "displayName": "Der Default-Branch kann nicht gelöscht werden", "description": "Der Default-Branch kann nicht gelöscht werden. Bitte wählen Sie zuerst einen neuen Default-Branch." diff --git a/scm-webapp/src/main/resources/locales/en/plugins.json b/scm-webapp/src/main/resources/locales/en/plugins.json index aeee6fa6ab..ae2856c823 100644 --- a/scm-webapp/src/main/resources/locales/en/plugins.json +++ b/scm-webapp/src/main/resources/locales/en/plugins.json @@ -289,6 +289,10 @@ "displayName": "Merge strategy not supported", "description": "The selected merge strategy is not supported by the repository." }, + "Re6hF5g14U": { + "displayName": "Fast forward failed", + "description": "The branches cannot be merged via fast forward" + }, "78RhWxTIw1": { "displayName": "Default branch cannot be deleted", "description": "The default branch of a repository cannot be deleted. Please select another default branch first."