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:
René Pfeuffer
2021-10-19 17:31:35 +02:00
committed by GitHub
parent 9b6fd16047
commit 39e367bd70
6 changed files with 524 additions and 62 deletions

View File

@@ -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) {

View File

@@ -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).
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<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());
}
}
}
}

View File

@@ -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();