diff --git a/scm-core/src/main/java/sonia/scm/repository/api/ModifyCommandBuilder.java b/scm-core/src/main/java/sonia/scm/repository/api/ModifyCommandBuilder.java
index 28adca18ae..5baa7dcd26 100644
--- a/scm-core/src/main/java/sonia/scm/repository/api/ModifyCommandBuilder.java
+++ b/scm-core/src/main/java/sonia/scm/repository/api/ModifyCommandBuilder.java
@@ -16,12 +16,14 @@ import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
+import java.util.function.BiConsumer;
import java.util.function.Consumer;
/**
* Use this {@link ModifyCommandBuilder} to make file changes to the head of a branch. You can
*
- * - create new files ({@link #createFile(String)}
+ * - create new files ({@link #createFile(String)} (with the option to overwrite a file, if it already exists; by
+ * default a {@link sonia.scm.AlreadyExistsException} will be thrown)
* - modify existing files ({@link #modifyFile(String)}
* - delete existing files ({@link #deleteFile(String)}
* - move/rename existing files ({@link #moveFile(String, String)}
@@ -67,12 +69,15 @@ public class ModifyCommandBuilder {
/**
* Create a new file. The content of the file will be specified in a subsequent call to
* {@link ContentLoader#withData(ByteSource)} or {@link ContentLoader#withData(InputStream)}.
+ * By default, an {@link sonia.scm.AlreadyExistsException} will be thrown, when there already
+ * exists a file with the given path. You can disable this setting
+ * {@link WithOverwriteFlagContentLoader#setOverwrite(boolean)} to true.
* @param path The path and the name of the file that should be created.
* @return The loader to specify the content of the new file.
*/
- public ContentLoader createFile(String path) {
- return new ContentLoader(
- content -> request.addRequest(new ModifyCommandRequest.CreateFileRequest(path, content))
+ public WithOverwriteFlagContentLoader createFile(String path) {
+ return new WithOverwriteFlagContentLoader(
+ (content, overwrite) -> request.addRequest(new ModifyCommandRequest.CreateFileRequest(path, content, overwrite))
);
}
@@ -82,8 +87,8 @@ public class ModifyCommandBuilder {
* @param path The path and the name of the file that should be modified.
* @return The loader to specify the new content of the file.
*/
- public ContentLoader modifyFile(String path) {
- return new ContentLoader(
+ public SimpleContentLoader modifyFile(String path) {
+ return new SimpleContentLoader(
content -> request.addRequest(new ModifyCommandRequest.ModifyFileRequest(path, content))
);
}
@@ -115,7 +120,7 @@ public class ModifyCommandBuilder {
*/
public String execute() {
try {
- Preconditions.checkArgument(request.isValid(), "commit message, author and branch are required");
+ Preconditions.checkArgument(request.isValid(), "commit message, branch and at least one request are required");
return command.execute(request);
} finally {
try {
@@ -153,30 +158,39 @@ public class ModifyCommandBuilder {
return this;
}
- public class ContentLoader {
-
- private final Consumer contentConsumer;
-
- private ContentLoader(Consumer contentConsumer) {
- this.contentConsumer = contentConsumer;
- }
-
+ public interface ContentLoader {
/**
* Specify the data of the file using a {@link ByteSource}.
+ *
* @return The builder instance.
* @throws IOException If the data could not be read.
*/
- public ModifyCommandBuilder withData(ByteSource data) throws IOException {
- File content = loadData(data);
- contentConsumer.accept(content);
- return ModifyCommandBuilder.this;
- }
+ ModifyCommandBuilder withData(ByteSource data) throws IOException;
/**
* Specify the data of the file using an {@link InputStream}.
* @return The builder instance.
* @throws IOException If the data could not be read.
*/
+ ModifyCommandBuilder withData(InputStream data) throws IOException;
+ }
+
+ public class SimpleContentLoader implements ContentLoader {
+
+ private final Consumer contentConsumer;
+
+ private SimpleContentLoader(Consumer contentConsumer) {
+ this.contentConsumer = contentConsumer;
+ }
+
+ @Override
+ public ModifyCommandBuilder withData(ByteSource data) throws IOException {
+ File content = loadData(data);
+ contentConsumer.accept(content);
+ return ModifyCommandBuilder.this;
+ }
+
+ @Override
public ModifyCommandBuilder withData(InputStream data) throws IOException {
File content = loadData(data);
contentConsumer.accept(content);
@@ -184,6 +198,36 @@ public class ModifyCommandBuilder {
}
}
+ public class WithOverwriteFlagContentLoader implements ContentLoader {
+
+ private final ContentLoader contentLoader;
+ private boolean overwrite = false;
+
+ private WithOverwriteFlagContentLoader(BiConsumer contentConsumer) {
+ this.contentLoader = new SimpleContentLoader(file -> contentConsumer.accept(file, overwrite));
+ }
+
+ /**
+ * Set this to true to overwrite the file if it already exists. Otherwise an
+ * {@link sonia.scm.AlreadyExistsException} will be thrown.
+ * @return This loader instance.
+ */
+ public WithOverwriteFlagContentLoader setOverwrite(boolean overwrite) {
+ this.overwrite = overwrite;
+ return this;
+ }
+
+ @Override
+ public ModifyCommandBuilder withData(ByteSource data) throws IOException {
+ return contentLoader.withData(data);
+ }
+
+ @Override
+ public ModifyCommandBuilder withData(InputStream data) throws IOException {
+ return contentLoader.withData(data);
+ }
+ }
+
@SuppressWarnings("UnstableApiUsage") // Files only used internal
private File loadData(ByteSource data) throws IOException {
File file = createTemporaryFile();
diff --git a/scm-core/src/main/java/sonia/scm/repository/api/RepositoryServiceFactory.java b/scm-core/src/main/java/sonia/scm/repository/api/RepositoryServiceFactory.java
index fe2af9f9e1..07c0b8fd47 100644
--- a/scm-core/src/main/java/sonia/scm/repository/api/RepositoryServiceFactory.java
+++ b/scm-core/src/main/java/sonia/scm/repository/api/RepositoryServiceFactory.java
@@ -136,13 +136,14 @@ public final class RepositoryServiceFactory
* @param resolvers a set of {@link RepositoryServiceResolver}
* @param preProcessorUtil helper object for pre processor handling
*
+ * @param workdirProvider
* @since 1.21
*/
@Inject
public RepositoryServiceFactory(ScmConfiguration configuration,
- CacheManager cacheManager, RepositoryManager repositoryManager,
- Set resolvers, PreProcessorUtil preProcessorUtil,
- Set protocolProviders)
+ CacheManager cacheManager, RepositoryManager repositoryManager,
+ Set resolvers, PreProcessorUtil preProcessorUtil,
+ Set protocolProviders, WorkdirProvider workdirProvider)
{
this.configuration = configuration;
this.cacheManager = cacheManager;
@@ -150,6 +151,7 @@ public final class RepositoryServiceFactory
this.resolvers = resolvers;
this.preProcessorUtil = preProcessorUtil;
this.protocolProviders = protocolProviders;
+ this.workdirProvider = workdirProvider;
ScmEventBus.getInstance().register(new CacheClearHook(cacheManager));
}
@@ -375,5 +377,5 @@ public final class RepositoryServiceFactory
private Set protocolProviders;
- private WorkdirProvider workdirProvider;
+ private final WorkdirProvider workdirProvider;
}
diff --git a/scm-core/src/main/java/sonia/scm/repository/spi/ModifyCommand.java b/scm-core/src/main/java/sonia/scm/repository/spi/ModifyCommand.java
index 07b29e8d21..578944c47f 100644
--- a/scm-core/src/main/java/sonia/scm/repository/spi/ModifyCommand.java
+++ b/scm-core/src/main/java/sonia/scm/repository/spi/ModifyCommand.java
@@ -1,6 +1,7 @@
package sonia.scm.repository.spi;
import java.io.File;
+import java.io.IOException;
public interface ModifyCommand {
@@ -9,7 +10,7 @@ public interface ModifyCommand {
interface Worker {
void delete(String toBeDeleted);
- void create(String toBeCreated, File file);
+ void create(String toBeCreated, File file, boolean overwrite) throws IOException;
void modify(String path, File file);
diff --git a/scm-core/src/main/java/sonia/scm/repository/spi/ModifyCommandRequest.java b/scm-core/src/main/java/sonia/scm/repository/spi/ModifyCommandRequest.java
index e8f2223bdd..723b287b6e 100644
--- a/scm-core/src/main/java/sonia/scm/repository/spi/ModifyCommandRequest.java
+++ b/scm-core/src/main/java/sonia/scm/repository/spi/ModifyCommandRequest.java
@@ -65,11 +65,11 @@ public class ModifyCommandRequest implements Resetable, Validateable {
@Override
public boolean isValid() {
- return StringUtils.isNotEmpty(commitMessage) && StringUtils.isNotEmpty(branch) && author != null && !requests.isEmpty();
+ return StringUtils.isNotEmpty(commitMessage) && StringUtils.isNotEmpty(branch) && !requests.isEmpty();
}
public interface PartialRequest {
- void execute(ModifyCommand.Worker worker);
+ void execute(ModifyCommand.Worker worker) throws IOException;
}
public static class DeleteFileRequest implements PartialRequest {
@@ -124,15 +124,17 @@ public class ModifyCommandRequest implements Resetable, Validateable {
public static class CreateFileRequest extends ContentModificationRequest {
private final String path;
+ private final boolean overwrite;
- public CreateFileRequest(String path, File content) {
+ public CreateFileRequest(String path, File content, boolean overwrite) {
super(content);
this.path = path;
+ this.overwrite = overwrite;
}
@Override
- public void execute(ModifyCommand.Worker worker) {
- worker.create(path, getContent());
+ public void execute(ModifyCommand.Worker worker) throws IOException {
+ worker.create(path, getContent(), overwrite);
cleanup();
}
}
diff --git a/scm-core/src/test/java/sonia/scm/repository/api/ModifyCommandBuilderTest.java b/scm-core/src/test/java/sonia/scm/repository/api/ModifyCommandBuilderTest.java
index 7d4d96642a..db9a247d65 100644
--- a/scm-core/src/test/java/sonia/scm/repository/api/ModifyCommandBuilderTest.java
+++ b/scm-core/src/test/java/sonia/scm/repository/api/ModifyCommandBuilderTest.java
@@ -1,6 +1,7 @@
package sonia.scm.repository.api;
import com.google.common.io.ByteSource;
+import com.sun.org.apache.xpath.internal.operations.Bool;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
@@ -25,6 +26,7 @@ import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyBoolean;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.doNothing;
import static org.mockito.Mockito.lenient;
@@ -57,7 +59,9 @@ class ModifyCommandBuilderTest {
when(command.execute(any())).thenAnswer(
invocation -> {
ModifyCommandRequest request = invocation.getArgument(0);
- request.getRequests().forEach(r -> r.execute(worker));
+ for (ModifyCommandRequest.PartialRequest r : request.getRequests()) {
+ r.execute(worker);
+ }
return "target";
}
);
@@ -73,7 +77,7 @@ class ModifyCommandBuilderTest {
}
@Test
- void shouldExecuteDelete() {
+ void shouldExecuteDelete() throws IOException {
initCommand()
.deleteFile("toBeDeleted")
.execute();
@@ -82,7 +86,7 @@ class ModifyCommandBuilderTest {
}
@Test
- void shouldExecuteMove() {
+ void shouldExecuteMove() throws IOException {
initCommand()
.moveFile("source", "target")
.execute();
@@ -94,7 +98,7 @@ class ModifyCommandBuilderTest {
void shouldExecuteCreateWithByteSourceContent() throws IOException {
ArgumentCaptor nameCaptor = ArgumentCaptor.forClass(String.class);
List contentCaptor = new ArrayList<>();
- doAnswer(new ExtractContent(contentCaptor)).when(worker).create(nameCaptor.capture(), any());
+ doAnswer(new ExtractContent(contentCaptor)).when(worker).create(nameCaptor.capture(), any(), anyBoolean());
initCommand()
.createFile("toBeCreated").withData(ByteSource.wrap("content".getBytes()))
@@ -108,7 +112,7 @@ class ModifyCommandBuilderTest {
void shouldExecuteCreateWithInputStreamContent() throws IOException {
ArgumentCaptor nameCaptor = ArgumentCaptor.forClass(String.class);
List contentCaptor = new ArrayList<>();
- doAnswer(new ExtractContent(contentCaptor)).when(worker).create(nameCaptor.capture(), any());
+ doAnswer(new ExtractContent(contentCaptor)).when(worker).create(nameCaptor.capture(), any(), anyBoolean());
initCommand()
.createFile("toBeCreated").withData(new ByteArrayInputStream("content".getBytes()))
@@ -118,11 +122,43 @@ class ModifyCommandBuilderTest {
assertThat(contentCaptor).contains("content");
}
+ @Test
+ void shouldExecuteCreateWithOverwriteFalseAsDefault() throws IOException {
+ ArgumentCaptor nameCaptor = ArgumentCaptor.forClass(String.class);
+ ArgumentCaptor overwriteCaptor = ArgumentCaptor.forClass(Boolean.class);
+ List contentCaptor = new ArrayList<>();
+ doAnswer(new ExtractContent(contentCaptor)).when(worker).create(nameCaptor.capture(), any(), overwriteCaptor.capture());
+
+ initCommand()
+ .createFile("toBeCreated").withData(new ByteArrayInputStream("content".getBytes()))
+ .execute();
+
+ assertThat(nameCaptor.getValue()).isEqualTo("toBeCreated");
+ assertThat(overwriteCaptor.getValue()).isFalse();
+ assertThat(contentCaptor).contains("content");
+ }
+
+ @Test
+ void shouldExecuteCreateWithOverwriteIfSet() throws IOException {
+ ArgumentCaptor nameCaptor = ArgumentCaptor.forClass(String.class);
+ ArgumentCaptor overwriteCaptor = ArgumentCaptor.forClass(Boolean.class);
+ List contentCaptor = new ArrayList<>();
+ doAnswer(new ExtractContent(contentCaptor)).when(worker).create(nameCaptor.capture(), any(), overwriteCaptor.capture());
+
+ initCommand()
+ .createFile("toBeCreated").setOverwrite(true).withData(new ByteArrayInputStream("content".getBytes()))
+ .execute();
+
+ assertThat(nameCaptor.getValue()).isEqualTo("toBeCreated");
+ assertThat(overwriteCaptor.getValue()).isTrue();
+ assertThat(contentCaptor).contains("content");
+ }
+
@Test
void shouldExecuteCreateMultipleTimes() throws IOException {
ArgumentCaptor nameCaptor = ArgumentCaptor.forClass(String.class);
List contentCaptor = new ArrayList<>();
- doAnswer(new ExtractContent(contentCaptor)).when(worker).create(nameCaptor.capture(), any());
+ doAnswer(new ExtractContent(contentCaptor)).when(worker).create(nameCaptor.capture(), any(), anyBoolean());
initCommand()
.createFile("toBeCreated_1").withData(new ByteArrayInputStream("content_1".getBytes()))
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 f94ccd59f6..6066b66371 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
@@ -35,15 +35,33 @@ package sonia.scm.repository.spi;
//~--- non-JDK imports --------------------------------------------------------
import com.google.common.base.Strings;
+import org.apache.shiro.SecurityUtils;
+import org.apache.shiro.subject.Subject;
+import org.eclipse.jgit.api.Git;
+import org.eclipse.jgit.api.errors.GitAPIException;
+import org.eclipse.jgit.api.errors.RefNotFoundException;
import org.eclipse.jgit.lib.ObjectId;
import org.eclipse.jgit.lib.Ref;
import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import sonia.scm.repository.GitUtil;
+import sonia.scm.repository.GitWorkdirFactory;
+import sonia.scm.repository.InternalRepositoryException;
+import sonia.scm.repository.Person;
+import sonia.scm.repository.util.WorkingCopy;
+import sonia.scm.user.User;
import java.io.IOException;
import java.util.Optional;
+import java.util.function.Function;
+import java.util.function.Supplier;
+
+import static java.util.Optional.empty;
+import static java.util.Optional.of;
+import static sonia.scm.ContextEntry.ContextBuilder.entity;
+import static sonia.scm.NotFoundException.notFound;
//~--- JDK imports ------------------------------------------------------------
@@ -51,7 +69,7 @@ import java.util.Optional;
*
* @author Sebastian Sdorra
*/
-public class AbstractGitCommand
+class AbstractGitCommand
{
/**
@@ -66,7 +84,7 @@ public class AbstractGitCommand
* @param context
* @param repository
*/
- protected AbstractGitCommand(GitContext context,
+ AbstractGitCommand(GitContext context,
sonia.scm.repository.Repository repository)
{
this.repository = repository;
@@ -83,12 +101,12 @@ public class AbstractGitCommand
*
* @throws IOException
*/
- protected Repository open() throws IOException
+ Repository open() throws IOException
{
return context.open();
}
- protected ObjectId getCommitOrDefault(Repository gitRepository, String requestedCommit) throws IOException {
+ ObjectId getCommitOrDefault(Repository gitRepository, String requestedCommit) throws IOException {
ObjectId commit;
if ( Strings.isNullOrEmpty(requestedCommit) ) {
commit = getDefaultBranch(gitRepository);
@@ -98,7 +116,7 @@ public class AbstractGitCommand
return commit;
}
- protected ObjectId getDefaultBranch(Repository gitRepository) throws IOException {
+ ObjectId getDefaultBranch(Repository gitRepository) throws IOException {
Ref ref = getBranchOrDefault(gitRepository, null);
if (ref == null) {
return null;
@@ -107,7 +125,7 @@ public class AbstractGitCommand
}
}
- protected Ref getBranchOrDefault(Repository gitRepository, String requestedBranch) throws IOException {
+ Ref getBranchOrDefault(Repository gitRepository, String requestedBranch) throws IOException {
if ( Strings.isNullOrEmpty(requestedBranch) ) {
String defaultBranchName = context.getConfig().getDefaultBranch();
if (!Strings.isNullOrEmpty(defaultBranchName)) {
@@ -122,6 +140,120 @@ public class AbstractGitCommand
}
}
+ > R inClone(Function workerSupplier, GitWorkdirFactory workdirFactory) {
+ try (WorkingCopy workingCopy = workdirFactory.createWorkingCopy(context)) {
+ Repository repository = workingCopy.getWorkingRepository();
+ logger.debug("cloned repository to folder {}", repository.getWorkTree());
+ return workerSupplier.apply(new Git(repository)).run();
+ } catch (IOException e) {
+ throw new InternalRepositoryException(context.getRepository(), "could not clone repository", e);
+ }
+ }
+
+ ObjectId resolveRevisionOrThrowNotFound(Repository repository, String revision) throws IOException {
+ ObjectId resolved = repository.resolve(revision);
+ if (resolved == null) {
+ throw notFound(entity("Revision", revision).in(context.getRepository()));
+ } else {
+ return resolved;
+ }
+ }
+
+ abstract class GitCloneWorker {
+ private final Git clone;
+
+ GitCloneWorker(Git clone) {
+ this.clone = clone;
+ }
+
+ abstract R run() throws IOException;
+
+ Git getClone() {
+ return clone;
+ }
+
+ void checkOutBranch(String branchName) throws IOException {
+ try {
+ clone.checkout().setName(branchName).call();
+ } catch (RefNotFoundException e) {
+ logger.trace("could not checkout branch {} directly; trying to create local branch", branchName, e);
+ checkOutTargetAsNewLocalBranch(branchName);
+ } catch (GitAPIException e) {
+ throw new InternalRepositoryException(context.getRepository(), "could not checkout branch: " + branchName, e);
+ }
+ }
+
+ private void checkOutTargetAsNewLocalBranch(String branchName) throws IOException {
+ try {
+ ObjectId targetRevision = resolveRevision(branchName);
+ clone.checkout().setStartPoint(targetRevision.getName()).setName(branchName).setCreateBranch(true).call();
+ } catch (RefNotFoundException e) {
+ logger.debug("could not checkout branch {} as local branch", branchName, e);
+ throw notFound(entity("Revision", branchName).in(context.getRepository()));
+ } catch (GitAPIException e) {
+ throw new InternalRepositoryException(context.getRepository(), "could not checkout branch as local branch: " + branchName, e);
+ }
+ }
+
+ ObjectId resolveRevision(String revision) throws IOException {
+ ObjectId resolved = clone.getRepository().resolve(revision);
+ if (resolved == null) {
+ return resolveRevisionOrThrowNotFound(clone.getRepository(), "origin/" + revision);
+ } else {
+ return resolved;
+ }
+ }
+
+ void failIfNotChanged(Supplier doThrow) {
+ try {
+ if (clone.status().call().isClean()) {
+ throw doThrow.get();
+ }
+ } catch (GitAPIException e) {
+ throw new InternalRepositoryException(context.getRepository(), "could not read status of repository", e);
+ }
+ }
+
+ Optional doCommit(String message, Person author) {
+ Person authorToUse = determineAuthor(author);
+ try {
+ if (!clone.status().call().isClean()) {
+ return of(clone.commit()
+ .setAuthor(authorToUse.getName(), authorToUse.getMail())
+ .setMessage(message)
+ .call());
+ } else {
+ return empty();
+ }
+ } catch (GitAPIException e) {
+ throw new InternalRepositoryException(context.getRepository(), "could not commit changes", e);
+ }
+ }
+
+ void push() {
+ try {
+ clone.push().call();
+ } catch (GitAPIException e) {
+ throw new IntegrateChangesFromWorkdirException(repository,
+ "could not push changes into central repository", e);
+ }
+ logger.debug("pushed changes");
+ }
+
+ private Person determineAuthor(Person author) {
+ if (author == null) {
+ Subject subject = SecurityUtils.getSubject();
+ User user = subject.getPrincipals().oneByType(User.class);
+ String name = user.getDisplayName();
+ String email = user.getMail();
+ logger.debug("no author set; using logged in user: {} <{}>", name, email);
+ return new Person(name, email);
+ } else {
+ return author;
+ }
+ }
+ }
+
//~--- fields ---------------------------------------------------------------
/** Field description */
diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitMergeCommand.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitMergeCommand.java
index 58c71b8dac..b4a7954b02 100644
--- a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitMergeCommand.java
+++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitMergeCommand.java
@@ -1,13 +1,10 @@
package sonia.scm.repository.spi;
import com.google.common.base.Strings;
-import org.apache.shiro.SecurityUtils;
-import org.apache.shiro.subject.Subject;
import org.eclipse.jgit.api.Git;
import org.eclipse.jgit.api.MergeCommand.FastForwardMode;
import org.eclipse.jgit.api.MergeResult;
import org.eclipse.jgit.api.errors.GitAPIException;
-import org.eclipse.jgit.api.errors.RefNotFoundException;
import org.eclipse.jgit.lib.ObjectId;
import org.eclipse.jgit.lib.Repository;
import org.eclipse.jgit.merge.MergeStrategy;
@@ -17,18 +14,12 @@ import org.slf4j.LoggerFactory;
import sonia.scm.repository.GitWorkdirFactory;
import sonia.scm.repository.InternalRepositoryException;
import sonia.scm.repository.Person;
-import sonia.scm.repository.RepositoryPermissions;
import sonia.scm.repository.api.MergeCommandResult;
import sonia.scm.repository.api.MergeDryRunCommandResult;
-import sonia.scm.repository.util.WorkingCopy;
-import sonia.scm.user.User;
import java.io.IOException;
import java.text.MessageFormat;
-import static sonia.scm.ContextEntry.ContextBuilder.entity;
-import static sonia.scm.NotFoundException.notFound;
-
public class GitMergeCommand extends AbstractGitCommand implements MergeCommand {
private static final Logger logger = LoggerFactory.getLogger(GitMergeCommand.class);
@@ -47,13 +38,7 @@ public class GitMergeCommand extends AbstractGitCommand implements MergeCommand
@Override
public MergeCommandResult merge(MergeCommandRequest request) {
- try (WorkingCopy workingCopy = workdirFactory.createWorkingCopy(context)) {
- Repository repository = workingCopy.getWorkingRepository();
- logger.debug("cloned repository to folder {}", repository.getWorkTree());
- return new MergeWorker(repository, request).merge();
- } catch (IOException e) {
- throw new InternalRepositoryException(context.getRepository(), "could not clone repository for merge", e);
- }
+ return inClone(clone -> new MergeWorker(clone, request), workdirFactory);
}
@Override
@@ -70,32 +55,23 @@ public class GitMergeCommand extends AbstractGitCommand implements MergeCommand
}
}
- private ObjectId resolveRevisionOrThrowNotFound(Repository repository, String revision) throws IOException {
- ObjectId resolved = repository.resolve(revision);
- if (resolved == null) {
- throw notFound(entity("Revision", revision).in(context.getRepository()));
- } else {
- return resolved;
- }
- }
-
- private class MergeWorker {
+ private class MergeWorker extends GitCloneWorker {
private final String target;
private final String toMerge;
private final Person author;
- private final Git clone;
private final String messageTemplate;
- private MergeWorker(Repository clone, MergeCommandRequest request) {
+ private MergeWorker(Git clone, MergeCommandRequest request) {
+ super(clone);
this.target = request.getTargetBranch();
this.toMerge = request.getBranchToMerge();
this.author = request.getAuthor();
this.messageTemplate = request.getMessageTemplate();
- this.clone = new Git(clone);
}
- private MergeCommandResult merge() throws IOException {
+ @Override
+ MergeCommandResult run() throws IOException {
checkOutTargetBranch();
MergeResult result = doMergeInClone();
if (result.getMergeStatus().isSuccessful()) {
@@ -108,33 +84,14 @@ public class GitMergeCommand extends AbstractGitCommand implements MergeCommand
}
private void checkOutTargetBranch() throws IOException {
- try {
- clone.checkout().setName(target).call();
- } catch (RefNotFoundException e) {
- logger.trace("could not checkout target branch {} for merge directly; trying to create local branch", target, e);
- checkOutTargetAsNewLocalBranch();
- } catch (GitAPIException e) {
- throw new InternalRepositoryException(context.getRepository(), "could not checkout target branch for merge: " + target, e);
- }
- }
-
- private void checkOutTargetAsNewLocalBranch() throws IOException {
- try {
- ObjectId targetRevision = resolveRevision(target);
- clone.checkout().setStartPoint(targetRevision.getName()).setName(target).setCreateBranch(true).call();
- } catch (RefNotFoundException e) {
- logger.debug("could not checkout target branch {} for merge as local branch", target, e);
- throw notFound(entity("Revision", target).in(context.getRepository()));
- } catch (GitAPIException e) {
- throw new InternalRepositoryException(context.getRepository(), "could not checkout target branch for merge as local branch: " + target, e);
- }
+ checkOutBranch(target);
}
private MergeResult doMergeInClone() throws IOException {
MergeResult result;
try {
ObjectId sourceRevision = resolveRevision(toMerge);
- result = clone.merge()
+ result = getClone().merge()
.setFastForward(FastForwardMode.NO_FF)
.setCommit(false) // we want to set the author manually
.include(toMerge, sourceRevision)
@@ -147,17 +104,7 @@ public class GitMergeCommand extends AbstractGitCommand implements MergeCommand
private void doCommit() {
logger.debug("merged branch {} into {}", toMerge, target);
- Person authorToUse = determineAuthor();
- try {
- if (!clone.status().call().isClean()) {
- clone.commit()
- .setAuthor(authorToUse.getName(), authorToUse.getMail())
- .setMessage(MessageFormat.format(determineMessageTemplate(), toMerge, target))
- .call();
- }
- } catch (GitAPIException e) {
- throw new InternalRepositoryException(context.getRepository(), "could not commit merge between branch " + toMerge + " and " + target, e);
- }
+ doCommit(MessageFormat.format(determineMessageTemplate(), toMerge, target), author);
}
private String determineMessageTemplate() {
@@ -168,41 +115,9 @@ public class GitMergeCommand extends AbstractGitCommand implements MergeCommand
}
}
- private Person determineAuthor() {
- if (author == null) {
- Subject subject = SecurityUtils.getSubject();
- User user = subject.getPrincipals().oneByType(User.class);
- String name = user.getDisplayName();
- String email = user.getMail();
- logger.debug("no author set; using logged in user: {} <{}>", name, email);
- return new Person(name, email);
- } else {
- return author;
- }
- }
-
- private void push() {
- try {
- clone.push().call();
- } catch (GitAPIException e) {
- throw new IntegrateChangesFromWorkdirException(repository,
- "could not push merged branch " + target + " into central repository", e);
- }
- logger.debug("pushed merged branch {}", target);
- }
-
private MergeCommandResult analyseFailure(MergeResult result) {
logger.info("could not merged branch {} into {} due to conflict in paths {}", toMerge, target, result.getConflicts().keySet());
return MergeCommandResult.failure(result.getConflicts().keySet());
}
-
- private ObjectId resolveRevision(String revision) throws IOException {
- ObjectId resolved = clone.getRepository().resolve(revision);
- if (resolved == null) {
- return resolveRevisionOrThrowNotFound(clone.getRepository(), "origin/" + revision);
- } else {
- return resolved;
- }
- }
}
}
diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitModifyCommand.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitModifyCommand.java
new file mode 100644
index 0000000000..680ef153bd
--- /dev/null
+++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitModifyCommand.java
@@ -0,0 +1,110 @@
+package sonia.scm.repository.spi;
+
+import org.eclipse.jgit.api.Git;
+import org.eclipse.jgit.api.errors.GitAPIException;
+import org.eclipse.jgit.revwalk.RevCommit;
+import sonia.scm.BadRequestException;
+import sonia.scm.ContextEntry;
+import sonia.scm.repository.GitWorkdirFactory;
+import sonia.scm.repository.InternalRepositoryException;
+import sonia.scm.repository.Repository;
+
+import java.io.File;
+import java.io.IOException;
+import java.nio.file.FileAlreadyExistsException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.Optional;
+
+import static java.nio.file.StandardCopyOption.REPLACE_EXISTING;
+import static sonia.scm.AlreadyExistsException.alreadyExists;
+import static sonia.scm.ContextEntry.ContextBuilder.entity;
+
+public class GitModifyCommand extends AbstractGitCommand implements ModifyCommand {
+
+ private final GitWorkdirFactory workdirFactory;
+
+ GitModifyCommand(GitContext context, Repository repository, GitWorkdirFactory workdirFactory) {
+ super(context, repository);
+ this.workdirFactory = workdirFactory;
+ }
+
+ @Override
+ public String execute(ModifyCommandRequest request) {
+ return inClone(clone -> new ModifyWorker(clone, request), workdirFactory);
+ }
+
+ private class ModifyWorker extends GitCloneWorker implements Worker {
+
+ private final File workDir;
+ private final ModifyCommandRequest request;
+
+ ModifyWorker(Git clone, ModifyCommandRequest request) {
+ super(clone);
+ this.workDir = clone.getRepository().getWorkTree();
+ this.request = request;
+ }
+
+ @Override
+ String run() throws IOException {
+ checkOutBranch(request.getBranch());
+ for (ModifyCommandRequest.PartialRequest r : request.getRequests()) {
+ r.execute(this);
+ }
+ failIfNotChanged(NoChangesMadeException::new);
+ Optional revCommit = doCommit(request.getCommitMessage(), request.getAuthor());
+ push();
+ return revCommit.orElseThrow(NoChangesMadeException::new).name();
+ }
+
+ @Override
+ public void create(String toBeCreated, File file, boolean overwrite) throws IOException {
+ Path targetFile = new File(workDir, toBeCreated).toPath();
+ Files.createDirectories(targetFile.getParent());
+ if (overwrite) {
+ Files.copy(file.toPath(), targetFile, REPLACE_EXISTING);
+ } else {
+ try {
+ Files.copy(file.toPath(), targetFile);
+ } catch (FileAlreadyExistsException e) {
+ throw alreadyExists(entity("file", toBeCreated).in("branch", request.getBranch()).in(context.getRepository()));
+ }
+ }
+ try {
+ getClone().add().addFilepattern(toBeCreated).call();
+ } catch (GitAPIException e) {
+ throwInternalRepositoryException("could not add new file to index", e);
+ }
+ }
+
+ @Override
+ public void delete(String toBeDeleted) {
+
+ }
+
+ @Override
+ public void modify(String path, File file) {
+
+ }
+
+ @Override
+ public void move(String sourcePath, String targetPath) {
+
+ }
+
+ private class NoChangesMadeException extends BadRequestException {
+ public NoChangesMadeException() {
+ super(ContextEntry.ContextBuilder.entity(context.getRepository()).build(), "no changes detected to branch " + ModifyWorker.this.request.getBranch());
+ }
+
+ @Override
+ public String getCode() {
+ return "40RaYIeeR1";
+ }
+ }
+ }
+
+ private String throwInternalRepositoryException(String message, Exception e) {
+ throw new InternalRepositoryException(context.getRepository(), message, e);
+ }
+}
diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitRepositoryServiceProvider.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitRepositoryServiceProvider.java
index 6870c85dea..22a9fdf5f3 100644
--- a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitRepositoryServiceProvider.java
+++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitRepositoryServiceProvider.java
@@ -268,6 +268,11 @@ public class GitRepositoryServiceProvider extends RepositoryServiceProvider
return new GitMergeCommand(context, repository, handler.getWorkdirFactory());
}
+ @Override
+ public ModifyCommand getModifyCommand() {
+ return new GitModifyCommand(context, repository, handler.getWorkdirFactory());
+ }
+
@Override
public Set getSupportedFeatures() {
return FEATURES;
diff --git a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitMergeCommandTest.java b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitMergeCommandTest.java
index 8b3b4548d9..674da396b5 100644
--- a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitMergeCommandTest.java
+++ b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitMergeCommandTest.java
@@ -10,30 +10,17 @@ import org.eclipse.jgit.lib.ObjectId;
import org.eclipse.jgit.lib.PersonIdent;
import org.eclipse.jgit.lib.Repository;
import org.eclipse.jgit.revwalk.RevCommit;
-import org.eclipse.jgit.transport.ScmTransportProtocol;
-import org.eclipse.jgit.transport.Transport;
-import org.junit.After;
-import org.junit.Before;
import org.junit.Rule;
import org.junit.Test;
import sonia.scm.NotFoundException;
-import sonia.scm.repository.GitRepositoryHandler;
import sonia.scm.repository.Person;
-import sonia.scm.repository.PreProcessorUtil;
-import sonia.scm.repository.RepositoryManager;
-import sonia.scm.repository.api.HookContextFactory;
import sonia.scm.repository.api.MergeCommandResult;
-import sonia.scm.repository.api.MergeDryRunCommandResult;
import sonia.scm.repository.util.WorkdirProvider;
import sonia.scm.user.User;
import java.io.IOException;
-import static com.google.inject.util.Providers.of;
import static org.assertj.core.api.Assertions.assertThat;
-import static org.mockito.ArgumentMatchers.any;
-import static org.mockito.Mockito.mock;
-import static org.mockito.Mockito.when;
@SubjectAware(configuration = "classpath:sonia/scm/configuration/shiro.ini", username = "admin", password = "secret")
public class GitMergeCommandTest extends AbstractGitCommandTestBase {
diff --git a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitModifyCommandTest.java b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitModifyCommandTest.java
new file mode 100644
index 0000000000..c1e1c05631
--- /dev/null
+++ b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitModifyCommandTest.java
@@ -0,0 +1,152 @@
+package sonia.scm.repository.spi;
+
+import com.github.sdorra.shiro.ShiroRule;
+import com.github.sdorra.shiro.SubjectAware;
+import org.eclipse.jgit.api.Git;
+import org.eclipse.jgit.api.errors.GitAPIException;
+import org.eclipse.jgit.errors.CorruptObjectException;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectReader;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.eclipse.jgit.treewalk.CanonicalTreeParser;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.TemporaryFolder;
+import sonia.scm.AlreadyExistsException;
+import sonia.scm.BadRequestException;
+import sonia.scm.repository.Person;
+import sonia.scm.repository.util.WorkdirProvider;
+
+import java.io.File;
+import java.io.IOException;
+import java.nio.file.Files;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+@SubjectAware(configuration = "classpath:sonia/scm/configuration/shiro.ini", username = "admin", password = "secret")
+public class GitModifyCommandTest extends AbstractGitCommandTestBase {
+
+ @Rule
+ public TemporaryFolder temporaryFolder = new TemporaryFolder();
+ @Rule
+ public BindTransportProtocolRule transportProtocolRule = new BindTransportProtocolRule();
+ @Rule
+ public ShiroRule shiro = new ShiroRule();
+
+ @Test
+ public void shouldCreateCommit() throws IOException, GitAPIException {
+ File newFile = Files.write(temporaryFolder.newFile().toPath(), "new content".getBytes()).toFile();
+
+ GitModifyCommand command = createCommand();
+
+ ModifyCommandRequest request = new ModifyCommandRequest();
+ request.setBranch("master");
+ request.setCommitMessage("test commit");
+ request.addRequest(new ModifyCommandRequest.CreateFileRequest("new_file", newFile, false));
+ request.setAuthor(new Person("Dirk Gently", "dirk@holistic.det"));
+
+ String newRef = command.execute(request);
+
+ try (Git git = new Git(createContext().open())) {
+ RevCommit lastCommit = getLastCommit(git);
+ assertThat(lastCommit.getFullMessage()).isEqualTo("test commit");
+ assertThat(lastCommit.getAuthorIdent().getName()).isEqualTo("Dirk Gently");
+ assertThat(newRef).isEqualTo(lastCommit.toObjectId().name());
+ }
+ }
+
+ @Test
+ public void shouldCreateNewFile() throws IOException, GitAPIException {
+ File newFile = Files.write(temporaryFolder.newFile().toPath(), "new content".getBytes()).toFile();
+
+ GitModifyCommand command = createCommand();
+
+ ModifyCommandRequest request = new ModifyCommandRequest();
+ request.setBranch("master");
+ request.setCommitMessage("test commit");
+ request.addRequest(new ModifyCommandRequest.CreateFileRequest("new_file", newFile, false));
+ request.setAuthor(new Person("Dirk Gently", "dirk@holistic.det"));
+
+ command.execute(request);
+
+ TreeAssertions assertions = canonicalTreeParser -> assertThat(canonicalTreeParser.findFile("new_file")).isTrue();
+
+ assertInTree(assertions);
+ }
+
+ @Test(expected = AlreadyExistsException.class)
+ public void shouldFailIfOverwritingExistingFileWithoutOverwriteFlag() throws IOException, GitAPIException {
+ File newFile = Files.write(temporaryFolder.newFile().toPath(), "new content".getBytes()).toFile();
+
+ GitModifyCommand command = createCommand();
+
+ ModifyCommandRequest request = new ModifyCommandRequest();
+ request.setBranch("master");
+ request.setCommitMessage("test commit");
+ request.addRequest(new ModifyCommandRequest.CreateFileRequest("a.txt", newFile, false));
+ request.setAuthor(new Person("Dirk Gently", "dirk@holistic.det"));
+
+ command.execute(request);
+ }
+
+ @Test
+ public void shouldOverwriteExistingFileIfOverwriteFlagSet() throws IOException, GitAPIException {
+ File newFile = Files.write(temporaryFolder.newFile().toPath(), "new content".getBytes()).toFile();
+
+ GitModifyCommand command = createCommand();
+
+ ModifyCommandRequest request = new ModifyCommandRequest();
+ request.setBranch("master");
+ request.setCommitMessage("test commit");
+ request.addRequest(new ModifyCommandRequest.CreateFileRequest("a.txt", newFile, true));
+ request.setAuthor(new Person("Dirk Gently", "dirk@holistic.det"));
+
+ command.execute(request);
+
+ TreeAssertions assertions = canonicalTreeParser -> assertThat(canonicalTreeParser.findFile("a.txt")).isTrue();
+
+ assertInTree(assertions);
+ }
+
+ @Test(expected = BadRequestException.class)
+ public void shouldFailIfNoChangesMade() throws IOException, GitAPIException {
+ File newFile = Files.write(temporaryFolder.newFile().toPath(), "b\n".getBytes()).toFile();
+
+ GitModifyCommand command = createCommand();
+
+ ModifyCommandRequest request = new ModifyCommandRequest();
+ request.setBranch("master");
+ request.setCommitMessage("test commit");
+ request.addRequest(new ModifyCommandRequest.CreateFileRequest("b.txt", newFile, true));
+ request.setAuthor(new Person("Dirk Gently", "dirk@holistic.det"));
+
+ command.execute(request);
+ }
+
+ private void assertInTree(TreeAssertions assertions) throws IOException, GitAPIException {
+ try (Git git = new Git(createContext().open())) {
+ RevCommit lastCommit = getLastCommit(git);
+ try (RevWalk walk = new RevWalk(git.getRepository())) {
+ RevCommit commit = walk.parseCommit(lastCommit);
+ ObjectId treeId = commit.getTree().getId();
+ try (ObjectReader reader = git.getRepository().newObjectReader()) {
+ assertions.checkAssertions(new CanonicalTreeParser(null, reader, treeId));
+ }
+ }
+ }
+ }
+
+ private RevCommit getLastCommit(Git git) throws GitAPIException {
+ return git.log().setMaxCount(1).call().iterator().next();
+ }
+
+ private GitModifyCommand createCommand() {
+ return new GitModifyCommand(createContext(), repository, new SimpleGitWorkdirFactory(new WorkdirProvider()));
+ }
+
+ @FunctionalInterface
+ private interface TreeAssertions {
+ void checkAssertions(CanonicalTreeParser treeParser) throws CorruptObjectException;
+ }
+}
diff --git a/scm-webapp/src/main/resources/locales/de/plugins.json b/scm-webapp/src/main/resources/locales/de/plugins.json
index bf534dc138..9c5bb3f5f5 100644
--- a/scm-webapp/src/main/resources/locales/de/plugins.json
+++ b/scm-webapp/src/main/resources/locales/de/plugins.json
@@ -171,6 +171,10 @@
"thbsUFokjk": {
"displayName": "Unerlaubte Änderung eines Schlüsselwerts",
"description": "Ein Schlüsselwert wurde unerlaubterweise geändert."
+ },
+ "40RaYIeeR1": {
+ "displayName": "Es wurden keine Änderungen durchgeführt",
+ "description": "Das Repository wurde nicht verändert. Daher konnte kein neuer Commit erzeugt werden."
}
},
"namespaceStrategies": {
diff --git a/scm-webapp/src/main/resources/locales/en/plugins.json b/scm-webapp/src/main/resources/locales/en/plugins.json
index 471da10159..1ffffd73b7 100644
--- a/scm-webapp/src/main/resources/locales/en/plugins.json
+++ b/scm-webapp/src/main/resources/locales/en/plugins.json
@@ -171,6 +171,10 @@
"thbsUFokjk": {
"displayName": "Illegal change of an identifier",
"description": "A identifier value has been changed in the entity. This is not allowed."
+ },
+ "40RaYIeeR1": {
+ "displayName": "No changes were made",
+ "description": "No changes were made to the files of the repository. Therefor no new commit could be created."
}
},
"namespaceStrategies": {