mirror of
https://github.com/scm-manager/scm-manager.git
synced 2025-11-10 15:35:49 +01:00
Prevent deletion of default branch (#1827)
Adds a pre receive repository hook that prevents the deletion of the default branch. Mirrored repositories will change their default branches to another branch, when it is deleted.
This commit is contained in:
2
gradle/changelog/protect_default_branch.yaml
Normal file
2
gradle/changelog/protect_default_branch.yaml
Normal file
@@ -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))
|
||||
@@ -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<RemoteRefUpdate.Status> 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<PushResult> 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) {
|
||||
|
||||
@@ -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<String> mirrorLog = new ArrayList<>();
|
||||
private final Stopwatch stopwatch;
|
||||
|
||||
private final DefaultBranchSelector defaultBranchSelector;
|
||||
|
||||
private final Git git;
|
||||
|
||||
private final Collection<String> 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).
|
||||
private void setNewDefaultBranch(String newDefaultBranch) {
|
||||
mirrorLog.add("Old default branch deleted. Setting default branch to '" + newDefaultBranch + "'.");
|
||||
|
||||
try {
|
||||
String oldBranch = git.getRepository().getBranch();
|
||||
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()));
|
||||
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");
|
||||
}
|
||||
git.branchDelete().setBranchNames(oldBranch).setForce(true).call();
|
||||
} catch (GitAPIException | IOException e) {
|
||||
throw new InternalRepositoryException(getRepository(), "Error while switching branch to change default branch", e);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
push(":refs/heads/master");
|
||||
}
|
||||
gitHeadModifier.ensure(repository, newDefaultBranch);
|
||||
ConfigurationStore<GitRepositoryConfig> configStore = storeProvider.get(repository);
|
||||
GitRepositoryConfig gitRepositoryConfig = configStore.get();
|
||||
gitRepositoryConfig.setDefaultBranch(newDefaultBranch);
|
||||
configStore.set(gitRepositoryConfig);
|
||||
}
|
||||
|
||||
private Collection<String> 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<TrackingRefUpdate, Result> 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<String> initialBranches;
|
||||
private final Set<String> remainingBranches;
|
||||
private final Set<String> newBranches = new HashSet<>();
|
||||
|
||||
DefaultBranchSelector(String initialDefaultBranch, Collection<String> 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<String> getBranches(Git git) {
|
||||
Set<String> 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<String> 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());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<GitRepositoryConfig> 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<MirrorFilter.BranchUpdate> 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<String> 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();
|
||||
|
||||
@@ -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<String> deletedOrClosed = event.getContext().getBranchProvider().getDeletedOrClosed();
|
||||
if (!deletedOrClosed.isEmpty()) {
|
||||
checkDeletedBranches(event, deletedOrClosed);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void checkDeletedBranches(PreReceiveRepositoryHookEvent event, List<String> 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<String> deletedOrClosed, Branch defaultBranch) {
|
||||
String defaultBranchName = defaultBranch.getName();
|
||||
if (deletedOrClosed.stream().anyMatch(branch -> branch.equals(defaultBranchName))) {
|
||||
throw new CannotDeleteDefaultBranchException(event.getRepository(), defaultBranchName);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<String> anything) {
|
||||
when(context.isFeatureSupported(HookFeature.BRANCH_PROVIDER)).thenReturn(true);
|
||||
when(context.getBranchProvider()).thenReturn(branchProvider);
|
||||
when(branchProvider.getDeletedOrClosed()).thenReturn(anything);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user