diff --git a/gradle/changelog/protect_default_branch.yaml b/gradle/changelog/protect_default_branch.yaml new file mode 100644 index 0000000000..9d60cae1a2 --- /dev/null +++ b/gradle/changelog/protect_default_branch.yaml @@ -0,0 +1,2 @@ +- type: Changed + description: The default branch of a repository cannot be deleted ([#1827](https://github.com/scm-manager/scm-manager/pull/1827)) 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 c765ae93d2..ff63829ffa 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 @@ -55,10 +55,14 @@ import java.util.Optional; import java.util.function.Function; import java.util.function.Supplier; +import static java.util.Arrays.asList; import static java.util.Arrays.stream; import static java.util.Optional.empty; import static java.util.Optional.of; import static java.util.stream.Collectors.toList; +import static org.eclipse.jgit.transport.RemoteRefUpdate.Status.NON_EXISTING; +import static org.eclipse.jgit.transport.RemoteRefUpdate.Status.OK; +import static org.eclipse.jgit.transport.RemoteRefUpdate.Status.UP_TO_DATE; import static sonia.scm.ContextEntry.ContextBuilder.entity; import static sonia.scm.NotFoundException.notFound; import static sonia.scm.repository.GitUtil.getBranchIdOrCurrentHead; @@ -75,6 +79,7 @@ class AbstractGitCommand { * the logger for AbstractGitCommand */ private static final Logger logger = LoggerFactory.getLogger(AbstractGitCommand.class); + private static final Collection ACCEPTED_UPDATE_STATUS = asList(OK, UP_TO_DATE, NON_EXISTING); /** * Constructs ... @@ -242,6 +247,7 @@ class AbstractGitCommand { } void push(String... refSpecs) { + logger.trace("Pushing mirror result to repository {} with refspec '{}'", repository, refSpecs); try { Iterable pushResults = clone @@ -260,10 +266,10 @@ class AbstractGitCommand { } remoteUpdates .stream() - .filter(remoteRefUpdate -> remoteRefUpdate.getStatus() != RemoteRefUpdate.Status.OK && remoteRefUpdate.getStatus() != RemoteRefUpdate.Status.UP_TO_DATE) + .filter(remoteRefUpdate -> !ACCEPTED_UPDATE_STATUS.contains(remoteRefUpdate.getStatus())) .findAny() .ifPresent(remoteRefUpdate -> { - logger.info("message for failed push: {}", pushResult.getMessages()); + logger.info("message for unexpected push result {} for remote {}: {}", remoteRefUpdate.getStatus(), remoteRefUpdate.getRemoteName(), pushResult.getMessages()); throw forMessage(repository, pushResult.getMessages()); }); } catch (GitAPIException e) { diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitMirrorCommand.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitMirrorCommand.java index fe115f39a9..0575c0b1f6 100644 --- a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitMirrorCommand.java +++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitMirrorCommand.java @@ -46,10 +46,12 @@ import org.eclipse.jgit.transport.TransportHttp; import org.eclipse.jgit.transport.UsernamePasswordCredentialsProvider; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import sonia.scm.api.v2.resources.GitRepositoryConfigStoreProvider; import sonia.scm.repository.Changeset; import sonia.scm.repository.GitChangesetConverter; import sonia.scm.repository.GitChangesetConverterFactory; import sonia.scm.repository.GitHeadModifier; +import sonia.scm.repository.GitRepositoryConfig; import sonia.scm.repository.GitWorkingCopyFactory; import sonia.scm.repository.InternalRepositoryException; import sonia.scm.repository.Tag; @@ -58,24 +60,27 @@ import sonia.scm.repository.api.MirrorCommandResult.ResultType; import sonia.scm.repository.api.MirrorFilter; import sonia.scm.repository.api.MirrorFilter.Result; import sonia.scm.repository.api.UsernamePasswordCredential; +import sonia.scm.store.ConfigurationStore; import javax.inject.Inject; import java.io.IOException; import java.util.ArrayList; import java.util.Collection; import java.util.HashMap; +import java.util.HashSet; import java.util.List; import java.util.Locale; import java.util.Map; import java.util.Optional; +import java.util.Set; import java.util.function.Function; import static java.lang.String.format; +import static java.util.Collections.emptyList; import static java.util.Collections.unmodifiableMap; import static java.util.Optional.empty; import static java.util.Optional.of; import static org.eclipse.jgit.lib.RefUpdate.Result.NEW; -import static org.eclipse.jgit.lib.RefUpdate.Result.REJECTED_CURRENT_BRANCH; import static sonia.scm.repository.api.MirrorCommandResult.ResultType.FAILED; import static sonia.scm.repository.api.MirrorCommandResult.ResultType.OK; import static sonia.scm.repository.api.MirrorCommandResult.ResultType.REJECTED_UPDATES; @@ -102,15 +107,23 @@ public class GitMirrorCommand extends AbstractGitCommand implements MirrorComman private final GitTagConverter gitTagConverter; private final GitWorkingCopyFactory workingCopyFactory; private final GitHeadModifier gitHeadModifier; + private final GitRepositoryConfigStoreProvider storeProvider; @Inject - GitMirrorCommand(GitContext context, MirrorHttpConnectionProvider mirrorHttpConnectionProvider, GitChangesetConverterFactory converterFactory, GitTagConverter gitTagConverter, GitWorkingCopyFactory workingCopyFactory, GitHeadModifier gitHeadModifier) { + GitMirrorCommand(GitContext context, + MirrorHttpConnectionProvider mirrorHttpConnectionProvider, + GitChangesetConverterFactory converterFactory, + GitTagConverter gitTagConverter, + GitWorkingCopyFactory workingCopyFactory, + GitHeadModifier gitHeadModifier, + GitRepositoryConfigStoreProvider storeProvider) { super(context); this.mirrorHttpConnectionProvider = mirrorHttpConnectionProvider; this.converterFactory = converterFactory; this.gitTagConverter = gitTagConverter; this.workingCopyFactory = workingCopyFactory; this.gitHeadModifier = gitHeadModifier; + this.storeProvider = storeProvider; } @Override @@ -145,6 +158,8 @@ public class GitMirrorCommand extends AbstractGitCommand implements MirrorComman private final List mirrorLog = new ArrayList<>(); private final Stopwatch stopwatch; + private final DefaultBranchSelector defaultBranchSelector; + private final Git git; private final Collection deletedRefs = new ArrayList<>(); @@ -155,28 +170,12 @@ public class GitMirrorCommand extends AbstractGitCommand implements MirrorComman private ResultType result = OK; - /** - * On the first synchronization, the clone has the implicit branch "master". This cannot be - * changed in JGit. When we fetch the refs from the repository that should be mirrored, the - * master branch of the clone will be updated to the revision of the remote repository (if - * it has a master branch). If now the master branch shall be filtered from mirroring (ie. - * if it is rejected), we normally would delete the ref in this clone. But because it is - * the current branch, it cannot be deleted. We detect this, set this variable to - * {@code true}, and later, after we have pushed the result, delete the master branch by - * pushing an empty ref to the central repository. - */ - private boolean deleteMasterAfterInitialSync = false; - /** - * We store a branch that has not been rejected here, so we can easily correct the HEAD reference - * afterwards (see #setHeadIfMirroredBranchExists) - */ - private String acceptedBranch; - private Worker(GitContext context, MirrorCommandRequest mirrorCommandRequest, sonia.scm.repository.Repository repository, Git git) { super(git, context, repository); this.mirrorCommandRequest = mirrorCommandRequest; this.git = git; stopwatch = Stopwatch.createStarted(); + defaultBranchSelector = new DefaultBranchSelector(git); } MirrorCommandResult run() { @@ -197,46 +196,41 @@ public class GitMirrorCommand extends AbstractGitCommand implements MirrorComman filter = mirrorCommandRequest.getFilter().getFilter(filterContext); if (fetchResult.getTrackingRefUpdates().isEmpty()) { + LOG.trace("No updates found for mirror repository {}", repository); mirrorLog.add("No updates found"); } else { handleBranches(); handleTags(); } - push(generatePushRefSpecs().toArray(new String[0])); - setHeadIfMirroredBranchExists(); - cleanUpMasterIfNecessary(); + defaultBranchSelector.newDefault().ifPresent(this::setNewDefaultBranch); + + String[] pushRefSpecs = generatePushRefSpecs().toArray(new String[0]); + push(pushRefSpecs); return new MirrorCommandResult(result, mirrorLog, stopwatch.stop().elapsed()); } - private void setHeadIfMirroredBranchExists() { - if (acceptedBranch != null) { - // Ensures that HEAD is set to an existing mirrored branch in the working copy (if this is set to a branch that - // should not have been mirrored, this branch cannot be deleted otherwise; see #cleanupMasterIfNecessary) and - // in the "real" mirror repository (here a HEAD with a not existing branch will lead to errors in the next clone - // call). - try { - RefUpdate refUpdate = git.getRepository().getRefDatabase().newUpdate(Constants.HEAD, true); - refUpdate.setForceUpdate(true); - refUpdate.link(acceptedBranch); - } catch (IOException e) { - throw new InternalRepositoryException(getRepository(), "Error while setting HEAD", e); - } - gitHeadModifier.ensure(repository, acceptedBranch.substring("refs/heads/".length())); - } - } + private void setNewDefaultBranch(String newDefaultBranch) { + mirrorLog.add("Old default branch deleted. Setting default branch to '" + newDefaultBranch + "'."); - private void cleanUpMasterIfNecessary() { - if (deleteMasterAfterInitialSync) { - try { - // we have to delete the master branch in the working copy, because otherwise it may be pushed - // to the mirror in the next synchronization call, when the working directory is cached. - git.branchDelete().setBranchNames("master").setForce(true).call(); - } catch (GitAPIException e) { - LOG.error("Could not delete master branch in mirror repository {}", getRepository().getNamespaceAndName(), e); + try { + String oldBranch = git.getRepository().getBranch(); + RefUpdate refUpdate = git.getRepository().getRefDatabase().newUpdate(Constants.HEAD, true); + refUpdate.setForceUpdate(true); + RefUpdate.Result result = refUpdate.link(Constants.R_HEADS + newDefaultBranch); + if (result != RefUpdate.Result.FORCED) { + throw new InternalRepositoryException(getRepository(), "Could not set HEAD to new default branch"); } - push(":refs/heads/master"); + git.branchDelete().setBranchNames(oldBranch).setForce(true).call(); + } catch (GitAPIException | IOException e) { + throw new InternalRepositoryException(getRepository(), "Error while switching branch to change default branch", e); } + + gitHeadModifier.ensure(repository, newDefaultBranch); + ConfigurationStore configStore = storeProvider.get(repository); + GitRepositoryConfig gitRepositoryConfig = configStore.get(); + gitRepositoryConfig.setDefaultBranch(newDefaultBranch); + configStore.set(gitRepositoryConfig); } private Collection generatePushRefSpecs() { @@ -248,6 +242,7 @@ public class GitMirrorCommand extends AbstractGitCommand implements MirrorComman } private void copyRemoteRefsToMain() { + LOG.trace("Copy remote refs to main"); try { RefDatabase refDatabase = git.getRepository().getRefDatabase(); refDatabase.getRefs() @@ -256,6 +251,7 @@ public class GitMirrorCommand extends AbstractGitCommand implements MirrorComman .forEach( ref -> { try { + LOG.trace("Copying reference {}", ref); String baseName = ref.getName().substring("refs/remotes/origin/".length()); RefUpdate refUpdate = refDatabase.newUpdate("refs/heads/" + baseName, true); refUpdate.setNewObjectId(ref.getObjectId()); @@ -304,12 +300,15 @@ public class GitMirrorCommand extends AbstractGitCommand implements MirrorComman } private void handleRef(Function filter) { + LOG.trace("Handling {}", ref.getLocalName()); Result filterResult = filter.apply(ref); try { String referenceName = ref.getLocalName().substring("refs/".length() + refType.length()); if (filterResult.isAccepted()) { + LOG.trace("Accepted ref {}", ref.getLocalName()); handleAcceptedReference(referenceName); } else { + LOG.trace("Rejected ref {}", ref.getLocalName()); handleRejectedRef(referenceName, filterResult); } } catch (Exception e) { @@ -319,11 +318,7 @@ public class GitMirrorCommand extends AbstractGitCommand implements MirrorComman private Result testFilterForBranch() { try { - Result filterResult = filter.acceptBranch(filterContext.getBranchUpdate(ref.getLocalName())); - if (filterResult.isAccepted()) { - acceptedBranch = ref.getLocalName(); - } - return filterResult; + return filter.acceptBranch(filterContext.getBranchUpdate(ref.getLocalName())); } catch (Exception e) { return handleExceptionFromFilter(e); } @@ -346,14 +341,17 @@ public class GitMirrorCommand extends AbstractGitCommand implements MirrorComman logger.logChange(ref, referenceName, filterResult.getRejectReason().orElse("rejected due to filter")); } - private void handleAcceptedReference(String referenceName) { + private void handleAcceptedReference(String referenceName) throws IOException { String targetRef = "refs/" + refType + referenceName; if (isDeletedReference(ref)) { LOG.trace("deleting {} ref in {}: {}", typeForLog, GitMirrorCommand.this.repository, targetRef); + defaultBranchSelector.deleted(referenceName); logger.logChange(ref, referenceName, "deleted"); + deleteReference(targetRef); deletedRefs.add(targetRef); } else { LOG.trace("updating {} ref in {}: {}", typeForLog, GitMirrorCommand.this.repository, targetRef); + defaultBranchSelector.accepted(referenceName); logger.logChange(ref, referenceName, getUpdateType(ref)); } } @@ -375,10 +373,7 @@ public class GitMirrorCommand extends AbstractGitCommand implements MirrorComman private void deleteReference(String targetRef) throws IOException { RefUpdate deleteUpdate = git.getRepository().getRefDatabase().newUpdate(targetRef, true); deleteUpdate.setForceUpdate(true); - RefUpdate.Result deleteResult = deleteUpdate.delete(); - if (deleteResult == REJECTED_CURRENT_BRANCH) { - deleteMasterAfterInitialSync = true; - } + deleteUpdate.delete(); } private boolean isDeletedReference(TrackingRefUpdate ref) { @@ -663,4 +658,73 @@ public class GitMirrorCommand extends AbstractGitCommand implements MirrorComman private interface RefUpdateConsumer { void accept(TrackingRefUpdate refUpdate) throws IOException; } + + static class DefaultBranchSelector { + private final String initialDefaultBranch; + private final Set initialBranches; + private final Set remainingBranches; + private final Set newBranches = new HashSet<>(); + + DefaultBranchSelector(String initialDefaultBranch, Collection initialBranches) { + this.initialDefaultBranch = initialBranches.isEmpty() ? null : initialDefaultBranch; + this.initialBranches = new HashSet<>(initialBranches); + this.remainingBranches = new HashSet<>(initialBranches); + } + + public DefaultBranchSelector(Git git) { + this(getInitialDefaultBranch(git), getBranches(git)); + } + + private static Collection getBranches(Git git) { + Set allBranches = new HashSet<>(); + try { + git.getRepository() + .getRefDatabase() + .getRefsByPrefix("refs/heads") + .stream() + .map(Ref::getName) + .map(ref -> ref.substring("refs/heads/".length())) + .forEach(allBranches::add); + git.getRepository() + .getRefDatabase() + .getRefsByPrefix("refs/remotes/origin") + .stream() + .map(Ref::getName) + .map(ref -> ref.substring("refs/remotes/origin/".length())) + .forEach(allBranches::add); + } catch (IOException e) { + throw new InternalRepositoryException(emptyList(), "Could not read existing branches for working copy of mirror", e); + } + return allBranches; + } + + private static String getInitialDefaultBranch(Git git) { + try { + return git.getRepository().getBranch(); + } catch (IOException e) { + throw new InternalRepositoryException(emptyList(), "Could not read current branch for working copy of mirror", e); + } + } + + public void accepted(String branch) { + newBranches.add(branch); + } + + public void deleted(String branch) { + remainingBranches.remove(branch); + } + + public Optional newDefault() { + if (initialDefaultBranch == null && newBranches.contains("master") || remainingBranches.contains(initialDefaultBranch)) { + return empty(); + } else if (!newBranches.isEmpty() && initialBranches.isEmpty()) { + return of(newBranches.iterator().next()); + } else if (remainingBranches.isEmpty()) { + LOG.warn("Could not compute new default branch."); + throw new IllegalStateException("Deleting all existing branches is not supported. Please restore branch '" + initialDefaultBranch + "' or recreate the mirror."); + } else { + return of(remainingBranches.iterator().next()); + } + } + } } diff --git a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitMirrorCommandTest.java b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitMirrorCommandTest.java index 5aa640dac7..0e5fa6977a 100644 --- a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitMirrorCommandTest.java +++ b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitMirrorCommandTest.java @@ -47,6 +47,7 @@ import sonia.scm.net.HttpURLConnectionFactory; import sonia.scm.repository.GitChangesetConverterFactory; import sonia.scm.repository.GitConfig; import sonia.scm.repository.GitHeadModifier; +import sonia.scm.repository.GitRepositoryConfig; import sonia.scm.repository.GitUtil; import sonia.scm.repository.api.MirrorCommandResult; import sonia.scm.repository.api.MirrorFilter; @@ -55,6 +56,7 @@ import sonia.scm.repository.work.NoneCachingWorkingCopyPool; import sonia.scm.repository.work.SimpleWorkingCopyFactory; import sonia.scm.repository.work.WorkdirProvider; import sonia.scm.security.GPG; +import sonia.scm.store.ConfigurationStore; import sonia.scm.store.InMemoryConfigurationStoreFactory; import sonia.scm.util.IOUtil; @@ -66,10 +68,17 @@ import java.util.List; import java.util.Optional; import java.util.function.Consumer; +import static java.util.Arrays.asList; +import static java.util.Collections.emptyList; import static java.util.Collections.singletonList; import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.Assert.assertThrows; +import static org.mockito.AdditionalMatchers.not; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; import static sonia.scm.repository.api.MirrorCommandResult.ResultType.FAILED; import static sonia.scm.repository.api.MirrorCommandResult.ResultType.OK; import static sonia.scm.repository.api.MirrorCommandResult.ResultType.REJECTED_UPDATES; @@ -92,6 +101,10 @@ public class GitMirrorCommandTest extends AbstractGitCommandTestBase { private final GitHeadModifier gitHeadModifier = mock(GitHeadModifier.class); + private final GitRepositoryConfigStoreProvider storeProvider = mock(GitRepositoryConfigStoreProvider.class); + private final ConfigurationStore configurationStore = mock(ConfigurationStore.class); + private final GitRepositoryConfig gitRepositoryConfig = new GitRepositoryConfig(); + @Before public void bendContextToNewRepository() throws IOException, GitAPIException { clone = tempFolder.newFolder(); @@ -117,7 +130,14 @@ public class GitMirrorCommandTest extends AbstractGitCommandTestBase { gitChangesetConverterFactory, gitTagConverter, workingCopyFactory, - gitHeadModifier); + gitHeadModifier, + storeProvider); + } + + @Before + public void initializeStore() { + when(storeProvider.get(repository)).thenReturn(configurationStore); + when(configurationStore.get()).thenReturn(gitRepositoryConfig); } @After @@ -173,9 +193,10 @@ public class GitMirrorCommandTest extends AbstractGitCommandTestBase { .isEmpty(); } try (Repository workdirRepository = GitUtil.open(workdirAfterClose)) { - assertThat(workdirRepository.findRef(Constants.HEAD).getTarget().getName()).isEqualTo("refs/heads/test-branch"); + assertThat(workdirRepository.findRef(Constants.HEAD).getTarget().getName()).isNotEqualTo("refs/heads/master"); } - verify(gitHeadModifier).ensure(repository, "test-branch"); + verify(gitHeadModifier) + .ensure(eq(repository), not(eq("master"))); } @Test @@ -725,6 +746,124 @@ public class GitMirrorCommandTest extends AbstractGitCommandTestBase { }); } + @Test + public void shouldSelectNewHeadIfOldHeadIsDeleted() throws IOException, GitAPIException { + callMirrorCommand(); + + try (Git updatedSource = Git.open(repositoryDirectory)) { + updatedSource.checkout().setName("test-branch").call(); + updatedSource.branchDelete().setBranchNames("master").setForce(true).call(); + } + + List collectedBranchUpdates = callMirrorAndCollectUpdates().branchUpdates; + + assertThat(collectedBranchUpdates) + .anySatisfy(update -> { + assertThat(update.getBranchName()).isEqualTo("master"); + assertThat(update.getNewRevision()).isEmpty(); + }); + verify(configurationStore).set(argThat(argument -> { + assertThat(argument.getDefaultBranch()).isNotEqualTo("master"); + return true; + })); + } + + public static class DefaultBranchSelectorTest { + + public static final List BRANCHES = asList("master", "one", "two", "three"); + + @Test + public void shouldKeepMasterIfMirroredInFirstSync() { + GitMirrorCommand.DefaultBranchSelector selector = + new GitMirrorCommand.DefaultBranchSelector("master", emptyList()); + + selector.accepted("master"); + selector.accepted("something"); + + assertThat(selector.newDefault()).isEmpty(); + } + + @Test + public void shouldKeepDefaultIfNotDeleted() { + GitMirrorCommand.DefaultBranchSelector selector = + new GitMirrorCommand.DefaultBranchSelector("master", BRANCHES); + + selector.accepted("new"); + selector.deleted("two"); + + assertThat(selector.newDefault()).isEmpty(); + } + + @Test + public void shouldChangeDefaultIfInitialOneIsDeleted() { + GitMirrorCommand.DefaultBranchSelector selector = + new GitMirrorCommand.DefaultBranchSelector("master", BRANCHES); + + selector.deleted("master"); + + assertThat(selector.newDefault()).get().isIn("one", "two", "three"); + } + + @Test + public void shouldChangeDefaultIfInitialOneIsDeletedButNotFromDeleted() { + GitMirrorCommand.DefaultBranchSelector selector = + new GitMirrorCommand.DefaultBranchSelector("master", BRANCHES); + + selector.deleted("master"); + selector.deleted("one"); + selector.deleted("three"); + + assertThat(selector.newDefault()).get().isEqualTo("two"); + } + + @Test + public void shouldChangeDefaultToRemainingBranchIfInitialOneIsDeleted() { + GitMirrorCommand.DefaultBranchSelector selector = + new GitMirrorCommand.DefaultBranchSelector("master", BRANCHES); + + selector.deleted("master"); + selector.deleted("one"); + selector.deleted("three"); + + assertThat(selector.newDefault()).get().isEqualTo("two"); + } + + @Test + public void shouldFailIfAllInitialBranchesAreDeleted() { + GitMirrorCommand.DefaultBranchSelector selector = + new GitMirrorCommand.DefaultBranchSelector("master", BRANCHES); + + selector.deleted("master"); + selector.deleted("one"); + selector.deleted("two"); + selector.accepted("new"); + selector.deleted("three"); + + assertThrows(IllegalStateException.class, selector::newDefault); + } + + @Test + public void shouldChangeDefaultOnInitialSyncIfMasterIsRejected() { + GitMirrorCommand.DefaultBranchSelector selector = + new GitMirrorCommand.DefaultBranchSelector("master", emptyList()); + + selector.accepted("main"); + selector.deleted("master"); + + assertThat(selector.newDefault()).get().isEqualTo("main"); + } + + @Test + public void shouldChangeDefaultOnInitialSyncIfMasterIsNotAvailable() { + GitMirrorCommand.DefaultBranchSelector selector = + new GitMirrorCommand.DefaultBranchSelector("master", emptyList()); + + selector.accepted("main"); + + assertThat(selector.newDefault()).get().isEqualTo("main"); + } + } + private Updates callMirrorAndCollectUpdates() { Updates updates = new Updates(); @@ -740,6 +879,7 @@ public class GitMirrorCommandTest extends AbstractGitCommandTestBase { updates.tagUpdates.add(tagUpdate); return Result.accept(); } + @Override public Result acceptBranch(BranchUpdate branchUpdate) { branchUpdate.getChangeset(); diff --git a/scm-webapp/src/main/java/sonia/scm/repository/DefaultBranchDeleteProtection.java b/scm-webapp/src/main/java/sonia/scm/repository/DefaultBranchDeleteProtection.java new file mode 100644 index 0000000000..1356b8fcb5 --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/repository/DefaultBranchDeleteProtection.java @@ -0,0 +1,92 @@ +/* + * MIT License + * + * Copyright (c) 2020-present Cloudogu GmbH and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package sonia.scm.repository; + +import com.github.legman.Subscribe; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import sonia.scm.EagerSingleton; +import sonia.scm.plugin.Extension; +import sonia.scm.repository.api.HookFeature; +import sonia.scm.repository.api.RepositoryService; +import sonia.scm.repository.api.RepositoryServiceFactory; +import sonia.scm.repository.spi.CannotDeleteDefaultBranchException; + +import javax.inject.Inject; +import java.io.IOException; +import java.util.List; + +@Extension +@EagerSingleton +public class DefaultBranchDeleteProtection { + + private static final Logger LOG = LoggerFactory.getLogger(DefaultBranchDeleteProtection.class); + + private final RepositoryServiceFactory serviceFactory; + + @Inject + public DefaultBranchDeleteProtection(RepositoryServiceFactory serviceFactory) { + this.serviceFactory = serviceFactory; + } + + @Subscribe(async = false) + public void protectDefaultBranch(PreReceiveRepositoryHookEvent event) { + if (event.getContext().isFeatureSupported(HookFeature.BRANCH_PROVIDER)) { + List deletedOrClosed = event.getContext().getBranchProvider().getDeletedOrClosed(); + if (!deletedOrClosed.isEmpty()) { + checkDeletedBranches(event, deletedOrClosed); + } + } + } + + private void checkDeletedBranches(PreReceiveRepositoryHookEvent event, List deletedOrClosed) { + try (RepositoryService service = serviceFactory.create(event.getRepository())) { + getBranches(service) + .getBranches() + .stream() + .filter(Branch::isDefaultBranch) + .findFirst() + .ifPresent( + defaultBranch -> assertBranchNotDeleted(event, deletedOrClosed, defaultBranch) + ); + } + } + + private Branches getBranches(RepositoryService service) { + try { + return service.getBranchesCommand().setDisableCache(true).getBranches(); + } catch (IOException e) { + LOG.warn("Could not read branches in repository {} to check for default branch", service.getRepository()); + return new Branches(); + } + } + + private void assertBranchNotDeleted(PreReceiveRepositoryHookEvent event, List deletedOrClosed, Branch defaultBranch) { + String defaultBranchName = defaultBranch.getName(); + if (deletedOrClosed.stream().anyMatch(branch -> branch.equals(defaultBranchName))) { + throw new CannotDeleteDefaultBranchException(event.getRepository(), defaultBranchName); + } + } +} diff --git a/scm-webapp/src/test/java/sonia/scm/repository/DefaultBranchDeleteProtectionTest.java b/scm-webapp/src/test/java/sonia/scm/repository/DefaultBranchDeleteProtectionTest.java new file mode 100644 index 0000000000..c381207cfb --- /dev/null +++ b/scm-webapp/src/test/java/sonia/scm/repository/DefaultBranchDeleteProtectionTest.java @@ -0,0 +1,158 @@ +/* + * MIT License + * + * Copyright (c) 2020-present Cloudogu GmbH and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package sonia.scm.repository; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Answers; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import sonia.scm.repository.api.BranchesCommandBuilder; +import sonia.scm.repository.api.HookBranchProvider; +import sonia.scm.repository.api.HookContext; +import sonia.scm.repository.api.HookFeature; +import sonia.scm.repository.api.RepositoryService; +import sonia.scm.repository.api.RepositoryServiceFactory; +import sonia.scm.repository.spi.CannotDeleteDefaultBranchException; + +import java.io.IOException; +import java.util.List; + +import static java.util.Arrays.asList; +import static java.util.Collections.emptyList; +import static java.util.Collections.singletonList; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static sonia.scm.repository.RepositoryHookType.PRE_RECEIVE; + +@ExtendWith(MockitoExtension.class) +class DefaultBranchDeleteProtectionTest { + + private static final Repository REPOSITORY = RepositoryTestData.createHeartOfGold(); + + @Mock + private RepositoryServiceFactory serviceFactory; + @Mock + private HookContext context; + @Mock + private HookBranchProvider branchProvider; + + @InjectMocks + private DefaultBranchDeleteProtection defaultBranchDeleteProtection; + + @Test + void shouldDoNothingWithoutBranchProvider() { + when(context.isFeatureSupported(HookFeature.BRANCH_PROVIDER)).thenReturn(false); + + PreReceiveRepositoryHookEvent event = new PreReceiveRepositoryHookEvent(new RepositoryHookEvent(context, REPOSITORY, PRE_RECEIVE)); + defaultBranchDeleteProtection.protectDefaultBranch(event); + + verify(context, never()).getBranchProvider(); + } + + @Test + void shouldDoNothingWithoutDeletedBranch() { + mockDeletedBranches(emptyList()); + + PreReceiveRepositoryHookEvent event = new PreReceiveRepositoryHookEvent(new RepositoryHookEvent(context, REPOSITORY, PRE_RECEIVE)); + defaultBranchDeleteProtection.protectDefaultBranch(event); + + verify(serviceFactory, never()).create(any(Repository.class)); + } + + @Nested + class WithService { + + @Mock + private RepositoryService service; + @Mock(answer = Answers.RETURNS_SELF) + private BranchesCommandBuilder branchesCommand; + + @BeforeEach + void initRepositoryService() { + when(serviceFactory.create(REPOSITORY)).thenReturn(service); + when(service.getBranchesCommand()).thenReturn(branchesCommand); + } + + @Test + @SuppressWarnings("java:S2699") + // we just need to make sure not exception is thrown + void shouldDoNothingWithoutDefaultBranch() throws IOException { + mockDeletedBranches(singletonList("anything")); + mockExistingBranches(branch("there"), branch("is"), branch("no"), branch("default")); + + PreReceiveRepositoryHookEvent event = new PreReceiveRepositoryHookEvent(new RepositoryHookEvent(context, REPOSITORY, PRE_RECEIVE)); + defaultBranchDeleteProtection.protectDefaultBranch(event); + } + + @Test + void shouldDoNothingIfDefaultBranchIsNotDeleted() throws IOException { + mockDeletedBranches(singletonList("anything")); + mockExistingBranches(branch("anything"), defaultBranch()); + + PreReceiveRepositoryHookEvent event = new PreReceiveRepositoryHookEvent(new RepositoryHookEvent(context, REPOSITORY, PRE_RECEIVE)); + defaultBranchDeleteProtection.protectDefaultBranch(event); + + verify(branchesCommand).setDisableCache(true); + } + + @Test + void shouldPreventDefaultBranchFromDeletion() throws IOException { + mockDeletedBranches(asList("anything", "default")); + mockExistingBranches(branch("anything"), defaultBranch()); + + PreReceiveRepositoryHookEvent event = new PreReceiveRepositoryHookEvent(new RepositoryHookEvent(context, REPOSITORY, PRE_RECEIVE)); + assertThrows(CannotDeleteDefaultBranchException.class, () -> defaultBranchDeleteProtection.protectDefaultBranch(event)); + } + + private void mockExistingBranches(Branch... branches) throws IOException { + when(branchesCommand.getBranches()).thenReturn(new Branches(branches)); + } + + private Branch defaultBranch() { + return branch("default", true); + } + + private Branch branch(String name) { + return branch(name, false); + } + + private Branch branch(String name, boolean defaultBranch) { + return new Branch(name, "1", defaultBranch, 1L); + } + } + + private void mockDeletedBranches(List anything) { + when(context.isFeatureSupported(HookFeature.BRANCH_PROVIDER)).thenReturn(true); + when(context.getBranchProvider()).thenReturn(branchProvider); + when(branchProvider.getDeletedOrClosed()).thenReturn(anything); + } +}