From 8422c3bc44e99facee726c5729aa4ca61978052c Mon Sep 17 00:00:00 2001 From: Rene Pfeuffer Date: Tue, 7 Jan 2025 11:06:53 +0100 Subject: [PATCH] Fast modifications inside git repositories MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With this change, most modifications of git repositories (like inserting, deleting and updating files and merging branches) do no longer work inside clones held in temporary working directories but are done directly inside the bare git repository data. This resolves in a massive performance boost for the editor plugin and pull requests, especially in larger repositories. Co-authored-by: René Pfeuffer Committed-by: René Pfeuffer --- gradle/changelog/fast_git_modifications.yaml | 2 + gradle/changelog/jgit7.yaml | 2 + scm-plugins/scm-git-plugin/build.gradle | 2 +- .../sonia/scm/repository/GitHeadModifier.java | 9 +- .../java/sonia/scm/repository/GitUtil.java | 11 +- .../sonia/scm/repository/ScmGpgSigner.java | 22 +- .../repository/ScmGpgSignerInitializer.java | 5 +- .../repository/spi/AbstractGitCommand.java | 2 +- .../scm/repository/spi/CommitHelper.java | 164 ++++++ .../scm/repository/spi/GitBranchCommand.java | 11 +- .../repository/spi/GitBranchesCommand.java | 4 +- .../sonia/scm/repository/spi/GitContext.java | 9 +- .../spi/GitFastForwardIfPossible.java | 49 +- .../spi/GitImportHookContextProvider.java | 9 +- .../spi/GitLazyChangesetResolver.java | 6 +- .../scm/repository/spi/GitLogCommand.java | 51 +- .../scm/repository/spi/GitMergeCommand.java | 44 +- .../scm/repository/spi/GitMergeCommit.java | 38 +- .../scm/repository/spi/GitMergeRebase.java | 131 +++-- .../scm/repository/spi/GitMergeStrategy.java | 123 ---- .../repository/spi/GitMergeWithSquash.java | 35 +- .../scm/repository/spi/GitModifyCommand.java | 533 +++++++++++++----- .../spi/GitRepositoryHookEventFactory.java | 19 +- .../scm/repository/spi/GitTagCommand.java | 7 +- .../repository/spi/GitUnbundleCommand.java | 4 +- .../sonia/scm/repository/spi/MergeHelper.java | 171 ++++++ ...PostReceiveRepositoryHookEventFactory.java | 19 +- .../repository/GitChangesetConverterTest.java | 33 +- .../sonia/scm/repository/GitTestHelper.java | 15 +- .../scm/repository/ScmGpgSignerTest.java | 11 +- .../spi/AbstractGitCommandTestBase.java | 4 +- .../spi/GitLazyChangesetResolverTest.java | 6 +- .../spi/GitMergeCommandConflictTest.java | 2 +- .../repository/spi/GitMergeCommandTest.java | 106 +++- .../repository/spi/GitModifyCommandTest.java | 79 ++- .../spi/GitModifyCommandTestBase.java | 35 +- .../spi/GitModifyCommand_LFSTest.java | 30 + ...ReceiveRepositoryHookEventFactoryTest.java | 2 +- .../scm/repository/spi/GitTagCommandTest.java | 5 +- .../spi/GitUnbundleCommandTest.java | 2 +- .../repository/spi/scm-git-spi-lfs-test.zip | Bin 14315 -> 16189 bytes .../repository/spi/scm-git-spi-move-test.zip | Bin 49768 -> 55776 bytes 42 files changed, 1201 insertions(+), 611 deletions(-) create mode 100644 gradle/changelog/fast_git_modifications.yaml create mode 100644 gradle/changelog/jgit7.yaml create mode 100644 scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/CommitHelper.java delete mode 100644 scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitMergeStrategy.java create mode 100644 scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/MergeHelper.java diff --git a/gradle/changelog/fast_git_modifications.yaml b/gradle/changelog/fast_git_modifications.yaml new file mode 100644 index 0000000000..81e9d48e24 --- /dev/null +++ b/gradle/changelog/fast_git_modifications.yaml @@ -0,0 +1,2 @@ +- type: added + description: Performance improvements for git modifications diff --git a/gradle/changelog/jgit7.yaml b/gradle/changelog/jgit7.yaml new file mode 100644 index 0000000000..114ac1a9b5 --- /dev/null +++ b/gradle/changelog/jgit7.yaml @@ -0,0 +1,2 @@ +- type: changed + description: Upgrade JGit to 7.1.0.202411261347-r diff --git a/scm-plugins/scm-git-plugin/build.gradle b/scm-plugins/scm-git-plugin/build.gradle index f79349dca2..fc66dce344 100644 --- a/scm-plugins/scm-git-plugin/build.gradle +++ b/scm-plugins/scm-git-plugin/build.gradle @@ -18,7 +18,7 @@ plugins { id 'org.scm-manager.smp' version '0.17.0' } -def jgitVersion = '6.7.0.202309050840-r-scm1-jakarta' +def jgitVersion = '7.1.0.202411261347-r-scm1' dependencies { // required by scm-it diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/GitHeadModifier.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/GitHeadModifier.java index 0eb4620739..34f32d01d8 100644 --- a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/GitHeadModifier.java +++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/GitHeadModifier.java @@ -48,11 +48,10 @@ public class GitHeadModifier { * repositories head points already to the given branch. * * @param repository repository to modify - * @param newHead branch which should be the new head of the repository - * + * @param newHead branch which should be the new head of the repository * @return {@code true} if the head has changed */ - public boolean ensure(Repository repository, String newHead) { + public boolean ensure(Repository repository, String newHead) { try (org.eclipse.jgit.lib.Repository gitRepository = open(repository)) { String currentHead = resolve(gitRepository); if (!Objects.equals(currentHead, newHead)) { @@ -65,8 +64,8 @@ public class GitHeadModifier { } private String resolve(org.eclipse.jgit.lib.Repository gitRepository) throws IOException { - Ref ref = gitRepository.getRefDatabase().getRef(Constants.HEAD); - if ( ref.isSymbolic() ) { + Ref ref = gitRepository.getRefDatabase().findRef(Constants.HEAD); + if (ref.isSymbolic()) { ref = ref.getTarget(); } return GitUtil.getBranch(ref); diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/GitUtil.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/GitUtil.java index f9c8b6f139..f2087ac5fe 100644 --- a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/GitUtil.java +++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/GitUtil.java @@ -239,9 +239,7 @@ public final class GitUtil { String branchName) throws IOException { Ref ref = null; - if (!branchName.startsWith(REF_HEAD)) { - branchName = PREFIX_HEADS.concat(branchName); - } + branchName = getRevString(branchName); checkBranchName(repo, branchName); @@ -258,6 +256,13 @@ public final class GitUtil { return ref; } + public static String getRevString(String branchName) { + if (!branchName.startsWith(REF_HEAD)) { + return PREFIX_HEADS.concat(branchName); + } + return branchName; + } + /** * @since 2.5.0 */ diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/ScmGpgSigner.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/ScmGpgSigner.java index c3572f99a0..fda764f799 100644 --- a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/ScmGpgSigner.java +++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/ScmGpgSigner.java @@ -18,16 +18,18 @@ package sonia.scm.repository; import jakarta.inject.Inject; import org.eclipse.jgit.api.errors.CanceledException; -import org.eclipse.jgit.lib.CommitBuilder; +import org.eclipse.jgit.api.errors.UnsupportedSigningFormatException; +import org.eclipse.jgit.lib.GpgConfig; import org.eclipse.jgit.lib.GpgSignature; -import org.eclipse.jgit.lib.GpgSigner; import org.eclipse.jgit.lib.PersonIdent; +import org.eclipse.jgit.lib.Repository; +import org.eclipse.jgit.lib.Signer; import org.eclipse.jgit.transport.CredentialsProvider; import sonia.scm.security.GPG; -import java.io.UnsupportedEncodingException; +import java.io.IOException; -public class ScmGpgSigner extends GpgSigner { +public class ScmGpgSigner implements Signer { private final GPG gpg; @@ -37,17 +39,13 @@ public class ScmGpgSigner extends GpgSigner { } @Override - public void sign(CommitBuilder commitBuilder, String keyId, PersonIdent personIdent, CredentialsProvider credentialsProvider) throws CanceledException { - try { - final byte[] signature = this.gpg.getPrivateKey().sign(commitBuilder.build()); - commitBuilder.setGpgSignature(new GpgSignature(signature)); - } catch (UnsupportedEncodingException e) { - throw new IllegalStateException(e); - } + public GpgSignature sign(Repository repository, GpgConfig gpgConfig, byte[] bytes, PersonIdent personIdent, String s, CredentialsProvider credentialsProvider) throws CanceledException, IOException, UnsupportedSigningFormatException { + final byte[] signature = this.gpg.getPrivateKey().sign(bytes); + return new GpgSignature(signature); } @Override - public boolean canLocateSigningKey(String keyId, PersonIdent personIdent, CredentialsProvider credentialsProvider) throws CanceledException { + public boolean canLocateSigningKey(Repository repository, GpgConfig gpgConfig, PersonIdent personIdent, String s, CredentialsProvider credentialsProvider) throws CanceledException { return true; } } diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/ScmGpgSignerInitializer.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/ScmGpgSignerInitializer.java index 901b4fee76..3e485436f7 100644 --- a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/ScmGpgSignerInitializer.java +++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/ScmGpgSignerInitializer.java @@ -19,7 +19,8 @@ package sonia.scm.repository; import jakarta.inject.Inject; import jakarta.servlet.ServletContextEvent; import jakarta.servlet.ServletContextListener; -import org.eclipse.jgit.lib.GpgSigner; +import org.eclipse.jgit.lib.GpgConfig; +import org.eclipse.jgit.lib.Signers; import sonia.scm.plugin.Extension; @Extension @@ -34,7 +35,7 @@ public class ScmGpgSignerInitializer implements ServletContextListener { @Override public void contextInitialized(ServletContextEvent servletContextEvent) { - GpgSigner.setDefault(scmGpgSigner); + Signers.set(GpgConfig.GpgFormat.OPENPGP, scmGpgSigner); } @Override diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/AbstractGitCommand.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/AbstractGitCommand.java index 02d8d24e25..2f6d68a794 100644 --- a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/AbstractGitCommand.java +++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/AbstractGitCommand.java @@ -78,7 +78,7 @@ class AbstractGitCommand { this.context = context; } - Repository open() throws IOException { + Repository open() { return context.open(); } diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/CommitHelper.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/CommitHelper.java new file mode 100644 index 0000000000..68139509be --- /dev/null +++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/CommitHelper.java @@ -0,0 +1,164 @@ +/* + * 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; + +import lombok.extern.slf4j.Slf4j; +import org.apache.shiro.SecurityUtils; +import org.eclipse.jgit.api.errors.CanceledException; +import org.eclipse.jgit.api.errors.UnsupportedSigningFormatException; +import org.eclipse.jgit.lib.CommitBuilder; +import org.eclipse.jgit.lib.GpgConfig; +import org.eclipse.jgit.lib.ObjectId; +import org.eclipse.jgit.lib.ObjectInserter; +import org.eclipse.jgit.lib.PersonIdent; +import org.eclipse.jgit.lib.RefUpdate; +import org.eclipse.jgit.lib.Repository; +import org.eclipse.jgit.lib.Signer; +import org.eclipse.jgit.lib.Signers; +import org.eclipse.jgit.revwalk.RevCommit; +import org.eclipse.jgit.revwalk.RevWalk; +import org.eclipse.jgit.transport.CredentialsProvider; +import sonia.scm.ConcurrentModificationException; +import sonia.scm.repository.GitUtil; +import sonia.scm.repository.InternalRepositoryException; +import sonia.scm.repository.Person; +import sonia.scm.repository.RepositoryManager; +import sonia.scm.user.User; + +import java.io.IOException; +import java.util.List; + +import static java.util.Collections.emptyList; +import static sonia.scm.ContextEntry.ContextBuilder.entity; + +@Slf4j +class CommitHelper { + + private final Repository repository; + private final GitContext context; + private final RepositoryManager repositoryManager; + private final GitRepositoryHookEventFactory eventFactory; + + CommitHelper(GitContext context, RepositoryManager repositoryManager, GitRepositoryHookEventFactory eventFactory) { + this.repository = context.open(); + this.context = context; + this.repositoryManager = repositoryManager; + this.eventFactory = eventFactory; + } + + ObjectId createCommit(ObjectId treeId, + Person author, + Person committer, + String message, + boolean sign, + ObjectId... parentCommitIds) throws IOException, CanceledException, UnsupportedSigningFormatException { + log.trace("create commit for tree {} and parent ids {} in repository {}", treeId, parentCommitIds, context.getRepository()); + try (ObjectInserter inserter = repository.newObjectInserter()) { + CommitBuilder commitBuilder = new CommitBuilder(); + commitBuilder.setTreeId(treeId); + commitBuilder.setParentIds(parentCommitIds); + commitBuilder.setAuthor(createPersonIdent(author)); + commitBuilder.setCommitter(createPersonIdent(committer)); + commitBuilder.setMessage(message); + if (sign) { + sign(commitBuilder, createPersonIdent(committer)); + } + ObjectId commitId = inserter.insert(commitBuilder); + inserter.flush(); + log.trace("created commit with id {}", commitId); + return commitId; + } + } + + private PersonIdent createPersonIdent(Person person) { + if (person == null) { + User currentUser = SecurityUtils.getSubject().getPrincipals().oneByType(User.class); + return new PersonIdent(currentUser.getDisplayName(), currentUser.getMail()); + } + return new PersonIdent(person.getName(), person.getMail()); + } + + private void sign(CommitBuilder commit, PersonIdent committer) + throws CanceledException, IOException, UnsupportedSigningFormatException { + log.trace("sign commit"); + GpgConfig gpgConfig = new GpgConfig(repository.getConfig()); + Signer signer = Signers.get(gpgConfig.getKeyFormat()); + signer.signObject(repository, gpgConfig, commit, committer, "SCM-MANAGER-DEFAULT-KEY", CredentialsProvider.getDefault()); + } + + void updateBranch(String branchName, ObjectId newCommitId, ObjectId expectedOldObjectId) { + log.trace("update branch {} with new commit id {} in repository {}", branchName, newCommitId, context.getRepository()); + try { + RevCommit newCommit = findNewCommit(newCommitId); + firePreCommitHook(branchName, newCommit); + doUpdate(branchName, newCommitId, expectedOldObjectId); + firePostCommitHook(branchName, newCommit); + } catch (IOException e) { + throw new InternalRepositoryException(context.getRepository(), "could not update branch " + branchName, e); + } + } + + private RevCommit findNewCommit(ObjectId newCommitId) throws IOException { + RevCommit newCommit; + try (RevWalk revWalk = new RevWalk(repository)) { + newCommit = revWalk.parseCommit(newCommitId); + } + return newCommit; + } + + private void firePreCommitHook(String branchName, RevCommit newCommit) { + repositoryManager.fireHookEvent( + eventFactory.createPreReceiveEvent( + context, + List.of(branchName), + emptyList(), + () -> List.of(newCommit) + ) + ); + } + + private void doUpdate(String branchName, ObjectId newCommitId, ObjectId expectedOldObjectId) throws IOException { + RefUpdate refUpdate = repository.updateRef(GitUtil.getRevString(branchName)); + if (newCommitId == null) { + refUpdate.setExpectedOldObjectId(ObjectId.zeroId()); + } else { + refUpdate.setExpectedOldObjectId(expectedOldObjectId); + } + refUpdate.setNewObjectId(newCommitId); + refUpdate.setForceUpdate(false); + RefUpdate.Result result = refUpdate.update(); + + if (isSuccessfulUpdate(expectedOldObjectId, result)) { + throw new ConcurrentModificationException(entity("branch", branchName).in(context.getRepository()).build()); + } + } + + private void firePostCommitHook(String branchName, RevCommit newCommit) { + repositoryManager.fireHookEvent( + eventFactory.createPostReceiveEvent( + context, + List.of(branchName), + emptyList(), + () -> List.of(newCommit) + ) + ); + } + + private boolean isSuccessfulUpdate(ObjectId expectedOldObjectId, RefUpdate.Result result) { + return result != RefUpdate.Result.FAST_FORWARD && !(expectedOldObjectId == null && result == RefUpdate.Result.NEW); + } +} diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitBranchCommand.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitBranchCommand.java index 710eb35bf4..7c84fe521a 100644 --- a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitBranchCommand.java +++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitBranchCommand.java @@ -97,7 +97,7 @@ public class GitBranchCommand extends AbstractGitCommand implements BranchComman log.debug("got exception for invalid branch name {}", request.getNewBranch(), e); doThrow().violation("Invalid branch name", "name").when(true); return null; - } catch (GitAPIException | IOException ex) { + } catch (GitAPIException ex) { throw new InternalRepositoryException(repository, "could not create branch " + request.getNewBranch(), ex); } } @@ -116,7 +116,7 @@ public class GitBranchCommand extends AbstractGitCommand implements BranchComman eventBus.post(new PostReceiveRepositoryHookEvent(hookEvent)); } catch (CannotDeleteCurrentBranchException e) { throw new CannotDeleteDefaultBranchException(context.getRepository(), branchName); - } catch (GitAPIException | IOException ex) { + } catch (GitAPIException ex) { throw new InternalRepositoryException(entity(context.getRepository()), String.format("Could not delete branch: %s", branchName)); } } @@ -161,12 +161,7 @@ public class GitBranchCommand extends AbstractGitCommand implements BranchComman @Override public HookChangesetProvider getChangesetProvider() { - Repository gitRepo; - try { - gitRepo = context.open(); - } catch (IOException e) { - throw new InternalRepositoryException(repository, "failed to open repository for post receive hook after internal change", e); - } + Repository gitRepo = context.open(); Collection receiveCommands = asList(createReceiveCommand()); return x -> { diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitBranchesCommand.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitBranchesCommand.java index 0679741e29..11cc1a032f 100644 --- a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitBranchesCommand.java +++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitBranchesCommand.java @@ -53,7 +53,7 @@ public class GitBranchesCommand extends AbstractGitCommand implements BranchesCo } @Override - public List getBranches() throws IOException { + public List getBranches() { Git git = createGit(); String defaultBranchName = determineDefaultBranchName(git); @@ -72,7 +72,7 @@ public class GitBranchesCommand extends AbstractGitCommand implements BranchesCo } @VisibleForTesting - Git createGit() throws IOException { + Git createGit() { return new Git(open()); } diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitContext.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitContext.java index d123206231..04e29ed1ea 100644 --- a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitContext.java +++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitContext.java @@ -23,6 +23,7 @@ import sonia.scm.api.v2.resources.GitRepositoryConfigStoreProvider; import sonia.scm.repository.GitConfig; import sonia.scm.repository.GitRepositoryConfig; import sonia.scm.repository.GitUtil; +import sonia.scm.repository.InternalRepositoryException; import sonia.scm.repository.Repository; import sonia.scm.repository.RepositoryProvider; @@ -62,13 +63,17 @@ public class GitContext implements Closeable, RepositoryProvider } - public org.eclipse.jgit.lib.Repository open() throws IOException + public org.eclipse.jgit.lib.Repository open() { if (gitRepository == null) { logger.trace("open git repository {}", directory); - gitRepository = GitUtil.open(directory); + try { + gitRepository = GitUtil.open(directory); + } catch (IOException e) { + throw new InternalRepositoryException(repository, "could not open git repository", e); + } } return gitRepository; 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 ba2396b01c..d6ea523239 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 @@ -16,38 +16,41 @@ package sonia.scm.repository.spi; -import org.eclipse.jgit.api.Git; -import org.eclipse.jgit.api.MergeCommand; -import org.eclipse.jgit.api.MergeResult; +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.MergeCommandResult; -import java.io.IOException; -import java.util.Collections; +@Slf4j +class GitFastForwardIfPossible { -class GitFastForwardIfPossible extends GitMergeStrategy { + private final MergeCommandRequest request; + private final MergeHelper mergeHelper; + private final GitMergeCommit fallbackMerge; + private final CommitHelper commitHelper; + private final Repository repository; - private GitMergeStrategy fallbackMerge; - - GitFastForwardIfPossible(Git clone, MergeCommandRequest request, GitContext context, Repository repository) { - super(clone, request, context, repository); - fallbackMerge = new GitMergeCommit(clone, request, context, repository); + GitFastForwardIfPossible(MergeCommandRequest request, GitContext context, RepositoryManager repositoryManager, GitRepositoryHookEventFactory eventFactory) { + this.request = request; + this.mergeHelper = new MergeHelper(context, request, repositoryManager, eventFactory); + this.fallbackMerge = new GitMergeCommit(request, context, repositoryManager, eventFactory); + this.commitHelper = new CommitHelper(context, repositoryManager, eventFactory); + this.repository = context.getRepository(); } - @Override - MergeCommandResult run() throws IOException { - MergeResult fastForwardResult = mergeWithFastForwardOnlyMode(); - if (fastForwardResult.getMergeStatus().isSuccessful()) { - push(); - return createSuccessResult(fastForwardResult.getNewHead().name()); + MergeCommandResult run() { + log.trace("try to fast forward branch {} onto {} in repository {}", request.getBranchToMerge(), request.getTargetBranch(), repository); + ObjectId sourceRevision = mergeHelper.getRevisionToMerge(); + ObjectId targetRevision = mergeHelper.getTargetRevision(); + + if (mergeHelper.isMergedInto(targetRevision, sourceRevision)) { + 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(); } } - - private MergeResult mergeWithFastForwardOnlyMode() throws IOException { - MergeCommand mergeCommand = getClone().merge(); - mergeCommand.setFastForward(MergeCommand.FastForwardMode.FF_ONLY); - return doMergeInClone(mergeCommand); - } } diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitImportHookContextProvider.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitImportHookContextProvider.java index 34c5e1bf22..723a05db66 100644 --- a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitImportHookContextProvider.java +++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitImportHookContextProvider.java @@ -17,6 +17,7 @@ package sonia.scm.repository.spi; import com.google.common.collect.ImmutableSet; +import org.eclipse.jgit.revwalk.RevCommit; import sonia.scm.repository.GitChangesetConverter; import sonia.scm.repository.Tag; import sonia.scm.repository.api.HookBranchProvider; @@ -27,17 +28,19 @@ import sonia.scm.repository.api.HookTagProvider; import java.util.Collections; import java.util.List; import java.util.Set; +import java.util.concurrent.Callable; +import java.util.function.Supplier; class GitImportHookContextProvider extends HookContextProvider { private final GitChangesetConverter converter; private final List newTags; - private final GitLazyChangesetResolver changesetResolver; + private final Supplier> changesetResolver; private final List newBranches; GitImportHookContextProvider(GitChangesetConverter converter, List newBranches, List newTags, - GitLazyChangesetResolver changesetResolver) { + Supplier> changesetResolver) { this.converter = converter; this.newTags = newTags; this.changesetResolver = changesetResolver; @@ -81,7 +84,7 @@ class GitImportHookContextProvider extends HookContextProvider { @Override public HookChangesetProvider getChangesetProvider() { - GitConvertingChangesetIterable changesets = new GitConvertingChangesetIterable(changesetResolver.call(), converter); + GitConvertingChangesetIterable changesets = new GitConvertingChangesetIterable(changesetResolver.get(), converter); return r -> new HookChangesetResponse(changesets); } } diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitLazyChangesetResolver.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitLazyChangesetResolver.java index 7a911682c9..942c696b3c 100644 --- a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitLazyChangesetResolver.java +++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitLazyChangesetResolver.java @@ -23,11 +23,11 @@ import sonia.scm.repository.InternalRepositoryException; import sonia.scm.repository.Repository; import java.io.IOException; -import java.util.concurrent.Callable; +import java.util.function.Supplier; import static sonia.scm.ContextEntry.ContextBuilder.entity; -class GitLazyChangesetResolver implements Callable> { +class GitLazyChangesetResolver implements Supplier> { private final Repository repository; private final Git git; @@ -37,7 +37,7 @@ class GitLazyChangesetResolver implements Callable> { } @Override - public Iterable call() { + public Iterable get() { try { return git.log().all().call(); } catch (IOException | GitAPIException e) { diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitLogCommand.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitLogCommand.java index cd98f05450..169264a085 100644 --- a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitLogCommand.java +++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitLogCommand.java @@ -31,7 +31,6 @@ import sonia.scm.repository.ChangesetPagingResult; import sonia.scm.repository.GitChangesetConverter; import sonia.scm.repository.GitChangesetConverterFactory; import sonia.scm.repository.GitUtil; -import sonia.scm.repository.InternalRepositoryException; import sonia.scm.util.IOUtil; import java.io.IOException; @@ -40,10 +39,9 @@ import static sonia.scm.ContextEntry.ContextBuilder.entity; import static sonia.scm.NotFoundException.notFound; -public class GitLogCommand extends AbstractGitCommand implements LogCommand -{ +public class GitLogCommand extends AbstractGitCommand implements LogCommand { + - private static final Logger logger = LoggerFactory.getLogger(GitLogCommand.class); public static final String REVISION = "Revision"; @@ -51,20 +49,16 @@ public class GitLogCommand extends AbstractGitCommand implements LogCommand @Inject - GitLogCommand(@Assisted GitContext context, GitChangesetConverterFactory converterFactory) - { + GitLogCommand(@Assisted GitContext context, GitChangesetConverterFactory converterFactory) { super(context); this.converterFactory = converterFactory; } - @Override @SuppressWarnings("java:S2093") - public Changeset getChangeset(String revision, LogCommandRequest request) - { - if (logger.isDebugEnabled()) - { + public Changeset getChangeset(String revision, LogCommandRequest request) { + if (logger.isDebugEnabled()) { logger.debug("fetch changeset {}", revision); } @@ -73,18 +67,15 @@ public class GitLogCommand extends AbstractGitCommand implements LogCommand GitChangesetConverter converter = null; RevWalk revWalk = null; - try - { + try { gr = open(); - if (!gr.getAllRefs().isEmpty()) - { + if (!gr.getAllRefs().isEmpty()) { revWalk = new RevWalk(gr); ObjectId id = GitUtil.getRevisionId(gr, revision); RevCommit commit = revWalk.parseCommit(id); - if (commit != null) - { + if (commit != null) { converter = converterFactory.create(gr, revWalk); if (isBranchRequested(request)) { @@ -98,23 +89,15 @@ public class GitLogCommand extends AbstractGitCommand implements LogCommand } else { changeset = converter.createChangeset(commit); } - } - else if (logger.isWarnEnabled()) - { + } else if (logger.isWarnEnabled()) { logger.warn("could not find revision {}", revision); } } - } - catch (IOException ex) - { + } catch (IOException ex) { logger.error("could not open repository: " + repository.getNamespaceAndName(), ex); - } - catch (NullPointerException e) - { + } catch (NullPointerException e) { throw notFound(entity(REVISION, revision).in(this.repository)); - } - finally - { + } finally { IOUtil.close(converter); GitUtil.release(revWalk); } @@ -138,14 +121,10 @@ public class GitLogCommand extends AbstractGitCommand implements LogCommand @Override @SuppressWarnings("java:S2093") public ChangesetPagingResult getChangesets(LogCommandRequest request) { - try { - if (Strings.isNullOrEmpty(request.getBranch())) { - request.setBranch(context.getConfig().getDefaultBranch()); - } - return new GitLogComputer(this.repository.getId(), open(), converterFactory).compute(request); - } catch (IOException e) { - throw new InternalRepositoryException(repository, "could not create change log", e); + if (Strings.isNullOrEmpty(request.getBranch())) { + request.setBranch(context.getConfig().getDefaultBranch()); } + return new GitLogComputer(this.repository.getId(), open(), converterFactory).compute(request); } public interface Factory { 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 100edf8670..3b68a08004 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 @@ -32,6 +32,7 @@ import org.eclipse.jgit.treewalk.filter.PathFilter; import sonia.scm.repository.GitRepositoryHandler; import sonia.scm.repository.GitWorkingCopyFactory; import sonia.scm.repository.InternalRepositoryException; +import sonia.scm.repository.RepositoryManager; import sonia.scm.repository.api.MergeCommandResult; import sonia.scm.repository.api.MergeDryRunCommandResult; import sonia.scm.repository.api.MergePreventReason; @@ -56,6 +57,9 @@ public class GitMergeCommand extends AbstractGitCommand implements MergeCommand private final GitWorkingCopyFactory workingCopyFactory; private final AttributeAnalyzer attributeAnalyzer; + private final RepositoryManager repositoryManager; + private final GitRepositoryHookEventFactory eventFactory; + private static final Set STRATEGIES = Set.of( MergeStrategy.MERGE_COMMIT, MergeStrategy.FAST_FORWARD_IF_POSSIBLE, @@ -64,14 +68,24 @@ public class GitMergeCommand extends AbstractGitCommand implements MergeCommand ); @Inject - GitMergeCommand(@Assisted GitContext context, GitRepositoryHandler handler, AttributeAnalyzer attributeAnalyzer) { - this(context, handler.getWorkingCopyFactory(), attributeAnalyzer); + GitMergeCommand(@Assisted GitContext context, + GitRepositoryHandler handler, + AttributeAnalyzer attributeAnalyzer, + RepositoryManager repositoryManager, + GitRepositoryHookEventFactory eventFactory) { + this(context, handler.getWorkingCopyFactory(), attributeAnalyzer, repositoryManager, eventFactory); } - GitMergeCommand(@Assisted GitContext context, GitWorkingCopyFactory workingCopyFactory, AttributeAnalyzer attributeAnalyzer) { + GitMergeCommand(@Assisted GitContext context, + GitWorkingCopyFactory workingCopyFactory, + AttributeAnalyzer attributeAnalyzer, + RepositoryManager repositoryManager, + GitRepositoryHookEventFactory eventFactory) { super(context); this.workingCopyFactory = workingCopyFactory; this.attributeAnalyzer = attributeAnalyzer; + this.repositoryManager = repositoryManager; + this.eventFactory = eventFactory; } @Override @@ -85,22 +99,14 @@ public class GitMergeCommand extends AbstractGitCommand implements MergeCommand } private MergeCommandResult mergeWithStrategy(MergeCommandRequest request) { - switch (request.getMergeStrategy()) { - case SQUASH: - return inClone(clone -> new GitMergeWithSquash(clone, request, context, repository), workingCopyFactory, request.getTargetBranch()); - - case FAST_FORWARD_IF_POSSIBLE: - return inClone(clone -> new GitFastForwardIfPossible(clone, request, context, repository), workingCopyFactory, request.getTargetBranch()); - - case MERGE_COMMIT: - return inClone(clone -> new GitMergeCommit(clone, request, context, repository), workingCopyFactory, request.getTargetBranch()); - - case REBASE: - return inClone(clone -> new GitMergeRebase(clone, request, context, repository), workingCopyFactory, request.getTargetBranch()); - - default: - throw new MergeStrategyNotSupportedException(repository, request.getMergeStrategy()); - } + 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(); + 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()); + }; } @Override diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitMergeCommit.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitMergeCommit.java index d06d4db962..98e930032f 100644 --- a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitMergeCommit.java +++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitMergeCommit.java @@ -16,39 +16,21 @@ package sonia.scm.repository.spi; -import org.eclipse.jgit.api.Git; -import org.eclipse.jgit.api.MergeCommand; -import org.eclipse.jgit.api.MergeResult; -import org.eclipse.jgit.revwalk.RevCommit; -import sonia.scm.NoChangesMadeException; -import sonia.scm.repository.Repository; +import org.eclipse.jgit.lib.ObjectId; +import sonia.scm.repository.RepositoryManager; import sonia.scm.repository.api.MergeCommandResult; -import java.io.IOException; -import java.util.Collections; -import java.util.Optional; +class GitMergeCommit { -import static sonia.scm.repository.spi.GitRevisionExtractor.extractRevisionFromRevCommit; + private final MergeCommandRequest request; + private final MergeHelper mergeHelper; -class GitMergeCommit extends GitMergeStrategy { - - GitMergeCommit(Git clone, MergeCommandRequest request, GitContext context, Repository repository) { - super(clone, request, context, repository); + GitMergeCommit(MergeCommandRequest request, GitContext context, RepositoryManager repositoryManager, GitRepositoryHookEventFactory eventFactory) { + this.request = request; + this.mergeHelper = new MergeHelper(context, request, repositoryManager, eventFactory); } - @Override - MergeCommandResult run() throws IOException { - MergeCommand mergeCommand = getClone().merge(); - mergeCommand.setFastForward(MergeCommand.FastForwardMode.NO_FF); - MergeResult result = doMergeInClone(mergeCommand); - - if (result.getMergeStatus().isSuccessful()) { - RevCommit revCommit = doCommit().orElseThrow(() -> new NoChangesMadeException(getRepository())); - push(); - return createSuccessResult(extractRevisionFromRevCommit(revCommit)); - } else { - return analyseFailure(result); - } + MergeCommandResult run() { + return mergeHelper.doRecursiveMerge(request, (sourceRevision, targetRevision) -> new ObjectId[]{targetRevision, sourceRevision}); } - } 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 12d6269b8d..72ba89bca6 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 @@ -16,73 +16,108 @@ package sonia.scm.repository.spi; -import org.eclipse.jgit.api.Git; -import org.eclipse.jgit.api.MergeCommand; -import org.eclipse.jgit.api.RebaseResult; -import org.eclipse.jgit.api.errors.GitAPIException; +import lombok.extern.slf4j.Slf4j; +import org.eclipse.jgit.api.errors.CanceledException; +import org.eclipse.jgit.api.errors.UnsupportedSigningFormatException; import org.eclipse.jgit.lib.ObjectId; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; +import org.eclipse.jgit.lib.PersonIdent; +import org.eclipse.jgit.merge.ResolveMerger; +import org.eclipse.jgit.revwalk.RevCommit; +import org.eclipse.jgit.revwalk.RevSort; +import org.eclipse.jgit.revwalk.RevWalk; import sonia.scm.repository.InternalRepositoryException; -import sonia.scm.repository.Repository; +import sonia.scm.repository.Person; +import sonia.scm.repository.RepositoryManager; import sonia.scm.repository.api.MergeCommandResult; import java.io.IOException; +import java.util.ArrayList; import java.util.Collections; -import java.util.Optional; +import java.util.Iterator; +import java.util.List; -public class GitMergeRebase extends GitMergeStrategy { +import static java.util.Optional.ofNullable; +import static org.eclipse.jgit.merge.MergeStrategy.RESOLVE; - private static final Logger logger = LoggerFactory.getLogger(GitMergeRebase.class); +@Slf4j +class GitMergeRebase { private final MergeCommandRequest request; + private final GitContext context; + private final MergeHelper mergeHelper; + private final CommitHelper commitHelper; + private final GitFastForwardIfPossible fastForwardMerge; - GitMergeRebase(Git clone, MergeCommandRequest request, GitContext context, Repository repository) { - super(clone, request, context, repository); + GitMergeRebase(MergeCommandRequest request, GitContext context, RepositoryManager repositoryManager, GitRepositoryHookEventFactory eventFactory) { this.request = request; + 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); } - @Override - MergeCommandResult run() throws IOException { - RebaseResult result; - String branchToMerge = request.getBranchToMerge(); - String targetBranch = request.getTargetBranch(); - try { - checkOutBranch(branchToMerge); - result = - getClone() - .rebase() - .setUpstream(targetBranch) - .call(); - } catch (GitAPIException e) { - throw new InternalRepositoryException(getContext().getRepository(), "could not rebase branch " + branchToMerge + " onto " + targetBranch, e); + MergeCommandResult run() { + log.debug("rebase branch {} onto {}", request.getBranchToMerge(), request.getTargetBranch()); + + ObjectId sourceRevision = mergeHelper.getRevisionToMerge(); + ObjectId targetRevision = mergeHelper.getTargetRevision(); + if (mergeHelper.isMergedInto(targetRevision, sourceRevision)) { + log.trace("fast forward is possible; using fast forward merge"); + return fastForwardMerge.run(); } - if (result.getStatus().isSuccessful()) { - return fastForwardTargetBranch(branchToMerge, targetBranch, result); - } else { - logger.info("could not rebase branch {} into {} with rebase status '{}' due to ...", branchToMerge, targetBranch, result.getStatus()); - logger.info("... conflicts: {}", result.getConflicts()); - logger.info("... failing paths: {}", result.getFailingPaths()); - logger.info("... message: {}", result); - return MergeCommandResult.failure(branchToMerge, targetBranch, Optional.ofNullable(result.getConflicts()).orElse(Collections.singletonList("UNKNOWN"))); + try { + List commits = computeCommits(); + Collections.reverse(commits); + + for (RevCommit commit : commits) { + log.trace("rebase {} onto {}", commit, targetRevision); + ResolveMerger merger = (ResolveMerger) RESOLVE.newMerger(context.open(), true); // The recursive merger is always a RecursiveMerge + merger.setBase(commit.getParent(0)); + boolean mergeSucceeded = merger.merge(commit, targetRevision); + if (!mergeSucceeded) { + log.trace("could not merge {} into {}", commit, targetRevision); + return MergeCommandResult.failure(request.getBranchToMerge(), request.getTargetBranch(), ofNullable(merger.getUnmergedPaths()).orElse(Collections.singletonList("UNKNOWN"))); + } + ObjectId newTreeId = merger.getResultTreeId(); + log.trace("create commit for new tree {}", newTreeId); + + PersonIdent originalAuthor = commit.getAuthorIdent(); + targetRevision = commitHelper.createCommit( + newTreeId, + new Person(originalAuthor.getName(), originalAuthor.getEmailAddress()), + request.getAuthor(), + commit.getFullMessage(), + request.isSign(), + targetRevision + ); + log.trace("created {}", targetRevision); + } + log.trace("update branch {} to new revision {}", request.getTargetBranch(), targetRevision); + commitHelper.updateBranch(request.getTargetBranch(), targetRevision, mergeHelper.getTargetRevision()); + return MergeCommandResult.success(targetRevision.name(), mergeHelper.getRevisionToMerge().name(), targetRevision.name()); + } catch (IOException | CanceledException | UnsupportedSigningFormatException e) { + throw new InternalRepositoryException(context.getRepository(), "could not rebase branch " + request.getBranchToMerge() + " onto " + request.getTargetBranch(), e); } } - private MergeCommandResult fastForwardTargetBranch(String branchToMerge, String targetBranch, RebaseResult result) throws IOException { - try { - getClone().checkout().setName(targetBranch).call(); - ObjectId sourceRevision = resolveRevision(branchToMerge); - getClone() - .merge() - .setFastForward(MergeCommand.FastForwardMode.FF_ONLY) - .include(branchToMerge, sourceRevision) - .call(); - push(); - return createSuccessResult(sourceRevision.name()); - } catch (GitAPIException e) { - return MergeCommandResult.failure(branchToMerge, targetBranch, result.getConflicts()); - } + private List computeCommits() throws IOException { + List cherryPickList = new ArrayList<>(); + try (RevWalk revWalk = new RevWalk(context.open())) { + revWalk.sort(RevSort.TOPO_KEEP_BRANCH_TOGETHER, true); + revWalk.sort(RevSort.COMMIT_TIME_DESC, true); + revWalk.markUninteresting(revWalk.lookupCommit(mergeHelper.getTargetRevision())); + revWalk.markStart(revWalk.lookupCommit(mergeHelper.getRevisionToMerge())); + for (RevCommit commit : revWalk) { + if (commit.getParentCount() <= 1) { + log.trace("add {} to cherry pick list", commit); + cherryPickList.add(commit); + } else { + log.trace("skip {} because it has more than one parent", commit); + } + } + } + return cherryPickList; } } diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitMergeStrategy.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitMergeStrategy.java deleted file mode 100644 index 2d073577f0..0000000000 --- a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitMergeStrategy.java +++ /dev/null @@ -1,123 +0,0 @@ -/* - * 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; - -import com.google.common.base.Strings; -import org.eclipse.jgit.api.Git; -import org.eclipse.jgit.api.MergeCommand; -import org.eclipse.jgit.api.MergeResult; -import org.eclipse.jgit.api.errors.GitAPIException; -import org.eclipse.jgit.lib.ObjectId; -import org.eclipse.jgit.revwalk.RevCommit; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import sonia.scm.repository.InternalRepositoryException; -import sonia.scm.repository.Person; -import sonia.scm.repository.api.MergeCommandResult; - -import java.io.IOException; -import java.text.MessageFormat; -import java.util.Optional; - -abstract class GitMergeStrategy extends AbstractGitCommand.GitCloneWorker { - - private static final Logger logger = LoggerFactory.getLogger(GitMergeStrategy.class); - - private static final String MERGE_COMMIT_MESSAGE_TEMPLATE = String.join("\n", - "Merge of branch {0} into {1}", - "", - "Automatic merge by SCM-Manager."); - - private final String targetBranch; - private final ObjectId targetRevision; - private final String branchToMerge; - private final ObjectId revisionToMerge; - private final Person author; - private final String messageTemplate; - private final String message; - private final boolean sign; - - GitMergeStrategy(Git clone, MergeCommandRequest request, GitContext context, sonia.scm.repository.Repository repository) { - super(clone, context, repository); - this.targetBranch = request.getTargetBranch(); - this.branchToMerge = request.getBranchToMerge(); - this.author = request.getAuthor(); - this.messageTemplate = request.getMessageTemplate(); - this.message = request.getMessage(); - this.sign = request.isSign(); - try { - this.targetRevision = resolveRevision(request.getTargetBranch()); - this.revisionToMerge = resolveRevision(request.getBranchToMerge()); - } catch (IOException e) { - throw new InternalRepositoryException(repository, "Could not resolve revisions of target branch or branch to merge", e); - } - } - - MergeResult doMergeInClone(MergeCommand mergeCommand) throws IOException { - MergeResult result; - try { - ObjectId sourceRevision = resolveRevision(branchToMerge); - mergeCommand - .setCommit(false) // we want to set the author manually - .include(branchToMerge, sourceRevision); - - result = mergeCommand.call(); - } catch (GitAPIException e) { - throw new InternalRepositoryException(getContext().getRepository(), "could not merge branch " + branchToMerge + " into " + targetBranch, e); - } - return result; - } - - Optional doCommit() { - logger.debug("merged branch {} into {}", branchToMerge, targetBranch); - return doCommit(determineMessage(), author, sign); - } - - MergeCommandResult createSuccessResult(String newRevision) { - return MergeCommandResult.success(targetRevision.name(), revisionToMerge.name(), newRevision); - } - - ObjectId getTargetRevision() { - return targetRevision; - } - - ObjectId getRevisionToMerge() { - return revisionToMerge; - } - - private String determineMessage() { - if (!Strings.isNullOrEmpty(message)) { - return message; - } else if (!Strings.isNullOrEmpty(messageTemplate)) { - return MessageFormat.format(messageTemplate, branchToMerge, targetBranch); - } else { - return MessageFormat.format(MERGE_COMMIT_MESSAGE_TEMPLATE, branchToMerge, targetBranch); - } - } - - MergeCommandResult analyseFailure(MergeResult result) { - logger.info("could not merge branch {} into {} with merge status '{}' due to ...", branchToMerge, targetBranch, result.getMergeStatus()); - logger.info("... conflicts: {}", result.getConflicts()); - logger.info("... checkout conflicts: {}", result.getCheckoutConflicts()); - logger.info("... failing paths: {}", result.getFailingPaths()); - logger.info("... message: {}", result); - if (result.getConflicts() == null) { - throw new UnexpectedMergeResultException(getRepository(), result); - } - return MergeCommandResult.failure(targetRevision.name(), revisionToMerge.name(), result.getConflicts().keySet()); - } -} diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitMergeWithSquash.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitMergeWithSquash.java index b2d45c9701..82b16083a9 100644 --- a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitMergeWithSquash.java +++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitMergeWithSquash.java @@ -16,36 +16,21 @@ package sonia.scm.repository.spi; -import org.eclipse.jgit.api.Git; -import org.eclipse.jgit.api.MergeCommand; -import org.eclipse.jgit.api.MergeResult; -import org.eclipse.jgit.revwalk.RevCommit; -import sonia.scm.NoChangesMadeException; -import sonia.scm.repository.Repository; +import org.eclipse.jgit.lib.ObjectId; +import sonia.scm.repository.RepositoryManager; import sonia.scm.repository.api.MergeCommandResult; -import java.io.IOException; +class GitMergeWithSquash { -import static sonia.scm.repository.spi.GitRevisionExtractor.extractRevisionFromRevCommit; + private final MergeCommandRequest request; + private final MergeHelper mergeHelper; -class GitMergeWithSquash extends GitMergeStrategy { - - GitMergeWithSquash(Git clone, MergeCommandRequest request, GitContext context, Repository repository) { - super(clone, request, context, repository); + GitMergeWithSquash(MergeCommandRequest request, GitContext context, RepositoryManager repositoryManager, GitRepositoryHookEventFactory eventFactory) { + this.request = request; + this.mergeHelper = new MergeHelper(context, request, repositoryManager, eventFactory); } - @Override - MergeCommandResult run() throws IOException { - MergeCommand mergeCommand = getClone().merge(); - mergeCommand.setSquash(true); - MergeResult result = doMergeInClone(mergeCommand); - - if (result.getMergeStatus().isSuccessful()) { - RevCommit revCommit = doCommit().orElseThrow(() -> new NoChangesMadeException(getRepository())); - push(); - return createSuccessResult(extractRevisionFromRevCommit(revCommit)); - } else { - return analyseFailure(result); - } + MergeCommandResult run() { + return mergeHelper.doRecursiveMerge(request, (sourceRevision, targetRevision) -> new ObjectId[]{targetRevision}); } } diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitModifyCommand.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitModifyCommand.java index a9e5dbc80d..815ef7b6de 100644 --- a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitModifyCommand.java +++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitModifyCommand.java @@ -16,198 +16,194 @@ package sonia.scm.repository.spi; -import com.google.common.util.concurrent.Striped; import com.google.inject.assistedinject.Assisted; import jakarta.inject.Inject; -import org.apache.commons.lang.StringUtils; -import org.eclipse.jgit.api.Git; -import org.eclipse.jgit.api.errors.GitAPIException; -import org.eclipse.jgit.attributes.FilterCommandRegistry; +import org.eclipse.jgit.api.errors.CanceledException; +import org.eclipse.jgit.api.errors.UnsupportedSigningFormatException; +import org.eclipse.jgit.attributes.AttributesNode; +import org.eclipse.jgit.attributes.AttributesRule; import org.eclipse.jgit.dircache.DirCache; +import org.eclipse.jgit.dircache.DirCacheBuilder; +import org.eclipse.jgit.dircache.DirCacheEntry; +import org.eclipse.jgit.dircache.InvalidPathException; +import org.eclipse.jgit.errors.DirCacheNameConflictException; +import org.eclipse.jgit.lib.Constants; +import org.eclipse.jgit.lib.FileMode; +import org.eclipse.jgit.lib.ObjectId; +import org.eclipse.jgit.lib.ObjectInserter; +import org.eclipse.jgit.lib.ObjectLoader; +import org.eclipse.jgit.lib.Repository; import org.eclipse.jgit.revwalk.RevCommit; +import org.eclipse.jgit.revwalk.RevWalk; +import org.eclipse.jgit.treewalk.TreeWalk; +import org.eclipse.jgit.treewalk.filter.PathFilter; import sonia.scm.ConcurrentModificationException; -import sonia.scm.ContextEntry; import sonia.scm.NoChangesMadeException; -import sonia.scm.api.v2.resources.GitRepositoryConfigStoreProvider; -import sonia.scm.repository.GitRepositoryHandler; -import sonia.scm.repository.GitWorkingCopyFactory; +import sonia.scm.repository.GitUtil; import sonia.scm.repository.InternalRepositoryException; -import sonia.scm.repository.Repository; +import sonia.scm.repository.RepositoryManager; import sonia.scm.web.lfs.LfsBlobStoreFactory; +import java.io.ByteArrayOutputStream; import java.io.File; +import java.io.FileInputStream; import java.io.IOException; -import java.nio.file.Path; -import java.util.Optional; -import java.util.concurrent.locks.Lock; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.HashMap; +import java.util.Map; + +import static sonia.scm.AlreadyExistsException.alreadyExists; +import static sonia.scm.ContextEntry.ContextBuilder.entity; +import static sonia.scm.NotFoundException.notFound; +import static sonia.scm.ScmConstraintViolationException.Builder.doThrow; public class GitModifyCommand extends AbstractGitCommand implements ModifyCommand { - private static final Striped REGISTER_LOCKS = Striped.lock(5); - - private final GitWorkingCopyFactory workingCopyFactory; + private final RepositoryManager repositoryManager; + private final GitRepositoryHookEventFactory eventFactory; private final LfsBlobStoreFactory lfsBlobStoreFactory; - private final GitRepositoryConfigStoreProvider gitRepositoryConfigStoreProvider; + + private RevCommit parentCommit; @Inject - GitModifyCommand(@Assisted GitContext context, GitRepositoryHandler repositoryHandler, LfsBlobStoreFactory lfsBlobStoreFactory, GitRepositoryConfigStoreProvider gitRepositoryConfigStoreProvider) { - this(context, repositoryHandler.getWorkingCopyFactory(), lfsBlobStoreFactory, gitRepositoryConfigStoreProvider); + GitModifyCommand(@Assisted GitContext context, LfsBlobStoreFactory lfsBlobStoreFactory, RepositoryManager repositoryManager, GitRepositoryHookEventFactory eventFactory) { + super(context); + this.repositoryManager = repositoryManager; + this.eventFactory = eventFactory; + this.lfsBlobStoreFactory = lfsBlobStoreFactory; } - GitModifyCommand(@Assisted GitContext context, GitWorkingCopyFactory workingCopyFactory, LfsBlobStoreFactory lfsBlobStoreFactory, GitRepositoryConfigStoreProvider gitRepositoryConfigStoreProvider) { - super(context); - this.workingCopyFactory = workingCopyFactory; - this.lfsBlobStoreFactory = lfsBlobStoreFactory; - this.gitRepositoryConfigStoreProvider = gitRepositoryConfigStoreProvider; + private interface TreeChange { + boolean keepOriginalEntry(String path, ObjectId blob); + + default void finish(TreeHelper treeHelper) { + } } @Override public String execute(ModifyCommandRequest request) { - return inClone(clone -> new ModifyWorker(clone, request), workingCopyFactory, request.getBranch()); + try { + org.eclipse.jgit.lib.Repository repository = context.open(); + CommitHelper commitHelper = new CommitHelper(context, repositoryManager, eventFactory); + String branchToChange = request.getBranch() == null ? context.getGlobalConfig().getDefaultBranch() : request.getBranch(); + ObjectId parentCommitId = repository.resolve(GitUtil.getRevString(branchToChange)); + if (parentCommitId == null && request.getBranch() != null && repository.resolve("HEAD") != null) { + throw notFound(entity("Branch", branchToChange).in(this.repository)); + } + if (request.getExpectedRevision() != null && !parentCommitId.name().equals(request.getExpectedRevision())) { + throw new ConcurrentModificationException(entity("Branch", branchToChange).in(this.repository).build()); + } + + InPlaceWorker inPlaceWorker = new InPlaceWorker(repository); + + try (RevWalk revWalk = new RevWalk(repository)) { + parentCommit = parentCommitId == null ? null : revWalk.parseCommit(parentCommitId); + } + + for (ModifyCommandRequest.PartialRequest r : request.getRequests()) { + r.execute(inPlaceWorker); + } + + TreeHelper treeHelper = new TreeHelper(repository); + if (parentCommitId != null) { + treeHelper.initialize(parentCommitId, inPlaceWorker.changes); + } + + inPlaceWorker.finish(treeHelper); + + ObjectId treeId = treeHelper.flush(); + + if (parentCommitId != null) { + if (parentCommit.getTree().equals(treeId)) { + throw new NoChangesMadeException(GitModifyCommand.this.repository, branchToChange); + } + } + + ObjectId commitId = commitHelper.createCommit( + treeId, + request.getAuthor(), + request.getAuthor(), + request.getCommitMessage(), + request.isSign(), + parentCommitId == null ? new ObjectId[0] : new ObjectId[]{parentCommitId} + ); + + commitHelper.updateBranch(branchToChange, commitId, parentCommitId); + + return commitId.name(); + } catch (IOException | CanceledException | UnsupportedSigningFormatException e) { + throw new InternalRepositoryException(repository, "Error during modification", e); + } } - private class ModifyWorker extends GitCloneWorker implements ModifyWorkerHelper { + private static String removeStartingSlash(String toBeCreated) { + return toBeCreated.startsWith("/") ? toBeCreated.substring(1) : toBeCreated; + } - private final File workDir; - private final ModifyCommandRequest request; + private class TreeHelper { - ModifyWorker(Git clone, ModifyCommandRequest request) { - super(clone, context, repository); - this.workDir = clone.getRepository().getWorkTree(); - this.request = request; + private final org.eclipse.jgit.lib.Repository repository; + private final DirCacheBuilder builder; + private final ObjectInserter inserter; + private final DirCache dirCache = DirCache.newInCore(); + + TreeHelper(Repository repository) { + this.repository = repository; + this.inserter = repository.newObjectInserter(); + this.builder = dirCache.builder(); } - @Override - String run() throws IOException { - getClone().getRepository().getFullBranch(); + private void initialize(ObjectId parentCommitId, Collection changes) throws IOException { + ObjectId parentTreeId = getTreeId(parentCommitId); + try (TreeWalk treeWalk = new TreeWalk(repository)) { - boolean initialCommit = getClone().getRepository().getRefDatabase().getRefs().isEmpty(); + treeWalk.addTree(parentTreeId); + treeWalk.setRecursive(true); - if (!StringUtils.isEmpty(request.getExpectedRevision()) - && !request.getExpectedRevision().equals(getCurrentObjectId().getName())) { - throw new ConcurrentModificationException(ContextEntry.ContextBuilder.entity("Branch", request.getBranch() == null ? "default" : request.getBranch()).in(repository).build()); - } - for (ModifyCommandRequest.PartialRequest r : request.getRequests()) { - r.execute(this); - } - failIfNotChanged(() -> new NoChangesMadeException(repository, ModifyWorker.this.request.getBranch())); - Optional revCommit = doCommit(request.getCommitMessage(), request.getAuthor(), request.isSign()); - - if (initialCommit) { - handleBranchForInitialCommit(); - } - - push(); - return revCommit.orElseThrow(() -> new NoChangesMadeException(repository, ModifyWorker.this.request.getBranch())).name(); - } - - private void handleBranchForInitialCommit() { - String branch = StringUtils.isNotBlank(request.getBranch()) ? request.getBranch() : context.getGlobalConfig().getDefaultBranch(); - if (StringUtils.isNotBlank(branch)) { - try { - createBranchIfNotThere(branch); - } catch (GitAPIException | IOException e) { - throw new InternalRepositoryException(repository, "could not create default branch for initial commit", e); + while (treeWalk.next()) { + String path = treeWalk.getPathString(); + if (changes.stream().allMatch(c -> c.keepOriginalEntry(path, treeWalk.getObjectId(0)))) { + DirCacheEntry entry = new DirCacheEntry(path); + entry.setObjectId(treeWalk.getObjectId(0)); + entry.setFileMode(treeWalk.getFileMode(0)); + builder.add(entry); + } } } } - private void createBranchIfNotThere(String branch) throws IOException, GitAPIException { - if (!branch.equals(getClone().getRepository().getBranch())) { - getClone().checkout().setName(branch).setCreateBranch(true).call(); - setBranchInConfig(branch); + ObjectId getTreeId(ObjectId commitId) throws IOException { + try (RevWalk revWalk = new RevWalk(repository)) { + RevCommit commit = revWalk.parseCommit(commitId); + return commit.getTree().getId(); } } - private void setBranchInConfig(String branch) { - gitRepositoryConfigStoreProvider.setDefaultBranch(repository, branch); - } - - @Override - public void addFileToScm(String name, Path file) { - addToGitWithLfsSupport(name, file); - } - - private void addToGitWithLfsSupport(String path, Path targetFile) { - REGISTER_LOCKS.get(targetFile).lock(); + void updateTreeWithNewFile(String filePath, ObjectId blobId) { + if (filePath.startsWith("/")) { + filePath = filePath.substring(1); + } try { - LfsBlobStoreCleanFilterFactory cleanFilterFactory = new LfsBlobStoreCleanFilterFactory(lfsBlobStoreFactory, repository, targetFile); - - String registerKey = "git-lfs clean -- '" + path + "'"; - LOG.debug("register lfs filter command factory for command '{}'", registerKey); - FilterCommandRegistry.register(registerKey, cleanFilterFactory::createFilter); - try { - addFileToGit(path); - } catch (GitAPIException e) { - throwInternalRepositoryException("could not add file to index", e); - } finally { - LOG.debug("unregister lfs filter command factory for command \"{}\"", registerKey); - FilterCommandRegistry.unregister(registerKey); - } - } finally { - REGISTER_LOCKS.get(targetFile).unlock(); + DirCacheEntry newEntry = new DirCacheEntry(filePath); + newEntry.setObjectId(blobId); + newEntry.setFileMode(FileMode.REGULAR_FILE); + builder.add(newEntry); + } catch (InvalidPathException e) { + doThrow().violation("Path", filePath).when(true); } } - @Override - public void addMovedFileToScm(String path, Path targetPath) { + ObjectId flush() throws IOException { try { - addFileToGit(path); - } catch (GitAPIException e) { - throwInternalRepositoryException("could not add file to index", e); + builder.finish(); + } catch (DirCacheNameConflictException e) { + throw alreadyExists(entity("File", e.getPath1()).in(GitModifyCommand.this.repository)); } - } - - private void addFileToGit(String toBeCreated) throws GitAPIException { - String toBeCreatedWithoutLeadingSlash = removeStartingPathSeparators(toBeCreated); - DirCache addResult = getClone().add().addFilepattern(toBeCreatedWithoutLeadingSlash).call(); - if (addResult.findEntry(toBeCreatedWithoutLeadingSlash) < 0) { - throw new ModificationFailedException(ContextEntry.ContextBuilder.entity("File", toBeCreated).in(repository).build(), "Could not add file to repository"); - } - } - - @Override - public void doScmDelete(String toBeDeleted) { - try { - String toBeDeletedWithoutLeadingSlash = removeStartingPathSeparators(toBeDeleted); - DirCache deleteResult = getClone().rm().addFilepattern(toBeDeletedWithoutLeadingSlash).call(); - if (deleteResult.findEntry(toBeDeletedWithoutLeadingSlash) >= 0) { - throw new ModificationFailedException(ContextEntry.ContextBuilder.entity("File", toBeDeleted).in(repository).build(), "Could not delete file from repository"); - } - } catch (GitAPIException e) { - throwInternalRepositoryException("could not remove file from index", e); - } - } - - @Override - public boolean isProtectedPath(Path path) { - return path.startsWith(getClone().getRepository().getDirectory().toPath().normalize()); - } - - @Override - public File getWorkDir() { - return workDir; - } - - @Override - public Repository getRepository() { - return repository; - } - - @Override - public String getBranch() { - return request.getBranch(); - } - - private String removeStartingPathSeparators(String path) { - if (path.startsWith("/")) { - return path.substring(1); - } - return path; - } - - private String throwInternalRepositoryException(String message, Exception e) { - throw new InternalRepositoryException(context.getRepository(), message, e); + ObjectId newTreeId = dirCache.writeTree(inserter); + inserter.flush(); + return newTreeId; } } @@ -215,4 +211,245 @@ public class GitModifyCommand extends AbstractGitCommand implements ModifyComman ModifyCommand create(GitContext context); } + private class InPlaceWorker implements Worker { + private final Collection changes = new ArrayList<>(); + private final Repository repository; + private final Map attributesCache = new HashMap<>(); + + public InPlaceWorker(Repository repository) { + this.repository = repository; + } + + @Override + public void delete(String toBeDeleted, boolean recursive) { + changes.add(new DeleteChange(toBeDeleted, recursive)); + } + + @Override + public void create(String toBeCreated, File file, boolean overwrite) throws IOException { + changes.add(new CreateChange(overwrite, toBeCreated, createBlob(toBeCreated, file))); + } + + @Override + public void modify(String toBeModified, File file) throws IOException { + ObjectId blobId = createBlob(toBeModified, file); + changes.add(new ModifyChange(toBeModified, blobId)); + } + + @Override + public void move(String oldPath, String newPath, boolean overwrite) { + changes.add(new MoveChange(oldPath, newPath)); + } + + public void finish(TreeHelper treeHelper) throws IOException { + for (TreeChange c : changes) { + c.finish(treeHelper); + } + } + + private ObjectId createBlob(String path, File file) throws IOException { + + try (ObjectInserter inserter = repository.newObjectInserter()) { + + if (isLfsFile(path)) { + return writeWithLfs(file, inserter); + } else { + ObjectId blobId = inserter.insert(Constants.OBJ_BLOB, file.length(), new FileInputStream(file)); + inserter.flush(); + return blobId; + } + } + } + + private boolean isLfsFile(String path) { + if (parentCommit == null) { + return false; + } + String[] pathParts = path.split("/"); + + for (int i = pathParts.length; i > 0; --i) { + String directory = i == 1 ? "" : String.join("/", Arrays.copyOf(pathParts, i - 1)) + "/"; + String relativeFileName = path.substring(directory.length()); + if (isLfsFile(directory, relativeFileName)) { + return true; + } + } + return false; + } + + private boolean isLfsFile(String directory, String relativeFileName) { + String attributesPath = directory + ".gitattributes"; + + ObjectId treeId = parentCommit.getTree().getId(); + + return attributesCache + .computeIfAbsent(directory, dir -> loadAttributes(treeId, attributesPath)) + .getRules() + .stream() + .anyMatch(attributes -> hasLfsFilterAttribute(relativeFileName, attributes)); + } + + private boolean hasLfsFilterAttribute(String relativeFileName, AttributesRule attributes) { + if (attributes.isMatch(relativeFileName, false)) { + return attributes.getAttributes().stream().anyMatch(attribute -> attribute.getKey().equals("filter") && attribute.getValue().equals("lfs")); + } + return false; + } + + private AttributesNode loadAttributes(ObjectId treeId, String attributesPath) { + try (TreeWalk treeWalk = new TreeWalk(repository)) { + treeWalk.addTree(treeId); + treeWalk.setRecursive(true); + treeWalk.setFilter(PathFilter.create(attributesPath)); + + AttributesNode attributesNode = new AttributesNode(); + if (treeWalk.next()) { + ObjectId objectId = treeWalk.getObjectId(0); + ObjectLoader loader = repository.open(objectId); + attributesNode.parse(loader.openStream()); + } + return attributesNode; + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + private ObjectId writeWithLfs(File file, ObjectInserter inserter) throws IOException { + LfsBlobStoreCleanFilterFactory cleanFilterFactory = new LfsBlobStoreCleanFilterFactory(lfsBlobStoreFactory, GitModifyCommand.this.repository, file.toPath()); + ByteArrayOutputStream pointer = new ByteArrayOutputStream(); + cleanFilterFactory.createFilter(repository, new FileInputStream(file), pointer).run(); + ObjectId blobId = inserter.insert(Constants.OBJ_BLOB, pointer.toByteArray()); + inserter.flush(); + return blobId; + } + + private class DeleteChange implements TreeChange { + private final String toBeDeleted; + private final boolean recursive; + private final String toBeDeletedAsDirectory; + private boolean foundOriginal; + + public DeleteChange(String toBeDeleted, boolean recursive) { + this.toBeDeleted = removeStartingSlash(toBeDeleted); + this.recursive = recursive; + this.toBeDeletedAsDirectory = this.toBeDeleted + "/"; + } + + @Override + public boolean keepOriginalEntry(String path, ObjectId blob) { + if (path.equals(toBeDeleted) || recursive && path.startsWith(toBeDeletedAsDirectory)) { + foundOriginal = true; + return false; + } + return true; + } + + @Override + public void finish(TreeHelper treeHelper) { + if (!foundOriginal) { + throw notFound(entity("File", toBeDeleted).in(GitModifyCommand.this.repository)); + } + } + } + + private class CreateChange implements TreeChange { + private final String toBeCreated; + private final boolean overwrite; + private final ObjectId blobId; + + public CreateChange(boolean overwrite, String toBeCreated, ObjectId blobId) { + this.toBeCreated = removeStartingSlash(toBeCreated); + this.overwrite = overwrite; + this.blobId = blobId; + } + + @Override + public boolean keepOriginalEntry(String path, ObjectId blob) { + if (path.equals(toBeCreated)) { + if (!overwrite) { + throw alreadyExists(entity("File", toBeCreated).in(GitModifyCommand.this.repository)); + } + return false; + } + return true; + } + + @Override + public void finish(TreeHelper treeHelper) { + treeHelper.updateTreeWithNewFile(toBeCreated, blobId); + } + } + + private class ModifyChange implements TreeChange { + private final String toBeModified; + private final ObjectId blobId; + private boolean foundOriginal; + + public ModifyChange(String toBeModified, ObjectId blobId) { + this.toBeModified = removeStartingSlash(toBeModified); + this.blobId = blobId; + } + + @Override + public boolean keepOriginalEntry(String path, ObjectId blob) { + if (path.equals(toBeModified)) { + foundOriginal = true; + return false; + } + return true; + } + + @Override + public void finish(TreeHelper treeHelper) { + if (!foundOriginal) { + throw notFound(entity("File", toBeModified).in(GitModifyCommand.this.repository)); + } + treeHelper.updateTreeWithNewFile(toBeModified, blobId); + } + } + + private class MoveChange implements TreeChange { + private final String oldPath; + private final String oldPathAsDirectory; + private final String newPath; + private final Collection moves = new ArrayList<>(); + + public MoveChange(String oldPath, String newPath) { + this.oldPath = removeStartingSlash(oldPath); + this.newPath = removeStartingSlash(newPath); + this.oldPathAsDirectory = this.oldPath + "/"; + } + + @Override + public boolean keepOriginalEntry(String path, ObjectId blob) { + if (path.equals(oldPath) || path.startsWith(oldPathAsDirectory)) { + moves.add(new Move(path, blob)); + return false; + } + return !path.equals(newPath); + } + + @Override + public void finish(TreeHelper treeHelper) { + if (moves.isEmpty()) { + throw notFound(entity("File", oldPath).in(GitModifyCommand.this.repository)); + } + moves.forEach(move -> move.move(treeHelper)); + } + + private class Move { + private final String to; + private final ObjectId blobId; + + private Move(String from, ObjectId blobId) { + this.to = from.replace(oldPath, newPath); + this.blobId = blobId; + } + + private void move(TreeHelper treeHelper) { + treeHelper.updateTreeWithNewFile(to, blobId); + } + } + } + } } diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitRepositoryHookEventFactory.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitRepositoryHookEventFactory.java index 605bfcde6e..f729ba1f6d 100644 --- a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitRepositoryHookEventFactory.java +++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitRepositoryHookEventFactory.java @@ -17,6 +17,7 @@ package sonia.scm.repository.spi; import jakarta.inject.Inject; +import org.eclipse.jgit.revwalk.RevCommit; import sonia.scm.repository.GitChangesetConverter; import sonia.scm.repository.GitChangesetConverterFactory; import sonia.scm.repository.RepositoryHookEvent; @@ -24,10 +25,11 @@ import sonia.scm.repository.Tag; import sonia.scm.repository.api.HookContext; import sonia.scm.repository.api.HookContextFactory; -import java.io.IOException; import java.util.List; +import java.util.function.Supplier; import static sonia.scm.repository.RepositoryHookType.POST_RECEIVE; +import static sonia.scm.repository.RepositoryHookType.PRE_RECEIVE; class GitRepositoryHookEventFactory { @@ -40,14 +42,23 @@ class GitRepositoryHookEventFactory { this.changesetConverterFactory = changesetConverterFactory; } - RepositoryHookEvent createEvent(GitContext gitContext, + RepositoryHookEvent createPostReceiveEvent(GitContext gitContext, List branches, List tags, - GitLazyChangesetResolver changesetResolver - ) throws IOException { + Supplier> changesetResolver) { GitChangesetConverter converter = changesetConverterFactory.create(gitContext.open()); GitImportHookContextProvider contextProvider = new GitImportHookContextProvider(converter, branches, tags, changesetResolver); HookContext context = hookContextFactory.createContext(contextProvider, gitContext.getRepository()); return new RepositoryHookEvent(context, gitContext.getRepository(), POST_RECEIVE); } + + RepositoryHookEvent createPreReceiveEvent(GitContext gitContext, + List branches, + List tags, + Supplier> changesetResolver) { + GitChangesetConverter converter = changesetConverterFactory.create(gitContext.open()); + GitImportHookContextProvider contextProvider = new GitImportHookContextProvider(converter, branches, tags, changesetResolver); + HookContext context = hookContextFactory.createContext(contextProvider, gitContext.getRepository()); + return new RepositoryHookEvent(context, gitContext.getRepository(), PRE_RECEIVE); + } } diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitTagCommand.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitTagCommand.java index ab4f219c61..aff083c6ea 100644 --- a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitTagCommand.java +++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitTagCommand.java @@ -230,12 +230,7 @@ public class GitTagCommand extends AbstractGitCommand implements TagCommand { .map(tag -> new ReceiveCommand(fromString(tag.getRevision()), zeroId(), REFS_TAGS_PREFIX + tag.getName())) .forEach(receiveCommands::add); return x -> { - Repository gitRepo; - try { - gitRepo = context.open(); - } catch (IOException e) { - throw new InternalRepositoryException(repository, "failed to open repository for post receive hook after internal change", e); - } + Repository gitRepo = context.open(); GitHookChangesetCollector collector = GitHookChangesetCollector.collectChangesets( converterFactory, diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitUnbundleCommand.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitUnbundleCommand.java index 45bd5b6c20..fa81c3ab3f 100644 --- a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitUnbundleCommand.java +++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitUnbundleCommand.java @@ -73,11 +73,11 @@ public class GitUnbundleCommand extends AbstractGitCommand implements UnbundleCo List branches = extractBranches(git); List tags = extractTags(git); GitLazyChangesetResolver changesetResolver = new GitLazyChangesetResolver(context.getRepository(), git); - RepositoryHookEvent event = eventFactory.createEvent(context, branches, tags, changesetResolver); + RepositoryHookEvent event = eventFactory.createPostReceiveEvent(context, branches, tags, changesetResolver); if (event != null) { request.getPostEventSink().accept(event); } - } catch (IOException | GitAPIException e) { + } catch (GitAPIException e) { throw new ImportFailedException( ContextEntry.ContextBuilder.entity(context.getRepository()).build(), "Could not fire post receive repository hook event after unbundle", diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/MergeHelper.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/MergeHelper.java new file mode 100644 index 0000000000..8422f86665 --- /dev/null +++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/MergeHelper.java @@ -0,0 +1,171 @@ +/* + * 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; + +import com.google.common.base.Strings; +import lombok.extern.slf4j.Slf4j; +import org.eclipse.jgit.api.errors.CanceledException; +import org.eclipse.jgit.api.errors.UnsupportedSigningFormatException; +import org.eclipse.jgit.lib.ObjectId; +import org.eclipse.jgit.lib.Repository; +import org.eclipse.jgit.merge.ResolveMerger; +import org.eclipse.jgit.revwalk.RevCommit; +import org.eclipse.jgit.revwalk.RevWalk; +import sonia.scm.NoChangesMadeException; +import sonia.scm.repository.InternalRepositoryException; +import sonia.scm.repository.RepositoryManager; +import sonia.scm.repository.api.MergeCommandResult; + +import java.io.IOException; +import java.text.MessageFormat; +import java.util.Collection; +import java.util.Map; +import java.util.function.BiFunction; + +import static org.eclipse.jgit.merge.MergeStrategy.RECURSIVE; +import static sonia.scm.ContextEntry.ContextBuilder.entity; +import static sonia.scm.NotFoundException.notFound; + +@Slf4j +class MergeHelper { + + private static final String MERGE_COMMIT_MESSAGE_TEMPLATE = String.join("\n", + "Merge of branch {0} into {1}", + "", + "Automatic merge by SCM-Manager."); + + private final GitContext context; + private final RepositoryManager repositoryManager; + private final GitRepositoryHookEventFactory eventFactory; + private final Repository repository; + + private final ObjectId targetRevision; + private final ObjectId revisionToMerge; + private final String targetBranch; + private final String branchToMerge; + private final String messageTemplate; + private final String message; + + MergeHelper(GitContext context, + MergeCommandRequest request, + RepositoryManager repositoryManager, + GitRepositoryHookEventFactory eventFactory) { + this.context = context; + this.repositoryManager = repositoryManager; + this.eventFactory = eventFactory; + try { + this.repository = context.open(); + this.targetRevision = resolveRevision(request.getTargetBranch()); + this.revisionToMerge = resolveRevision(request.getBranchToMerge()); + } catch (IOException e) { + throw new InternalRepositoryException(context.getRepository(), "Could not resolve revisions of target branch or branch to merge", e); + } + this.targetBranch = request.getTargetBranch(); + this.branchToMerge = request.getBranchToMerge(); + this.messageTemplate = request.getMessageTemplate(); + this.message = request.getMessage(); + } + + ObjectId getTargetRevision() { + return targetRevision; + } + + ObjectId getRevisionToMerge() { + return revisionToMerge; + } + + ObjectId resolveRevision(String revision) throws IOException { + ObjectId resolved = repository.resolve(revision); + if (resolved == null) { + throw notFound(entity("Revision", revision).in(context.getRepository())); + } else { + return resolved; + } + } + + String determineMessage() { + if (!Strings.isNullOrEmpty(message)) { + return message; + } else if (!Strings.isNullOrEmpty(messageTemplate)) { + return MessageFormat.format(messageTemplate, branchToMerge, targetBranch); + } else { + return MessageFormat.format(MERGE_COMMIT_MESSAGE_TEMPLATE, branchToMerge, targetBranch); + } + } + + Collection getFailingPaths(ResolveMerger merger) { + return merger.getMergeResults() + .entrySet() + .stream() + .filter(entry -> entry.getValue().containsConflicts()) + .map(Map.Entry::getKey) + .toList(); + } + + boolean isMergedInto(ObjectId baseRevision, ObjectId revisionToCheck) { + try (RevWalk revWalk = new RevWalk(context.open())) { + RevCommit baseCommit = revWalk.parseCommit(baseRevision); + RevCommit commitToCheck = revWalk.parseCommit(revisionToCheck); + return revWalk.isMergedInto(baseCommit, commitToCheck); + } catch (IOException e) { + throw new InternalRepositoryException(context.getRepository(), "failed to check whether revision " + revisionToCheck + " is merged into " + baseRevision, e); + } + } + + MergeCommandResult doRecursiveMerge(MergeCommandRequest request, BiFunction parents) { + log.trace("merge branch {} into {}", branchToMerge, targetBranch); + try { + org.eclipse.jgit.lib.Repository repository = context.open(); + ObjectId sourceRevision = getRevisionToMerge(); + ObjectId targetRevision = getTargetRevision(); + + assertBranchesNotMerged(request, sourceRevision, targetRevision); + + ResolveMerger merger = (ResolveMerger) RECURSIVE.newMerger(repository, true); // The recursive merger is always a RecursiveMerge + boolean mergeSucceeded = merger.merge(sourceRevision, targetRevision); + if (!mergeSucceeded) { + log.trace("could not merge branch {} into {}", branchToMerge, targetBranch); + return MergeCommandResult.failure(targetRevision.name(), sourceRevision.name(), getFailingPaths(merger)); + } + ObjectId newTreeId = merger.getResultTreeId(); + log.trace("create commit for new tree {}", newTreeId); + + CommitHelper commitHelper = new CommitHelper(context, repositoryManager, eventFactory); + ObjectId commitId = commitHelper.createCommit( + newTreeId, + request.getAuthor(), + request.getAuthor(), + determineMessage(), + request.isSign(), + parents.apply(sourceRevision, targetRevision) + ); + log.trace("created commit {}", commitId); + + commitHelper.updateBranch(request.getTargetBranch(), commitId, targetRevision); + + return MergeCommandResult.success(targetRevision.name(), sourceRevision.name(), commitId.name()); + } catch (IOException | CanceledException | UnsupportedSigningFormatException e) { + throw new InternalRepositoryException(context.getRepository(), "Error during merge", e); + } + } + + private void assertBranchesNotMerged(MergeCommandRequest request, ObjectId sourceRevision, ObjectId targetRevision) throws IOException { + if (isMergedInto(sourceRevision, targetRevision)) { + throw new NoChangesMadeException(context.getRepository(), request.getTargetBranch()); + } + } +} diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/PostReceiveRepositoryHookEventFactory.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/PostReceiveRepositoryHookEventFactory.java index 90af25f255..19c63b6cee 100644 --- a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/PostReceiveRepositoryHookEventFactory.java +++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/PostReceiveRepositoryHookEventFactory.java @@ -20,14 +20,11 @@ import com.google.inject.assistedinject.Assisted; import jakarta.inject.Inject; import org.eclipse.jgit.api.Git; import org.eclipse.jgit.transport.FetchResult; -import sonia.scm.ContextEntry; import sonia.scm.event.ScmEventBus; import sonia.scm.repository.PostReceiveRepositoryHookEvent; import sonia.scm.repository.Tag; import sonia.scm.repository.WrappedRepositoryHookEvent; -import sonia.scm.repository.api.ImportFailedException; -import java.io.IOException; import java.util.List; import java.util.stream.Collectors; @@ -46,18 +43,10 @@ public class PostReceiveRepositoryHookEventFactory { void fireForFetch(Git git, FetchResult result) { PostReceiveRepositoryHookEvent event; - try { - List branches = getBranchesFromFetchResult(result); - List tags = getTagsFromFetchResult(result); - GitLazyChangesetResolver changesetResolver = new GitLazyChangesetResolver(context.getRepository(), git); - event = new PostReceiveRepositoryHookEvent(WrappedRepositoryHookEvent.wrap(eventFactory.createEvent(context, branches, tags, changesetResolver))); - } catch (IOException e) { - throw new ImportFailedException( - ContextEntry.ContextBuilder.entity(context.getRepository()).build(), - "Could not fire post receive repository hook event after fetch", - e - ); - } + List branches = getBranchesFromFetchResult(result); + List tags = getTagsFromFetchResult(result); + GitLazyChangesetResolver changesetResolver = new GitLazyChangesetResolver(context.getRepository(), git); + event = new PostReceiveRepositoryHookEvent(WrappedRepositoryHookEvent.wrap(eventFactory.createPostReceiveEvent(context, branches, tags, changesetResolver))); eventBus.post(event); } diff --git a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/GitChangesetConverterTest.java b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/GitChangesetConverterTest.java index 5f1403fa54..29887052b1 100644 --- a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/GitChangesetConverterTest.java +++ b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/GitChangesetConverterTest.java @@ -29,13 +29,17 @@ import org.bouncycastle.openpgp.PGPSignatureGenerator; import org.bouncycastle.openpgp.operator.jcajce.JcaPGPContentSignerBuilder; import org.bouncycastle.openpgp.operator.jcajce.JcaPGPKeyPair; import org.eclipse.jgit.api.Git; +import org.eclipse.jgit.api.errors.CanceledException; import org.eclipse.jgit.api.errors.GitAPIException; import org.eclipse.jgit.api.errors.JGitInternalException; +import org.eclipse.jgit.api.errors.UnsupportedSigningFormatException; import org.eclipse.jgit.internal.JGitText; -import org.eclipse.jgit.lib.CommitBuilder; +import org.eclipse.jgit.lib.GpgConfig; import org.eclipse.jgit.lib.GpgSignature; -import org.eclipse.jgit.lib.GpgSigner; import org.eclipse.jgit.lib.PersonIdent; +import org.eclipse.jgit.lib.Repository; +import org.eclipse.jgit.lib.Signer; +import org.eclipse.jgit.lib.Signers; import org.eclipse.jgit.revwalk.RevCommit; import org.eclipse.jgit.transport.CredentialsProvider; import org.junit.jupiter.api.AfterAll; @@ -134,21 +138,21 @@ class GitChangesetConverterTest { private PublicKey publicKey; private PGPKeyPair keyPair; - private GpgSigner defaultSigner; + private Signer defaultSigner; @BeforeEach void setUpTestingSignerAndCaptureDefault() throws Exception { - defaultSigner = GpgSigner.getDefault(); + defaultSigner = Signers.get(GpgConfig.GpgFormat.OPENPGP); // we use the same keypair for all tests to speed things up a little bit if (keyPair == null) { keyPair = createKeyPair(); - GpgSigner.setDefault(new TestingGpgSigner(keyPair)); + Signers.set(GpgConfig.GpgFormat.OPENPGP, new TestingGpgSigner(keyPair)); } } @AfterEach void restoreDefaultSigner() { - GpgSigner.setDefault(defaultSigner); + Signers.set(GpgConfig.GpgFormat.OPENPGP, defaultSigner); } @Test @@ -242,7 +246,7 @@ class GitChangesetConverterTest { return new JcaPGPKeyPair(PGPPublicKey.RSA_GENERAL, pair, new Date()); } - private static class TestingGpgSigner extends GpgSigner { + private static class TestingGpgSigner implements Signer { private final PGPKeyPair keyPair; @@ -251,13 +255,7 @@ class GitChangesetConverterTest { } @Override - public boolean canLocateSigningKey(String gpgSigningKey, PersonIdent committer, CredentialsProvider credentialsProvider) { - return true; - } - - @Override - public void sign(CommitBuilder commit, String gpgSigningKey, - PersonIdent committer, CredentialsProvider credentialsProvider) { + public GpgSignature sign(Repository repository, GpgConfig gpgConfig, byte[] bytes, PersonIdent personIdent, String s, CredentialsProvider credentialsProvider) throws CanceledException, IOException, UnsupportedSigningFormatException { try { if (keyPair == null) { throw new JGitInternalException(JGitText.get().unableToSignCommitNoSecretKey); @@ -274,15 +272,18 @@ class GitChangesetConverterTest { ByteArrayOutputStream buffer = new ByteArrayOutputStream(); try (BCPGOutputStream out = new BCPGOutputStream(new ArmoredOutputStream(buffer))) { - signatureGenerator.update(commit.build()); signatureGenerator.generate().encode(out); } - commit.setGpgSignature(new GpgSignature(buffer.toByteArray())); + return new GpgSignature(buffer.toByteArray()); } catch (PGPException | IOException e) { throw new JGitInternalException(e.getMessage(), e); } } + @Override + public boolean canLocateSigningKey(Repository repository, GpgConfig gpgConfig, PersonIdent personIdent, String s, CredentialsProvider credentialsProvider) throws CanceledException { + return true; + } } // register bouncy castle provider on load diff --git a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/GitTestHelper.java b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/GitTestHelper.java index e13fe90afe..316636dc78 100644 --- a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/GitTestHelper.java +++ b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/GitTestHelper.java @@ -16,11 +16,11 @@ package sonia.scm.repository; -import org.eclipse.jgit.api.errors.CanceledException; -import org.eclipse.jgit.lib.CommitBuilder; +import org.eclipse.jgit.lib.GpgConfig; import org.eclipse.jgit.lib.GpgSignature; -import org.eclipse.jgit.lib.GpgSigner; import org.eclipse.jgit.lib.PersonIdent; +import org.eclipse.jgit.lib.Repository; +import org.eclipse.jgit.lib.Signer; import org.eclipse.jgit.transport.CredentialsProvider; import sonia.scm.security.GPG; import sonia.scm.security.PrivateKey; @@ -38,20 +38,19 @@ public final class GitTestHelper { return new GitChangesetConverterFactory(new NoopGPG()); } - public static class SimpleGpgSigner extends GpgSigner { + public static class SimpleGpgSigner implements Signer { public static byte[] getSignature() { return "SIGNATURE".getBytes(); } @Override - public void sign(CommitBuilder commitBuilder, String s, PersonIdent personIdent, CredentialsProvider - credentialsProvider) throws CanceledException { - commitBuilder.setGpgSignature(new GpgSignature(SimpleGpgSigner.getSignature())); + public GpgSignature sign(Repository repository, GpgConfig gpgConfig, byte[] bytes, PersonIdent personIdent, String s, CredentialsProvider credentialsProvider) { + return new GpgSignature(SimpleGpgSigner.getSignature()); } @Override - public boolean canLocateSigningKey(String s, PersonIdent personIdent, CredentialsProvider credentialsProvider) throws CanceledException { + public boolean canLocateSigningKey(Repository repository, GpgConfig gpgConfig, PersonIdent personIdent, String s, CredentialsProvider credentialsProvider) { return true; } diff --git a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/ScmGpgSignerTest.java b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/ScmGpgSignerTest.java index 47caba200d..ea9378b07a 100644 --- a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/ScmGpgSignerTest.java +++ b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/ScmGpgSignerTest.java @@ -18,12 +18,9 @@ package sonia.scm.repository; import org.eclipse.jgit.api.Git; import org.eclipse.jgit.api.errors.CanceledException; -import org.eclipse.jgit.internal.storage.dfs.DfsRepositoryDescription; -import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository; -import org.eclipse.jgit.lib.CommitBuilder; -import org.eclipse.jgit.lib.GpgSigner; +import org.eclipse.jgit.lib.GpgConfig; import org.eclipse.jgit.lib.PersonIdent; -import org.eclipse.jgit.lib.Repository; +import org.eclipse.jgit.lib.Signers; import org.eclipse.jgit.revwalk.RevCommit; import org.eclipse.jgit.transport.CredentialsProvider; import org.junit.jupiter.api.BeforeEach; @@ -79,7 +76,7 @@ class ScmGpgSignerTest { when(gpg.getPrivateKey()).thenReturn(privateKey); - GpgSigner.setDefault(signer); + Signers.set(GpgConfig.GpgFormat.OPENPGP, signer); Path repositoryPath = workdir.resolve("repository"); Git git = Git.init().setDirectory(repositoryPath.toFile()).call(); @@ -103,6 +100,6 @@ class ScmGpgSignerTest { @Test void canLocateSigningKey() throws CanceledException { - assertThat(signer.canLocateSigningKey("foo", personIdent, credentialsProvider)).isTrue(); + assertThat(signer.canLocateSigningKey(null, null, personIdent, "foo", credentialsProvider)).isTrue(); } } diff --git a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/AbstractGitCommandTestBase.java b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/AbstractGitCommandTestBase.java index f11b7b653b..097162a788 100644 --- a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/AbstractGitCommandTestBase.java +++ b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/AbstractGitCommandTestBase.java @@ -41,7 +41,9 @@ public class AbstractGitCommandTestBase extends ZippedRepositoryTestBase { if (context == null) { - context = new GitContext(repositoryDirectory, repository, new GitRepositoryConfigStoreProvider(InMemoryConfigurationStoreFactory.create()), new GitConfig()); + GitConfig config = new GitConfig(); + config.setDefaultBranch("master"); + context = new GitContext(repositoryDirectory, repository, new GitRepositoryConfigStoreProvider(InMemoryConfigurationStoreFactory.create()), config); } return context; diff --git a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitLazyChangesetResolverTest.java b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitLazyChangesetResolverTest.java index 874014544f..3884b54a1e 100644 --- a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitLazyChangesetResolverTest.java +++ b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitLazyChangesetResolverTest.java @@ -34,7 +34,7 @@ public class GitLazyChangesetResolverTest extends AbstractGitCommandTestBase { @Test public void shouldResolveChangesets() throws IOException { GitLazyChangesetResolver changesetResolver = new GitLazyChangesetResolver(repository, Git.wrap(createContext().open())); - Iterable commits = changesetResolver.call(); + Iterable commits = changesetResolver.get(); RevCommit firstCommit = commits.iterator().next(); assertThat(firstCommit.getId().toString()).isEqualTo("commit a8495c0335a13e6e432df90b3727fa91943189a7 1602078219 -----sp"); @@ -46,7 +46,7 @@ public class GitLazyChangesetResolverTest extends AbstractGitCommandTestBase { public void shouldResolveAllChangesets() throws IOException, GitAPIException { Git git = Git.wrap(createContext().open()); GitLazyChangesetResolver changesetResolver = new GitLazyChangesetResolver(repository, git); - Iterable allCommits = changesetResolver.call(); + Iterable allCommits = changesetResolver.get(); int allCommitsCounter = Iterables.size(allCommits); int singleBranchCommitsCounter = Iterables.size(git.log().call()); @@ -57,7 +57,7 @@ public class GitLazyChangesetResolverTest extends AbstractGitCommandTestBase { public void shouldThrowImportFailedException() { Git git = mock(Git.class); doThrow(ImportFailedException.class).when(git).log(); - new GitLazyChangesetResolver(repository, git).call(); + new GitLazyChangesetResolver(repository, git).get(); } } diff --git a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitMergeCommandConflictTest.java b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitMergeCommandConflictTest.java index 5d8d8be25e..0246d95449 100644 --- a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitMergeCommandConflictTest.java +++ b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitMergeCommandConflictTest.java @@ -90,7 +90,7 @@ public class GitMergeCommandConflictTest extends AbstractGitCommandTestBase { private MergeConflictResult computeMergeConflictResult(String branchToMerge, String targetBranch) { AttributeAnalyzer attributeAnalyzer = mock(AttributeAnalyzer.class); when(attributeAnalyzer.hasExternalMergeToolConflicts(any(), any())).thenReturn(false); - GitMergeCommand gitMergeCommand = new GitMergeCommand(createContext(), new SimpleGitWorkingCopyFactory(new NoneCachingWorkingCopyPool(new WorkdirProvider(null, repositoryLocationResolver)), new SimpleMeterRegistry()), attributeAnalyzer); + GitMergeCommand gitMergeCommand = new GitMergeCommand(createContext(), new SimpleGitWorkingCopyFactory(new NoneCachingWorkingCopyPool(new WorkdirProvider(null, repositoryLocationResolver)), new SimpleMeterRegistry()), attributeAnalyzer, null, null); MergeCommandRequest mergeCommandRequest = new MergeCommandRequest(); mergeCommandRequest.setBranchToMerge(branchToMerge); mergeCommandRequest.setTargetBranch(targetBranch); 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 487db356b0..c3f460a0d4 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 @@ -23,24 +23,28 @@ import org.apache.shiro.subject.SimplePrincipalCollection; import org.apache.shiro.subject.Subject; import org.eclipse.jgit.api.Git; import org.eclipse.jgit.api.errors.GitAPIException; -import org.eclipse.jgit.lib.GpgSigner; +import org.eclipse.jgit.lib.GpgConfig; import org.eclipse.jgit.lib.ObjectId; import org.eclipse.jgit.lib.PersonIdent; +import org.eclipse.jgit.lib.RefUpdate; import org.eclipse.jgit.lib.Repository; +import org.eclipse.jgit.lib.Signers; import org.eclipse.jgit.revwalk.RevCommit; import org.junit.BeforeClass; import org.junit.Rule; import org.junit.Test; -import org.junit.jupiter.api.Assertions; import org.junit.runner.RunWith; import org.mockito.Mock; import org.mockito.junit.MockitoJUnitRunner; +import sonia.scm.ConcurrentModificationException; import sonia.scm.NoChangesMadeException; import sonia.scm.NotFoundException; import sonia.scm.repository.Added; import sonia.scm.repository.GitTestHelper; 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.MergeCommandResult; import sonia.scm.repository.api.MergeDryRunCommandResult; import sonia.scm.repository.api.MergePreventReason; @@ -50,15 +54,17 @@ import sonia.scm.repository.work.NoneCachingWorkingCopyPool; import sonia.scm.repository.work.WorkdirProvider; import sonia.scm.user.User; -import java.io.BufferedWriter; -import java.io.File; -import java.io.FileWriter; import java.io.IOException; import java.util.List; import java.util.function.Consumer; import java.util.function.Function; import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @RunWith(MockitoJUnitRunner.class) @@ -69,14 +75,16 @@ public class GitMergeCommandTest extends AbstractGitCommandTestBase { @Rule public ShiroRule shiro = new ShiroRule(); - @Rule - public BindTransportProtocolRule transportProtocolRule = new BindTransportProtocolRule(); @Mock private AttributeAnalyzer attributeAnalyzer; + @Mock + private RepositoryManager repositoryManager; + @Mock + private GitRepositoryHookEventFactory eventFactory; @BeforeClass public static void setSigner() { - GpgSigner.setDefault(new GitTestHelper.SimpleGpgSigner()); + Signers.set(GpgConfig.GpgFormat.OPENPGP, new GitTestHelper.SimpleGpgSigner()); } @Test @@ -248,27 +256,26 @@ public class GitMergeCommandTest extends AbstractGitCommandTestBase { assertThat(mergeCommandResult.getFilesWithConflict()).containsExactly("a.txt"); } - @Test - public void shouldHandleUnexpectedMergeResults() { - GitMergeCommand command = createCommand(git -> { - try { - FileWriter fw = new FileWriter(new File(git.getRepository().getWorkTree(), "b.txt"), true); - BufferedWriter bw = new BufferedWriter(fw); - bw.write("change"); - bw.newLine(); - bw.close(); - } catch (IOException e) { - e.printStackTrace(); - } - }); + @Test(expected = ConcurrentModificationException.class) + public void shouldHandleConcurrentBranchModification() { + GitMergeCommand command = createCommand(); MergeCommandRequest request = new MergeCommandRequest(); - request.setBranchToMerge("mergeable"); request.setTargetBranch("master"); + request.setBranchToMerge("mergeable"); request.setMergeStrategy(MergeStrategy.MERGE_COMMIT); request.setAuthor(new Person("Dirk Gently", "dirk@holistic.det")); - request.setMessageTemplate("simple"); - Assertions.assertThrows(UnexpectedMergeResultException.class, () -> command.merge(request)); + // create concurrent modification after the pre commit hook was fired + doAnswer(invocation -> { + RefUpdate refUpdate = createCommand() + .open() + .updateRef("refs/heads/master"); + refUpdate.setNewObjectId(ObjectId.fromString("2f95f02d9c568594d31e78464bd11a96c62e3f91")); + refUpdate.update(); + return null; + }).when(repositoryManager).fireHookEvent(any()); + + command.merge(request); } @Test @@ -344,6 +351,7 @@ public class GitMergeCommandTest extends AbstractGitCommandTestBase { 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(); String message = mergeCommit.getFullMessage(); assertThat(mergeAuthor.getName()).isEqualTo("Dirk Gently"); @@ -370,6 +378,8 @@ public class GitMergeCommandTest extends AbstractGitCommandTestBase { String message = mergeCommit.getFullMessage(); assertThat(mergeAuthor.getName()).isEqualTo("Dirk Gently"); assertThat(message).isEqualTo("squash three commits"); + assertThat(mergeCommit.getParentCount()).isEqualTo(1); + assertThat(mergeCommit.getParent(0).name()).isEqualTo("fcd0ef1831e4002ac43ea539f4094334c79ea9ec"); GitModificationsCommand modificationsCommand = new GitModificationsCommand(createContext()); List changes = modificationsCommand.getModifications("master").getAdded(); @@ -535,6 +545,32 @@ public class GitMergeCommandTest extends AbstractGitCommandTestBase { assertThat(mergeCommit.getName()).doesNotStartWith("91b99de908fcd04772798a31c308a64aea1a5523"); } + @Test + public void shouldRebaseMultipleCommits() throws IOException, GitAPIException { + GitMergeCommand command = createCommand(); + MergeCommandRequest request = new MergeCommandRequest(); + request.setTargetBranch("master"); + request.setBranchToMerge("squash"); + request.setMergeStrategy(MergeStrategy.REBASE); + request.setAuthor(new Person("Dirk Gently", "dirk@holistic.det")); + + command.merge(request); + + Repository repository = createContext().open(); + Iterable commits = new Git(repository).log().add(repository.resolve("master")).setMaxCount(6).call(); + + assertThat(commits) + .extracting("shortMessage") + .containsExactly( + "third", + "second commit", + "first commit", + "added new line for blame", + "added file f", + "added file d and e in folder c" + ); + } + @Test public void shouldRejectRebaseMergeIfBranchCannotBeRebased() throws IOException, GitAPIException { GitMergeCommand command = createCommand(); @@ -547,11 +583,31 @@ public class GitMergeCommandTest extends AbstractGitCommandTestBase { MergeCommandResult mergeCommandResult = command.merge(request); assertThat(mergeCommandResult.isSuccess()).isFalse(); + assertThat(mergeCommandResult.getFilesWithConflict()).containsExactly("a.txt"); Repository repository = createContext().open(); Iterable commits = new Git(repository).log().add(repository.resolve("master")).setMaxCount(1).call(); RevCommit headCommit = commits.iterator().next(); assertThat(headCommit.getName()).isEqualTo("fcd0ef1831e4002ac43ea539f4094334c79ea9ec"); + } + @Test + public void shouldFireEvents() { + RepositoryHookEvent preReceive = mock(RepositoryHookEvent.class); + RepositoryHookEvent postReceive = mock(RepositoryHookEvent.class); + when(eventFactory.createPreReceiveEvent(any(), eq(List.of("master")), any(), any())).thenReturn(preReceive); + when(eventFactory.createPostReceiveEvent(any(), eq(List.of("master")), any(), any())).thenReturn(postReceive); + + GitMergeCommand command = createCommand(); + MergeCommandRequest request = new MergeCommandRequest(); + request.setTargetBranch("master"); + request.setBranchToMerge("mergeable"); + request.setMergeStrategy(MergeStrategy.MERGE_COMMIT); + request.setAuthor(new Person("Dirk Gently", "dirk@holistic.det")); + + command.merge(request); + + verify(repositoryManager).fireHookEvent(preReceive); + verify(repositoryManager).fireHookEvent(postReceive); } private GitMergeCommand createCommand() { @@ -560,7 +616,7 @@ public class GitMergeCommandTest extends AbstractGitCommandTestBase { } private GitMergeCommand createCommand(Consumer interceptor) { - return new GitMergeCommand(createContext(), new SimpleGitWorkingCopyFactory(new NoneCachingWorkingCopyPool(new WorkdirProvider(null, repositoryLocationResolver)), new SimpleMeterRegistry()), attributeAnalyzer) { + return new GitMergeCommand(createContext(), new SimpleGitWorkingCopyFactory(new NoneCachingWorkingCopyPool(new WorkdirProvider(null, repositoryLocationResolver)), new SimpleMeterRegistry()), attributeAnalyzer, repositoryManager, eventFactory) { @Override > R inClone(Function workerSupplier, GitWorkingCopyFactory workingCopyFactory, String initialBranch) { Function interceptedWorkerSupplier = git -> { diff --git a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitModifyCommandTest.java b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitModifyCommandTest.java index 616c4a15e6..ad6d2bc395 100644 --- a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitModifyCommandTest.java +++ b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitModifyCommandTest.java @@ -16,12 +16,12 @@ package sonia.scm.repository.spi; -import com.github.sdorra.shiro.SubjectAware; -import org.apache.shiro.subject.SimplePrincipalCollection; -import org.apache.shiro.subject.Subject; import org.eclipse.jgit.api.Git; +import org.eclipse.jgit.api.ResetCommand; import org.eclipse.jgit.api.errors.GitAPIException; import org.eclipse.jgit.lib.ObjectId; +import org.eclipse.jgit.lib.RefUpdate; +import org.eclipse.jgit.lib.Repository; import org.eclipse.jgit.revwalk.RevCommit; import org.eclipse.jgit.revwalk.RevWalk; import org.eclipse.jgit.treewalk.TreeWalk; @@ -29,12 +29,12 @@ import org.junit.Test; import sonia.scm.AlreadyExistsException; import sonia.scm.BadRequestException; import sonia.scm.ConcurrentModificationException; +import sonia.scm.NoChangesMadeException; import sonia.scm.NotFoundException; import sonia.scm.ScmConstraintViolationException; import sonia.scm.repository.GitTestHelper; import sonia.scm.repository.Person; import sonia.scm.repository.RepositoryHookType; -import sonia.scm.user.User; import java.io.File; import java.io.IOException; @@ -45,14 +45,14 @@ import static java.util.concurrent.TimeUnit.SECONDS; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.fail; import static org.awaitility.Awaitility.await; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.argThat; import static org.mockito.Mockito.description; +import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.verify; public class GitModifyCommandTest extends GitModifyCommandTestBase { - private static final String REALM = "AdminRealm"; - @Override protected String getZippedRepositoryResource() { return "sonia/scm/repository/spi/scm-git-spi-move-test.zip"; @@ -263,6 +263,38 @@ public class GitModifyCommandTest extends GitModifyCommandTestBase { } } + @Test + public void shouldDeleteDirectoryButNotFileWithSamePrefix() throws IOException { + GitModifyCommand command = createCommand(); + + ModifyCommandRequest request = prepareModifyCommandRequest(); + request.setBranch("similar-paths"); + request.addRequest(new ModifyCommandRequest.DeleteFileRequest("c", true)); + + command.execute(request); + + boolean foundCTxt = false; + + Repository repository = createContext().open(); + ObjectId lastCommit = repository.resolve("refs/heads/similar-paths"); + try (RevWalk walk = new RevWalk(repository)) { + RevCommit commit = walk.parseCommit(lastCommit); + ObjectId treeId = commit.getTree().getId(); + TreeWalk treeWalk = new TreeWalk(repository); + treeWalk.setRecursive(true); + treeWalk.addTree(treeId); + while (treeWalk.next()) { + if (treeWalk.getPathString().startsWith("c/")) { + fail("directory should be deleted"); + } + if (treeWalk.getPathString().equals("c.txt")) { + foundCTxt = true; + } + } + } + assertThat(foundCTxt).isTrue(); + } + @Test(expected = NotFoundException.class) public void shouldThrowNotFoundExceptionWhenFileToDeleteDoesNotExist() { GitModifyCommand command = createCommand(); @@ -346,10 +378,10 @@ public class GitModifyCommandTest extends GitModifyCommandTestBase { command.execute(request); - verify(transportProtocolRule.repositoryManager, description("pre receive hook event expected")) + verify(repositoryManager, description("pre receive hook event expected")) .fireHookEvent(argThat(argument -> argument.getType() == RepositoryHookType.PRE_RECEIVE)); await().pollInterval(50, MILLISECONDS).atMost(1, SECONDS).untilAsserted(() -> - verify(transportProtocolRule.repositoryManager, description("post receive hook event expected")) + verify(repositoryManager, description("post receive hook event expected")) .fireHookEvent(argThat(argument -> argument.getType() == RepositoryHookType.POST_RECEIVE)) ); } @@ -511,7 +543,7 @@ public class GitModifyCommandTest extends GitModifyCommandTestBase { assertInTree(assertions); } - @Test(expected = AlreadyExistsException.class) + @Test(expected = NoChangesMadeException.class) public void shouldFailMoveAndKeepFilesWhenSourceAndTargetAreTheSame() { GitModifyCommand command = createCommand(); @@ -521,18 +553,31 @@ public class GitModifyCommandTest extends GitModifyCommandTestBase { command.execute(request); } + @Test(expected = ConcurrentModificationException.class) + public void shouldFailOnConcurrent() throws IOException, GitAPIException { + File newFile = Files.write(tempFolder.newFile().toPath(), "new content".getBytes()).toFile(); + GitModifyCommand command = createCommand(); + ModifyCommandRequest request = prepareModifyCommandRequest(); + request.setBranch("master"); + request.addRequest(new ModifyCommandRequest.CreateFileRequest("new_file", newFile, false)); + + // create concurrent modification after the pre commit hook was fired + doAnswer(invocation -> { + RefUpdate refUpdate = createCommand() + .open() + .updateRef("refs/heads/master"); + refUpdate.setNewObjectId(ObjectId.fromString("a7d622087b6847725670ae84fa37bdf451123008")); + refUpdate.update(); + return null; + }).when(repositoryManager).fireHookEvent(any()); + + command.execute(request); + } + private ModifyCommandRequest prepareModifyCommandRequest() { ModifyCommandRequest request = new ModifyCommandRequest(); request.setCommitMessage("Make some change"); request.setAuthor(new Person("Dirk Gently", "dirk@holistic.det")); return request; } - - private ModifyCommandRequest prepareModifyCommandRequestWithoutAuthorEmail() { - ModifyCommandRequest request = new ModifyCommandRequest(); - request.setAuthor(new Person("Dirk Gently", "")); - request.setCommitMessage("Make some change"); - return request; - } - } diff --git a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitModifyCommandTestBase.java b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitModifyCommandTestBase.java index 3dba080ba3..21b1f2dc1f 100644 --- a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitModifyCommandTestBase.java +++ b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitModifyCommandTestBase.java @@ -18,39 +18,42 @@ package sonia.scm.repository.spi; import com.github.sdorra.shiro.ShiroRule; import com.github.sdorra.shiro.SubjectAware; -import io.micrometer.core.instrument.simple.SimpleMeterRegistry; import org.eclipse.jgit.api.Git; import org.eclipse.jgit.api.errors.GitAPIException; -import org.eclipse.jgit.lib.GpgSigner; +import org.eclipse.jgit.lib.GpgConfig; import org.eclipse.jgit.lib.ObjectId; +import org.eclipse.jgit.lib.Signers; import org.eclipse.jgit.revwalk.RevCommit; import org.eclipse.jgit.revwalk.RevWalk; import org.eclipse.jgit.treewalk.TreeWalk; import org.junit.BeforeClass; import org.junit.Rule; import sonia.scm.repository.GitTestHelper; -import sonia.scm.repository.work.NoneCachingWorkingCopyPool; -import sonia.scm.repository.work.WorkdirProvider; +import sonia.scm.repository.RepositoryHookEvent; +import sonia.scm.repository.RepositoryHookType; +import sonia.scm.repository.RepositoryManager; import sonia.scm.web.lfs.LfsBlobStoreFactory; import java.io.IOException; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.mock; -import static sonia.scm.repository.spi.GitRepositoryConfigStoreProviderTestUtil.createGitRepositoryConfigStoreProvider; +import static org.mockito.Mockito.when; +import static sonia.scm.repository.RepositoryHookType.POST_RECEIVE; +import static sonia.scm.repository.RepositoryHookType.PRE_RECEIVE; @SubjectAware(configuration = "classpath:sonia/scm/configuration/shiro.ini", username = "admin", password = "secret") class GitModifyCommandTestBase extends AbstractGitCommandTestBase { - @Rule - public BindTransportProtocolRule transportProtocolRule = new BindTransportProtocolRule(); @Rule public ShiroRule shiro = new ShiroRule(); final LfsBlobStoreFactory lfsBlobStoreFactory = mock(LfsBlobStoreFactory.class); + final RepositoryManager repositoryManager = mock(RepositoryManager.class); @BeforeClass public static void setSigner() { - GpgSigner.setDefault(new GitTestHelper.SimpleGpgSigner()); + Signers.set(GpgConfig.GpgFormat.OPENPGP, new GitTestHelper.SimpleGpgSigner()); } RevCommit getLastCommit(Git git) throws GitAPIException, IOException { @@ -58,11 +61,23 @@ class GitModifyCommandTestBase extends AbstractGitCommandTestBase { } GitModifyCommand createCommand() { + GitRepositoryHookEventFactory eventFactory = mock(GitRepositoryHookEventFactory.class); + RepositoryHookEvent preReceiveEvent = mockEvent(PRE_RECEIVE); + when(eventFactory.createPreReceiveEvent(any(), any(), any(), any())).thenReturn(preReceiveEvent); + RepositoryHookEvent postReceiveEvent = mockEvent(POST_RECEIVE); + when(eventFactory.createPostReceiveEvent(any(), any(), any(), any())).thenReturn(postReceiveEvent); return new GitModifyCommand( createContext(), - new SimpleGitWorkingCopyFactory(new NoneCachingWorkingCopyPool(new WorkdirProvider(null, repositoryLocationResolver)), new SimpleMeterRegistry()), lfsBlobStoreFactory, - createGitRepositoryConfigStoreProvider()); + repositoryManager, + eventFactory + ); + } + + private static RepositoryHookEvent mockEvent(RepositoryHookType type) { + RepositoryHookEvent mock = mock(RepositoryHookEvent.class); + when(mock.getType()).thenReturn(type); + return mock; } void assertInTree(TreeAssertions assertions) throws IOException, GitAPIException { diff --git a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitModifyCommand_LFSTest.java b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitModifyCommand_LFSTest.java index 6f662f1503..1ecbc0d681 100644 --- a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitModifyCommand_LFSTest.java +++ b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitModifyCommand_LFSTest.java @@ -68,6 +68,36 @@ public class GitModifyCommand_LFSTest extends GitModifyCommandTestBase { assertThat(outputStream).hasToString("new content"); } + @Test + public void shouldCreateCommitInSubdirectoryWithAttributesInSamePath() throws IOException, GitAPIException { + ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); + String newRef = createCommit("jpegs/new_lfs.jpg", "new content", "fe32608c9ef5b6cf7e3f946480253ff76f24f4ec0678f3d0f07f9844cbff9601", outputStream); + + try (Git git = new Git(createContext().open())) { + RevCommit lastCommit = getLastCommit(git); + assertThat(lastCommit.getFullMessage()).isEqualTo("test commit"); + assertThat(lastCommit.getAuthorIdent().getName()).isEqualTo("Dirk Gently"); + assertThat(newRef).isEqualTo(lastCommit.toObjectId().name()); + } + + assertThat(outputStream).hasToString("new content"); + } + + @Test + public void shouldCreateCommitInSubdirectoryWithAttributesInParentPath() throws IOException, GitAPIException { + ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); + String newRef = createCommit("jpegs/new_lfs.png", "new content", "fe32608c9ef5b6cf7e3f946480253ff76f24f4ec0678f3d0f07f9844cbff9601", outputStream); + + try (Git git = new Git(createContext().open())) { + RevCommit lastCommit = getLastCommit(git); + assertThat(lastCommit.getFullMessage()).isEqualTo("test commit"); + assertThat(lastCommit.getAuthorIdent().getName()).isEqualTo("Dirk Gently"); + assertThat(newRef).isEqualTo(lastCommit.toObjectId().name()); + } + + assertThat(outputStream).hasToString("new content"); + } + @Test public void shouldCreateSecondCommits() throws IOException, GitAPIException { createCommit("new_lfs.png", "new content", "fe32608c9ef5b6cf7e3f946480253ff76f24f4ec0678f3d0f07f9844cbff9601", new ByteArrayOutputStream()); diff --git a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitPostReceiveRepositoryHookEventFactoryTest.java b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitPostReceiveRepositoryHookEventFactoryTest.java index 3c6b6922af..0e3b381a10 100644 --- a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitPostReceiveRepositoryHookEventFactoryTest.java +++ b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitPostReceiveRepositoryHookEventFactoryTest.java @@ -60,7 +60,7 @@ public class GitPostReceiveRepositoryHookEventFactoryTest extends AbstractGitCom when(hookContext.getBranchProvider().getCreatedOrModified()).thenReturn(branches); when(hookContext.getTagProvider().getCreatedTags()).thenReturn(tags); - RepositoryHookEvent event = eventFactory.createEvent( + RepositoryHookEvent event = eventFactory.createPostReceiveEvent( createContext(), branches, tags, diff --git a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitTagCommandTest.java b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitTagCommandTest.java index 4a6a2df4f2..35d5cf2bde 100644 --- a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitTagCommandTest.java +++ b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitTagCommandTest.java @@ -22,8 +22,9 @@ import org.apache.shiro.subject.Subject; import org.apache.shiro.util.ThreadContext; import org.eclipse.jgit.api.Git; import org.eclipse.jgit.api.errors.GitAPIException; -import org.eclipse.jgit.lib.GpgSigner; +import org.eclipse.jgit.lib.GpgConfig; import org.eclipse.jgit.lib.ObjectId; +import org.eclipse.jgit.lib.Signers; import org.eclipse.jgit.revwalk.RevCommit; import org.eclipse.jgit.revwalk.RevWalk; import org.junit.After; @@ -81,7 +82,7 @@ public class GitTagCommandTest extends AbstractGitCommandTestBase { @Before public void setSigner() { - GpgSigner.setDefault(new GitTestHelper.SimpleGpgSigner()); + Signers.set(GpgConfig.GpgFormat.OPENPGP, new GitTestHelper.SimpleGpgSigner()); } @Before diff --git a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitUnbundleCommandTest.java b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitUnbundleCommandTest.java index d98268b0e6..d61b02204e 100644 --- a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitUnbundleCommandTest.java +++ b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitUnbundleCommandTest.java @@ -49,7 +49,7 @@ public class GitUnbundleCommandTest extends AbstractGitCommandTestBase { @Test public void shouldUnbundleRepositoryFiles() throws IOException { RepositoryHookEvent event = new RepositoryHookEvent(null, repository, RepositoryHookType.POST_RECEIVE); - when(eventFactory.createEvent(eq(createContext()), any(), any(), any())).thenReturn(event); + when(eventFactory.createPostReceiveEvent(eq(createContext()), any(), any(), any())).thenReturn(event); AtomicReference receivedEvent = new AtomicReference<>(); diff --git a/scm-plugins/scm-git-plugin/src/test/resources/sonia/scm/repository/spi/scm-git-spi-lfs-test.zip b/scm-plugins/scm-git-plugin/src/test/resources/sonia/scm/repository/spi/scm-git-spi-lfs-test.zip index b97f51968427060cc523a8b1594930e1cd487f9a..dc383f79d21f58fc0d87fc2b02655791edd32b82 100644 GIT binary patch delta 1880 zcmaEzzqf9J0Sk{yLi%Jzb=ir5@+>oT6VfM^`~=gJ3mJpql9QL|@ooOh$fXJv+03ZU z!^rZeaYE!|K@C}s@6HM7P*a2_PtahS?608(F@jMYB%p`PVPm-t(sy6OjpG?m9|%v@ z)ht0)!ZrDjrWZs&KUXM_|DDTP~LQK$zIQ!>DMe%`xK2giG5RCWg1p# zrl7>v@$Uiq;~B=D{;VQzcBOVZ-Mb|-sq1}Nv{p>^%;!S$4KCeSd3fVhr;jz!%kF0P zs;C}dLuec>I`p&`kd~B|l4zcmVv=Hznqp?2 zh(Fx4HNAX84a^fKCu(b(Tsy+RA+m`p=yJE@x!O59oh0#ii}>}(Nc z5Drr+#^M8$H0(YI0{LJ=$lqdPpzA>x*#|&PiK%8O$%dxDZfa;@WP#!XU|y1DKCR_3*;`XlFjU|JhHE7GQ}H-P!b)7w3g{3JMs`ec zs(zZGrD0NXnq^vIqDh*eiIJf}im730QgX6^X`-2tseyrIqKSbes$)E~^`$<vm zIAM8RRpoj8p`g95XD$_zVwiJL@cCqWGnvf>x=L)2Tp4Dp%>qiilRxUpPxd#Hn!L}r z4N_Qmn1rx^3yO~>@|*XYR53v`>^G5`9BwWG(3dxZ`Tcu&fN=W^66xC%w&V(rHVlZBdG&B1T`fi zJ7gcKR$|jNk_E^Pi7`Mp1eh>Uq6S=;qNZYGhb%s43uYy|1&LiV#!Z1J-LuE7$Up*CZmcfLg8dN_4ku2 zH3cTiY6!AU0_uPmaX_DAvcHBFV?5YE5U)~GhS$hMKQx4sff+>|i*JC7|K$4`ZX6(C z5T2~7Spu~Iq;sF99}8GmKL$tI2b_gVawXTLu2w59YuSP%`v$50|S^~J?JRN zV8Fq=;r|^5*1WQ7P4%4K9;+I6J)L%`NamWv69o~8up{5B;TH0fFIWjo?zQ5Z{K32p;{7U%kjc|5Z8vjUCNKe2%1!RH76Gc1oXl^^CJT2E q(0Q6bEXW`MBpDbOfq2Q}o7U1mt)e2VY#=dtAUw#$z#yUv;sF4RbesnO diff --git a/scm-plugins/scm-git-plugin/src/test/resources/sonia/scm/repository/spi/scm-git-spi-move-test.zip b/scm-plugins/scm-git-plugin/src/test/resources/sonia/scm/repository/spi/scm-git-spi-move-test.zip index 15ddfe1c137f6d6ee48f2e43b784154cb0443e2e..ec020a66778b476def898a76cab4fc64dfac372f 100644 GIT binary patch literal 55776 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