diff --git a/docs/de/user/repo/assets/repository-code-changeset-revert-modal.png b/docs/de/user/repo/assets/repository-code-changeset-revert-modal.png new file mode 100644 index 0000000000..2d21428edb Binary files /dev/null and b/docs/de/user/repo/assets/repository-code-changeset-revert-modal.png differ diff --git a/docs/de/user/repo/assets/repository-code-changeset-revert.png b/docs/de/user/repo/assets/repository-code-changeset-revert.png new file mode 100644 index 0000000000..b1a6112c45 Binary files /dev/null and b/docs/de/user/repo/assets/repository-code-changeset-revert.png differ diff --git a/docs/de/user/repo/code.md b/docs/de/user/repo/code.md index 3d62318f3f..f380ba3ccc 100644 --- a/docs/de/user/repo/code.md +++ b/docs/de/user/repo/code.md @@ -84,6 +84,24 @@ Es muss lediglich ein gewünschter Name angegeben werden, welcher die gleichen F ![Repository-Code-Changeset-Create-Tag](assets/repository-code-changeset-create-tag.png) +#### Reverts +In Changesets innerhalb von Git-Repositories steht in der oberen rechten Ecke (unter "Tag erstellen") ein Knopf zum Reverten des Commits. + +**Hinweis:** Der Revert-Knopf wird nur dann angezeigt, wenn der Commit genau einen Vorgänger hat. +Commits mit mehr als einem Vorgänger (z.B. Merge-Commits) und initiale Commits ohne Vorgänger können nicht zurückgesetzt werden. + +![Repository-Code-Changeset-Revert](assets/repository-code-changeset-revert.png) + +Für einen Revert ist nach Drücken des Knopfs ein Branch auszuwählen, auf welchem der Revert angewendet wird. +Gelangt man aus der Commit-Übersicht eines Branches in den Commit, ist die Auswahl automatisch vorgenommen. + +Ebenso kann eine Commit-Nachricht für den Revert angegeben werden. +Sie ist automatisch ausgefüllt; es empfiehlt sich jedoch aus Gründen der Übersichtlichkeit, in dieser den Revert zu begründen. + +Mit Drücken von "Revert" wird man auf den neu erstellten Revert-Commit automatisch weitergeleitet, sofern kein Fehler auftritt. + +![Repository-Code-Changeset-Revert-Modal](assets/repository-code-changeset-revert-modal.png) + ### Datei Details Nach einem Klick auf eine Datei in den Sources landet man in der Detailansicht der Datei. Dabei sind je nach Dateiformat unterschiedliche Ansichten zu sehen: diff --git a/docs/en/user/repo/assets/repository-code-changeset-revert-modal.png b/docs/en/user/repo/assets/repository-code-changeset-revert-modal.png new file mode 100644 index 0000000000..b36b881539 Binary files /dev/null and b/docs/en/user/repo/assets/repository-code-changeset-revert-modal.png differ diff --git a/docs/en/user/repo/assets/repository-code-changeset-revert.png b/docs/en/user/repo/assets/repository-code-changeset-revert.png new file mode 100644 index 0000000000..8118c91386 Binary files /dev/null and b/docs/en/user/repo/assets/repository-code-changeset-revert.png differ diff --git a/docs/en/user/repo/code.md b/docs/en/user/repo/code.md index 871d814352..07c15421fd 100644 --- a/docs/en/user/repo/code.md +++ b/docs/en/user/repo/code.md @@ -85,6 +85,24 @@ Only a name has to be provided that meets the same formatting conditions as bran ![Repository-Code-Changeset-Create-Tag](assets/repository-code-changeset-create-tag.png) +#### Reverts +Changesets within Git repositories provide a "Revert" button at the upper right-hand corner (beneath "Create Tag"). + +**Note:** The revert button is only displayed on commits with exactly one parent element. +Commits with multiple predecessors (e.g. merge commits) and initial commits without parent cannot be reverted. + +![Repository-Code-Changeset-Revert](assets/repository-code-changeset-revert.png) + +After pressing the button and before reverting a changeset, you need to first select the branch where it is applied upon. +This selection is already filled out if you reached the changeset by the changeset overview of a specific branch. + +Furthermore, you may type a commit message for the revert. +It is filled out with a default message; however, it is recommended to choose a reasonable custom message in order to keep the changeset history comprehensible. + +By pressing "Revert", you're going to be forwarded to the newly created commit including the revert if no error occurs. + +![Repository-Code-Changeset-Revert-Modal](assets/repository-code-changeset-revert-modal.png) + ### File Details After clicking on a file in the sources, the details of the file are shown. Depending on the format of the file, there are different views: diff --git a/gradle/changelog/git_revert.yaml b/gradle/changelog/git_revert.yaml new file mode 100644 index 0000000000..f148be9b7b --- /dev/null +++ b/gradle/changelog/git_revert.yaml @@ -0,0 +1,2 @@ +- type: added + description: Git revert commit functionality diff --git a/scm-core/src/main/java/sonia/scm/ConflictException.java b/scm-core/src/main/java/sonia/scm/ConflictException.java new file mode 100644 index 0000000000..44b16df146 --- /dev/null +++ b/scm-core/src/main/java/sonia/scm/ConflictException.java @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2020 - present Cloudogu GmbH + * + * This program is free software: you can redistribute it and/or modify it under + * the terms of the GNU Affero General Public License as published by the Free + * Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS + * FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more + * details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +package sonia.scm; + +import sonia.scm.repository.NamespaceAndName; + +import java.util.Collection; +import java.util.List; + +import static sonia.scm.ContextEntry.ContextBuilder.entity; + +public class ConflictException extends ExceptionWithContext { + private static final String CODE = "7XUd94Iwo1"; + + public ConflictException(NamespaceAndName namespaceAndName, Collection conflictingFiles) { + super( + createContext(namespaceAndName, conflictingFiles), + "conflict" + ); + } + + private static List createContext(NamespaceAndName namespaceAndName, Collection conflictingFiles) { + return entity("files", String.join(", ", conflictingFiles)) + .in(namespaceAndName) + .build(); + } + + @Override + public String getCode() { + return CODE; + } +} diff --git a/scm-core/src/main/java/sonia/scm/repository/MultipleParentsNotAllowedException.java b/scm-core/src/main/java/sonia/scm/repository/MultipleParentsNotAllowedException.java new file mode 100644 index 0000000000..f6f67d3f7c --- /dev/null +++ b/scm-core/src/main/java/sonia/scm/repository/MultipleParentsNotAllowedException.java @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2020 - present Cloudogu GmbH + * + * This program is free software: you can redistribute it and/or modify it under + * the terms of the GNU Affero General Public License as published by the Free + * Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS + * FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more + * details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +package sonia.scm.repository; + +import sonia.scm.BadRequestException; + +import java.util.Collections; + +@SuppressWarnings("squid:MaximumInheritanceDepth") // exceptions have a deep inheritance depth themselves; therefore we accept this here +public class MultipleParentsNotAllowedException extends BadRequestException { + public MultipleParentsNotAllowedException(String changeset) { + super( + Collections.emptyList(), + String.format("%s has more than one parent changeset, which is not allowed with this request.", changeset)); + } + + @Override + public String getCode() { + return "3a47Hzu1e3"; + } +} diff --git a/scm-core/src/main/java/sonia/scm/repository/NoParentException.java b/scm-core/src/main/java/sonia/scm/repository/NoParentException.java new file mode 100644 index 0000000000..77321d8e3b --- /dev/null +++ b/scm-core/src/main/java/sonia/scm/repository/NoParentException.java @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2020 - present Cloudogu GmbH + * + * This program is free software: you can redistribute it and/or modify it under + * the terms of the GNU Affero General Public License as published by the Free + * Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS + * FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more + * details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +package sonia.scm.repository; + +import lombok.Getter; +import sonia.scm.BadRequestException; + +import static java.util.Collections.emptyList; + +/** + * Thrown when a changeset has no parent. + * @since 3.8 + */ +@SuppressWarnings("squid:MaximumInheritanceDepth") // exceptions have a deep inheritance depth themselves; therefore we accept this here +@Getter +public class NoParentException extends BadRequestException { + + public NoParentException(String changeset) { + super(emptyList(), String.format("%s has no parent.", changeset)); + this.revision = changeset; + } + + private final String revision; + + @Override + public String getCode() { + return "a37jI66dup"; + } +} diff --git a/scm-core/src/main/java/sonia/scm/repository/api/Command.java b/scm-core/src/main/java/sonia/scm/repository/api/Command.java index d0a728c370..ceb27f2a48 100644 --- a/scm-core/src/main/java/sonia/scm/repository/api/Command.java +++ b/scm-core/src/main/java/sonia/scm/repository/api/Command.java @@ -82,5 +82,10 @@ public enum Command /** * @since 2.39.0 */ - CHANGESETS + CHANGESETS, + + /** + * @since 3.8 + */ + REVERT } diff --git a/scm-core/src/main/java/sonia/scm/repository/api/RepositoryService.java b/scm-core/src/main/java/sonia/scm/repository/api/RepositoryService.java index 038e0cf7cb..176b7cfc73 100644 --- a/scm-core/src/main/java/sonia/scm/repository/api/RepositoryService.java +++ b/scm-core/src/main/java/sonia/scm/repository/api/RepositoryService.java @@ -17,6 +17,7 @@ package sonia.scm.repository.api; import jakarta.annotation.Nullable; +import lombok.Getter; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import sonia.scm.cache.CacheManager; @@ -55,22 +56,6 @@ import java.util.stream.Stream; * after work is finished. For closing the connection to the repository use the * {@link #close()} method. * - * @apiviz.uses sonia.scm.repository.Feature - * @apiviz.uses sonia.scm.repository.api.Command - * @apiviz.uses sonia.scm.repository.api.BlameCommandBuilder - * @apiviz.uses sonia.scm.repository.api.BrowseCommandBuilder - * @apiviz.uses sonia.scm.repository.api.CatCommandBuilder - * @apiviz.uses sonia.scm.repository.api.DiffCommandBuilder - * @apiviz.uses sonia.scm.repository.api.LogCommandBuilder - * @apiviz.uses sonia.scm.repository.api.TagsCommandBuilder - * @apiviz.uses sonia.scm.repository.api.BranchesCommandBuilder - * @apiviz.uses sonia.scm.repository.api.IncomingCommandBuilder - * @apiviz.uses sonia.scm.repository.api.OutgoingCommandBuilder - * @apiviz.uses sonia.scm.repository.api.PullCommandBuilder - * @apiviz.uses sonia.scm.repository.api.PushCommandBuilder - * @apiviz.uses sonia.scm.repository.api.BundleCommandBuilder - * @apiviz.uses sonia.scm.repository.api.UnbundleCommandBuilder - * @apiviz.uses sonia.scm.repository.api.MergeCommandBuilder * @since 1.17 */ public final class RepositoryService implements Closeable { @@ -80,7 +65,10 @@ public final class RepositoryService implements Closeable { private final CacheManager cacheManager; private final PreProcessorUtil preProcessorUtil; private final RepositoryServiceProvider provider; + + @Getter private final Repository repository; + @SuppressWarnings({"rawtypes", "java:S3740"}) private final Set protocolProviders; private final WorkdirProvider workdirProvider; @@ -119,7 +107,7 @@ public final class RepositoryService implements Closeable { /** * Closes the connection to the repository and releases all locks - * and resources. This method should be called in a finally block e.g.: + * and resources. This method should be called in a finally block; e.g.: * *

    * RepositoryService service = null;
@@ -143,7 +131,29 @@ public final class RepositoryService implements Closeable {
   }
 
   /**
-   * The blame command shows changeset information by line for a given file.
+   * Returns true if the command is supported by the repository service.
+   *
+   * @param command command
+   * @return true if the command is supported
+   */
+  public boolean isSupported(Command command) {
+    return provider.getSupportedCommands().contains(command);
+  }
+
+  /**
+   * Returns true if the feature is supported by the repository service.
+   *
+   * @param feature feature
+   * @return true if the feature is supported
+   * @since 1.25
+   */
+  public boolean isSupported(Feature feature) {
+    return provider.getSupportedFeatures().contains(feature);
+  }
+
+  /**
+   * Creates a {@link BlameCommandBuilder}. It can take the respective parameters and be executed to show
+   * changeset information by line for a given file.
    *
    * @return instance of {@link BlameCommandBuilder}
    * @throws CommandNotSupportedException if the command is not supported
@@ -157,21 +167,8 @@ public final class RepositoryService implements Closeable {
   }
 
   /**
-   * The branches command list all repository branches.
-   *
-   * @return instance of {@link BranchesCommandBuilder}
-   * @throws CommandNotSupportedException if the command is not supported
-   *                                      by the implementation of the repository service provider.
-   */
-  public BranchesCommandBuilder getBranchesCommand() {
-    LOG.debug("create branches command for repository {}", repository);
-
-    return new BranchesCommandBuilder(cacheManager,
-      provider.getBranchesCommand(), repository);
-  }
-
-  /**
-   * The branch command creates new branches.
+   * Creates a {@link BranchCommandBuilder}. It can take the respective parameters and be executed to
+   * create new branches, if supported by the particular SCM system.
    *
    * @return instance of {@link BranchCommandBuilder}
    * @throws CommandNotSupportedException if the command is not supported
@@ -186,7 +183,37 @@ public final class RepositoryService implements Closeable {
   }
 
   /**
-   * The browse command allows browsing of a repository.
+   * Creates a {@link BranchDetailsCommandBuilder}. It can take the respective parameters and be executed to
+   * get details for a branch.
+   *
+   * @return instance of {@link BranchDetailsCommand}
+   * @throws CommandNotSupportedException if the command is not supported
+   *                                      by the implementation of the repository service provider.
+   * @since 2.28.0
+   */
+  public BranchDetailsCommandBuilder getBranchDetailsCommand() {
+    LOG.debug("create branch details command for repository {}", repository);
+    return new BranchDetailsCommandBuilder(repository, provider.getBranchDetailsCommand(), cacheManager);
+  }
+
+  /**
+   * Creates a {@link BranchesCommandBuilder}. It can take the respective parameters and be executed to list
+   * all repository branches.
+   *
+   * @return instance of {@link BranchesCommandBuilder}
+   * @throws CommandNotSupportedException if the command is not supported
+   *                                      by the implementation of the repository service provider.
+   */
+  public BranchesCommandBuilder getBranchesCommand() {
+    LOG.debug("create branches command for repository {}", repository);
+
+    return new BranchesCommandBuilder(cacheManager,
+      provider.getBranchesCommand(), repository);
+  }
+
+  /**
+   * Creates a {@link BrowseCommandBuilder}. It can take the respective parameters and be executed to
+   * browse for content within a repository.
    *
    * @return instance of {@link BrowseCommandBuilder}
    * @throws CommandNotSupportedException if the command is not supported
@@ -200,7 +227,8 @@ public final class RepositoryService implements Closeable {
   }
 
   /**
-   * The bundle command creates an archive from the repository.
+   * Creates a {@link BundleCommandBuilder}. It can take the respective parameters and be executed to
+   * create an archive from the repository.
    *
    * @return instance of {@link BundleCommandBuilder}
    * @throws CommandNotSupportedException if the command is not supported
@@ -214,7 +242,8 @@ public final class RepositoryService implements Closeable {
   }
 
   /**
-   * The cat command show the content of a given file.
+   * Creates a {@link CatCommandBuilder}. It can take the respective parameters and be executed to
+   * show the content of a given file.
    *
    * @return instance of {@link CatCommandBuilder}
    * @throws CommandNotSupportedException if the command is not supported
@@ -227,8 +256,21 @@ public final class RepositoryService implements Closeable {
   }
 
   /**
-   * The diff command shows differences between revisions for a specified file
-   * or the entire revision.
+   * Creates a {@link ChangesetsCommandBuilder}. It can take the respective parameters and be executed to
+   * retrieve a set of at least one changeset.
+   *
+   * @return Instance of {@link ChangesetsCommandBuilder}.
+   * @throws CommandNotSupportedException if the command is not supported by
+   *                                      the implementation of the {@link RepositoryServiceProvider}.
+   */
+  public ChangesetsCommandBuilder getChangesetsCommand() {
+    LOG.debug("create changesets command for repository {}", repository);
+    return new ChangesetsCommandBuilder(repository, provider.getChangesetsCommand());
+  }
+
+  /**
+   * Creates a {@link DiffCommandBuilder}. It can take the respective parameters and be executed to
+   * show differences between revisions for a specified file or the entire revision.
    *
    * @return instance of {@link DiffCommandBuilder}
    * @throws CommandNotSupportedException if the command is not supported
@@ -241,8 +283,8 @@ public final class RepositoryService implements Closeable {
   }
 
   /**
-   * The diff command shows differences between revisions for a specified file
-   * or the entire revision.
+   * Creates a {@link DiffResultCommandBuilder}. It can take the respective parameters and be executed to
+   * show differences between revisions for a specified file or the entire revision.
    *
    * @return instance of {@link DiffResultCommandBuilder}
    * @throws CommandNotSupportedException if the command is not supported
@@ -255,8 +297,36 @@ public final class RepositoryService implements Closeable {
   }
 
   /**
-   * The incoming command shows new {@link Changeset}s found in a different
-   * repository location.
+   * Creates a {@link FullHealthCheckCommandBuilder}. It can take the respective parameters and be executed to
+   * inspect a repository profoundly. This might take a while in contrast to the lighter checks executed at startup.
+   *
+   * @return instance of {@link FullHealthCheckCommandBuilder}
+   * @throws CommandNotSupportedException if the command is not supported
+   *                                      by the implementation of the repository service provider.
+   * @since 2.17.0
+   */
+  public FullHealthCheckCommandBuilder getFullCheckCommand() {
+    LOG.debug("create full check command for repository {}", repository);
+    return new FullHealthCheckCommandBuilder(provider.getFullHealthCheckCommand());
+  }
+
+  /**
+   * Creates a {@link FileLockCommandBuilder}. It can take the respective parameters and be executed to
+   * lock and unlock files.
+   *
+   * @return instance of {@link FileLockCommandBuilder}
+   * @throws CommandNotSupportedException if the command is not supported
+   *                                      by the implementation of the repository service provider.
+   * @since 2.26.0
+   */
+  public FileLockCommandBuilder getLockCommand() {
+    LOG.debug("create lock command for repository {}", repository);
+    return new FileLockCommandBuilder(provider.getFileLockCommand(), repository);
+  }
+
+  /**
+   * Creates a {@link IncomingCommandBuilder}. It can take the respective parameters and be executed to
+   * show new {@link Changeset}s found in a different repository location.
    *
    * @return instance of {@link IncomingCommandBuilder}
    * @throws CommandNotSupportedException if the command is not supported
@@ -271,7 +341,8 @@ public final class RepositoryService implements Closeable {
   }
 
   /**
-   * The log command shows revision history of entire repository or files.
+   * Creates a {@link LogCommandBuilder}. It can take the respective parameters and be executed to
+   * show revision history of entire repository or files.
    *
    * @return instance of {@link LogCommandBuilder}
    * @throws CommandNotSupportedException if the command is not supported
@@ -285,7 +356,54 @@ public final class RepositoryService implements Closeable {
   }
 
   /**
-   * The modification command shows file modifications in a revision.
+   * Creates a {@link LookupCommandBuilder}. It can take the respective parameters and be executed to
+   * conduct a lookup which returns additional information for the repository.
+   *
+   * @return instance of {@link LookupCommandBuilder}
+   * @throws CommandNotSupportedException if the command is not supported
+   *                                      by the implementation of the repository service provider.
+   * @since 2.10.0
+   */
+  public LookupCommandBuilder getLookupCommand() {
+    LOG.debug("create lookup command for repository {}", repository);
+    return new LookupCommandBuilder(provider.getLookupCommand());
+  }
+
+  /**
+   * Creates a {@link MergeCommandBuilder}. It can take the respective parameters and be executed to
+   * conduct a merge of two branches.
+   *
+   * @return instance of {@link MergeCommandBuilder}
+   * @throws CommandNotSupportedException if the command is not supported
+   *                                      by the implementation of the repository service provider.
+   * @since 2.0.0
+   */
+  public MergeCommandBuilder getMergeCommand() {
+    RepositoryReadOnlyChecker.checkReadOnly(getRepository());
+    LOG.debug("create merge command for repository {}", repository);
+
+    return new MergeCommandBuilder(provider.getMergeCommand(), eMail);
+  }
+
+  /**
+   * Creates a {@link MirrorCommandBuilder}. It can take the respective parameters and be executed to
+   * create a 'mirror' of an existing repository (specified by a URL) by copying all data
+   * to the repository of this service. Therefore, this repository has to be empty (otherwise the behaviour is
+   * not specified).
+   *
+   * @return instance of {@link MirrorCommandBuilder}
+   * @throws CommandNotSupportedException if the command is not supported
+   *                                      by the implementation of the repository service provider.
+   * @since 2.19.0
+   */
+  public MirrorCommandBuilder getMirrorCommand() {
+    LOG.debug("create mirror command for repository {}", repository);
+    return new MirrorCommandBuilder(provider.getMirrorCommand(), repository);
+  }
+
+  /**
+   * Creates a {@link ModificationsCommandBuilder}. It can take the respective parameters and be executed to
+   * show file modifications in a revision.
    *
    * @return instance of {@link ModificationsCommandBuilder}
    * @throws CommandNotSupportedException if the command is not supported
@@ -297,7 +415,25 @@ public final class RepositoryService implements Closeable {
   }
 
   /**
-   * The outgoing command show {@link Changeset}s not found in a remote repository.
+   * Creates a {@link ModifyCommandBuilder}. It can take the respective parameters and be executed to
+   * makes changes to the files within a changeset.
+   *
+   * @return instance of {@link ModifyCommandBuilder}
+   * @throws CommandNotSupportedException if the command is not supported
+   *                                      by the implementation of the repository service provider.
+   * @see ModifyCommandBuilder
+   * @since 2.0.0
+   */
+  public ModifyCommandBuilder getModifyCommand() {
+    RepositoryReadOnlyChecker.checkReadOnly(getRepository());
+    LOG.debug("create modify command for repository {}", repository);
+
+    return new ModifyCommandBuilder(provider.getModifyCommand(), workdirProvider, repository.getId(), eMail);
+  }
+
+  /**
+   * Creates an {@link OutgoingCommandBuilder}. It can take the respective parameters and be executed to
+   * show {@link Changeset}s not found in a remote repository.
    *
    * @return instance of {@link OutgoingCommandBuilder}
    * @throws CommandNotSupportedException if the command is not supported
@@ -312,7 +448,8 @@ public final class RepositoryService implements Closeable {
   }
 
   /**
-   * The pull command pull changes from a other repository.
+   * Creates a {@link PullCommandBuilder}. It can take the respective parameters and be executed to
+   * pull changes from another repository.
    *
    * @return instance of {@link PullCommandBuilder}
    * @throws CommandNotSupportedException if the command is not supported
@@ -327,7 +464,8 @@ public final class RepositoryService implements Closeable {
   }
 
   /**
-   * The push command pushes changes to a other repository.
+   * Creates a {@link PushCommandBuilder}. It can take the respective parameters and be executed to
+   * push changes to another repository.
    *
    * @return instance of {@link PushCommandBuilder}
    * @throws CommandNotSupportedException if the command is not supported
@@ -341,12 +479,18 @@ public final class RepositoryService implements Closeable {
   }
 
   /**
-   * Returns the repository of this service.
+   * Creates a {@link RevertCommandBuilder}. It can take the respective parameters and be executed to
+   * apply a revert of a chosen changeset onto the given repository/branch combination.
    *
-   * @return repository of this service
+   * @return Instance of {@link RevertCommandBuilder}.
+   * @throws CommandNotSupportedException if the command is not supported by
+   *                                      the implementation of the {@link RepositoryServiceProvider}.
+   * @since 3.8
+   * @see RevertCommandBuilder
    */
-  public Repository getRepository() {
-    return repository;
+  public RevertCommandBuilder getRevertCommand() {
+    LOG.debug("create revert command for repository {}", repository);
+    return new RevertCommandBuilder(provider.getRevertCommand(), eMail);
   }
 
   /**
@@ -376,7 +520,8 @@ public final class RepositoryService implements Closeable {
   }
 
   /**
-   * The unbundle command restores a repository from the given bundle.
+   * Creates an {@link UnbundleCommandBuilder}. It can take the respective parameters and be executed to
+   * restore a repository from the given bundle.
    *
    * @return instance of {@link UnbundleCommandBuilder}
    * @throws CommandNotSupportedException if the command is not supported
@@ -390,137 +535,6 @@ public final class RepositoryService implements Closeable {
       repository);
   }
 
-  /**
-   * The merge command executes a merge of two branches. It is possible to do a dry run to check, whether the given
-   * branches can be merged without conflicts.
-   *
-   * @return instance of {@link MergeCommandBuilder}
-   * @throws CommandNotSupportedException if the command is not supported
-   *                                      by the implementation of the repository service provider.
-   * @since 2.0.0
-   */
-  public MergeCommandBuilder getMergeCommand() {
-    RepositoryReadOnlyChecker.checkReadOnly(getRepository());
-    LOG.debug("create merge command for repository {}", repository);
-
-    return new MergeCommandBuilder(provider.getMergeCommand(), eMail);
-  }
-
-  /**
-   * The modify command makes changes to the head of a branch. It is possible to
-   * 
    - *
  • create new files
  • - *
  • delete existing files
  • - *
  • modify/replace files
  • - *
  • move files
  • - *
- * - * @return instance of {@link ModifyCommandBuilder} - * @throws CommandNotSupportedException if the command is not supported - * by the implementation of the repository service provider. - * @since 2.0.0 - */ - public ModifyCommandBuilder getModifyCommand() { - RepositoryReadOnlyChecker.checkReadOnly(getRepository()); - LOG.debug("create modify command for repository {}", repository); - - return new ModifyCommandBuilder(provider.getModifyCommand(), workdirProvider, repository.getId(), eMail); - } - - /** - * The lookup command executes a lookup which returns additional information for the repository. - * - * @return instance of {@link LookupCommandBuilder} - * @throws CommandNotSupportedException if the command is not supported - * by the implementation of the repository service provider. - * @since 2.10.0 - */ - public LookupCommandBuilder getLookupCommand() { - LOG.debug("create lookup command for repository {}", repository); - return new LookupCommandBuilder(provider.getLookupCommand()); - } - - /** - * The full health check command inspects a repository in a way, that might take a while in contrast to the - * light checks executed at startup. - * - * @return instance of {@link FullHealthCheckCommandBuilder} - * @throws CommandNotSupportedException if the command is not supported - * by the implementation of the repository service provider. - * @since 2.17.0 - */ - public FullHealthCheckCommandBuilder getFullCheckCommand() { - LOG.debug("create full check command for repository {}", repository); - return new FullHealthCheckCommandBuilder(provider.getFullHealthCheckCommand()); - } - - /** - * The mirror command creates a 'mirror' of an existing repository (specified by a URL) by copying all data - * to the repository of this service. Therefore this repository has to be empty (otherwise the behaviour is - * not specified). - * - * @return instance of {@link MirrorCommandBuilder} - * @throws CommandNotSupportedException if the command is not supported - * by the implementation of the repository service provider. - * @since 2.19.0 - */ - public MirrorCommandBuilder getMirrorCommand() { - LOG.debug("create mirror command for repository {}", repository); - return new MirrorCommandBuilder(provider.getMirrorCommand(), repository); - } - - /** - * Lock and unlock files. - * - * @return instance of {@link FileLockCommandBuilder} - * @throws CommandNotSupportedException if the command is not supported - * by the implementation of the repository service provider. - * @since 2.26.0 - */ - public FileLockCommandBuilder getLockCommand() { - LOG.debug("create lock command for repository {}", repository); - return new FileLockCommandBuilder(provider.getFileLockCommand(), repository); - } - - /** - * Get details for a branch. - * - * @return instance of {@link BranchDetailsCommand} - * @throws CommandNotSupportedException if the command is not supported - * by the implementation of the repository service provider. - * @since 2.28.0 - */ - public BranchDetailsCommandBuilder getBranchDetailsCommand() { - LOG.debug("create branch details command for repository {}", repository); - return new BranchDetailsCommandBuilder(repository, provider.getBranchDetailsCommand(), cacheManager); - } - - public ChangesetsCommandBuilder getChangesetsCommand() { - LOG.debug("create changesets command for repository {}", repository); - return new ChangesetsCommandBuilder(repository, provider.getChangesetsCommand()); - } - - /** - * Returns true if the command is supported by the repository service. - * - * @param command command - * @return true if the command is supported - */ - public boolean isSupported(Command command) { - return provider.getSupportedCommands().contains(command); - } - - /** - * Returns true if the feature is supported by the repository service. - * - * @param feature feature - * @return true if the feature is supported - * @since 1.25 - */ - public boolean isSupported(Feature feature) { - return provider.getSupportedFeatures().contains(feature); - } - public Stream getSupportedProtocols() { return protocolProviders.stream() .filter(protocolProvider -> protocolProvider.getType().equals(getRepository().getType())) diff --git a/scm-core/src/main/java/sonia/scm/repository/api/RevertCommandBuilder.java b/scm-core/src/main/java/sonia/scm/repository/api/RevertCommandBuilder.java new file mode 100644 index 0000000000..fb41ad49aa --- /dev/null +++ b/scm-core/src/main/java/sonia/scm/repository/api/RevertCommandBuilder.java @@ -0,0 +1,110 @@ +/* + * Copyright (c) 2020 - present Cloudogu GmbH + * + * This program is free software: you can redistribute it and/or modify it under + * the terms of the GNU Affero General Public License as published by the Free + * Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS + * FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more + * details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +package sonia.scm.repository.api; + +import com.google.common.base.Preconditions; +import jakarta.annotation.Nullable; +import sonia.scm.repository.spi.RevertCommand; +import sonia.scm.repository.spi.RevertCommandRequest; +import sonia.scm.repository.util.AuthorUtil; +import sonia.scm.user.DisplayUser; +import sonia.scm.user.EMail; + +/** + * Applies a revert of a chosen changeset onto the given repository/branch combination. + * + * @since 3.8 + */ +public final class RevertCommandBuilder { + + private final RevertCommand command; + private final RevertCommandRequest request; + + @Nullable + private final EMail email; + + /** + * @param command A {@link RevertCommand} implementation provided by some source. + */ + public RevertCommandBuilder(RevertCommand command, @Nullable EMail email) { + this.command = command; + this.email = email; + this.request = new RevertCommandRequest(); + } + + /** + * Use this to set the author of the revert commit manually. If this is omitted, the currently logged-in user will be + * used instead. If the given user object does not have an email address, we will use {@link EMail} to compute a + * fallback address. + * + * @param author Author entity. + * @return This instance. + */ + public RevertCommandBuilder setAuthor(DisplayUser author) { + request.setAuthor(AuthorUtil.createAuthorWithMailFallback(author, email)); + return this; + } + + /** + * Obligatory value. + * + * @param revision Identifier of the revision. + * @return This instance. + */ + public RevertCommandBuilder setRevision(String revision) { + request.setRevision(revision); + return this; + } + + /** + * This is an optional parameter. Not every SCM system supports branches. + * If null or empty and supported by the SCM, the default branch of the repository shall be used. + * + * @param branch Name of the branch. + * @return This instance. + */ + public RevertCommandBuilder setBranch(String branch) { + request.setBranch(branch); + return this; + } + + /** + * This is an optional parameter. If null or empty, a default message will be set. + * + * @param message Particular message. + * @return This instance. + */ + public RevertCommandBuilder setMessage(String message) { + request.setMessage(message); + return this; + } + + /** + * Executes the revert with the given builder parameters. + * + * @return {@link RevertCommandResult} with information about the executed revert. + */ + public RevertCommandResult execute() { + AuthorUtil.setAuthorIfNotAvailable(request, email); + Preconditions.checkArgument(request.isValid(), "Revert request is invalid, request was: %s", request); + return command.revert(request); + } + + protected RevertCommandRequest getRequest() { + return this.request; + } +} diff --git a/scm-core/src/main/java/sonia/scm/repository/api/RevertCommandResult.java b/scm-core/src/main/java/sonia/scm/repository/api/RevertCommandResult.java new file mode 100644 index 0000000000..17fab2fd65 --- /dev/null +++ b/scm-core/src/main/java/sonia/scm/repository/api/RevertCommandResult.java @@ -0,0 +1,81 @@ +/* + * Copyright (c) 2020 - present Cloudogu GmbH + * + * This program is free software: you can redistribute it and/or modify it under + * the terms of the GNU Affero General Public License as published by the Free + * Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS + * FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more + * details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +package sonia.scm.repository.api; + +import lombok.Getter; + +import java.util.Collection; +import java.util.HashSet; + +import static java.util.Collections.emptyList; + +/** + * Contains the result of an executed revert command. + * + * @since 3.8 + */ +@Getter +public class RevertCommandResult { + + /** + * The identifier of the revision after the applied revert. + */ + private final String revision; + /** + * A collection of files where conflicts occur. + */ + private final Collection filesWithConflict; + + /** + * Creates a {@link RevertCommandResult}. + * + * @param revision revision identifier + * @param filesWithConflict a collection of files where conflicts occur + */ + public RevertCommandResult(String revision, Collection filesWithConflict) { + this.revision = revision; + this.filesWithConflict = filesWithConflict; + } + + /** + * Used to indicate a successful revert. + * + * @param newHeadRevision id of the newly created revert + * @return {@link RevertCommandResult} + */ + public static RevertCommandResult success(String newHeadRevision) { + return new RevertCommandResult(newHeadRevision, emptyList()); + } + + /** + * Used to indicate a failed revert. + * + * @param filesWithConflict collection of conflicting files + * @return {@link RevertCommandResult} + */ + public static RevertCommandResult failure(Collection filesWithConflict) { + return new RevertCommandResult(null, new HashSet<>(filesWithConflict)); + } + + /** + * If this returns true, the revert was successful. If this returns false, there may have + * been problems like a merge conflict after the revert. + */ + public boolean isSuccessful() { + return filesWithConflict.isEmpty() && revision != null; + } +} diff --git a/scm-core/src/main/java/sonia/scm/repository/spi/RepositoryServiceProvider.java b/scm-core/src/main/java/sonia/scm/repository/spi/RepositoryServiceProvider.java index 48c979b6ad..d7f10f4559 100644 --- a/scm-core/src/main/java/sonia/scm/repository/spi/RepositoryServiceProvider.java +++ b/scm-core/src/main/java/sonia/scm/repository/spi/RepositoryServiceProvider.java @@ -16,6 +16,7 @@ package sonia.scm.repository.spi; +import lombok.extern.slf4j.Slf4j; import sonia.scm.repository.Feature; import sonia.scm.repository.api.Command; import sonia.scm.repository.api.CommandNotSupportedException; @@ -26,13 +27,17 @@ import java.util.Collections; import java.util.Set; /** + * This class is an extension base for SCM system providers to implement command functionalitites. + * If unimplemented, the methods within this class throw {@link CommandNotSupportedException}. These are not supposed + * to be called if unimplemented for an SCM system. * + * @see sonia.scm.repository.api.RepositoryService * @since 1.17 */ -public abstract class RepositoryServiceProvider implements Closeable -{ +@Slf4j +public abstract class RepositoryServiceProvider implements Closeable { + - public abstract Set getSupportedCommands(); @@ -41,195 +46,118 @@ public abstract class RepositoryServiceProvider implements Closeable * free resources, close connections or release locks than you have to * override this method. * - * * @throws IOException */ @Override - public void close() throws IOException - { - - // should be implmentented from a service provider + public void close() throws IOException { + log.warn("warning: close() has been called without implementation from a service provider."); } - - - public BlameCommand getBlameCommand() - { - throw new CommandNotSupportedException(Command.BLAME); - } - - - public BranchesCommand getBranchesCommand() - { - throw new CommandNotSupportedException(Command.BRANCHES); - } - - - public BranchCommand getBranchCommand() - { - throw new CommandNotSupportedException(Command.BRANCH); - } - - - public BrowseCommand getBrowseCommand() - { - throw new CommandNotSupportedException(Command.BROWSE); - } - - /** - * @since 1.43 - */ - public BundleCommand getBundleCommand() - { - throw new CommandNotSupportedException(Command.BUNDLE); - } - - public CatCommand getCatCommand() - { - throw new CommandNotSupportedException(Command.CAT); - } - - - public DiffCommand getDiffCommand() - { - throw new CommandNotSupportedException(Command.DIFF); - } - - public DiffResultCommand getDiffResultCommand() - { - throw new CommandNotSupportedException(Command.DIFF_RESULT); - } - - /** - * @since 1.31 - */ - public IncomingCommand getIncomingCommand() - { - throw new CommandNotSupportedException(Command.INCOMING); - } - - - public LogCommand getLogCommand() - { - throw new CommandNotSupportedException(Command.LOG); - } - - /** - * Get the corresponding {@link ModificationsCommand} implemented from the Plugins - * - * @return the corresponding {@link ModificationsCommand} implemented from the Plugins - * @throws CommandNotSupportedException if there is no Implementation - */ - public ModificationsCommand getModificationsCommand() { - throw new CommandNotSupportedException(Command.MODIFICATIONS); - } - - /** - * @since 1.31 - */ - public OutgoingCommand getOutgoingCommand() - { - throw new CommandNotSupportedException(Command.OUTGOING); - } - - /** - * @since 1.31 - */ - public PullCommand getPullCommand() - { - throw new CommandNotSupportedException(Command.PULL); - } - - /** - * @since 1.31 - */ - public PushCommand getPushCommand() - { - throw new CommandNotSupportedException(Command.PUSH); - } - - - public Set getSupportedFeatures() - { + public Set getSupportedFeatures() { return Collections.emptySet(); } - - public TagsCommand getTagsCommand() - { - throw new CommandNotSupportedException(Command.TAGS); + public BlameCommand getBlameCommand() { + throw new CommandNotSupportedException(Command.BLAME); } - - /** - * @since 2.11.0 - */ - public TagCommand getTagCommand() - { - throw new CommandNotSupportedException(Command.TAG); + public BranchesCommand getBranchesCommand() { + throw new CommandNotSupportedException(Command.BRANCHES); } - /** - * @since 1.43 - */ - public UnbundleCommand getUnbundleCommand() - { - throw new CommandNotSupportedException(Command.UNBUNDLE); + public BranchCommand getBranchCommand() { + throw new CommandNotSupportedException(Command.BRANCH); } - /** - * @since 2.0 - */ - public MergeCommand getMergeCommand() - { - throw new CommandNotSupportedException(Command.MERGE); - } - - /** - * @since 2.0 - */ - public ModifyCommand getModifyCommand() - { - throw new CommandNotSupportedException(Command.MODIFY); - } - - /** - * @since 2.10.0 - */ - public LookupCommand getLookupCommand() - { - throw new CommandNotSupportedException(Command.LOOKUP); - } - - /** - * @since 2.17.0 - */ - public FullHealthCheckCommand getFullHealthCheckCommand() { - throw new CommandNotSupportedException(Command.FULL_HEALTH_CHECK); - } - - /** - * @since 2.19.0 - */ - public MirrorCommand getMirrorCommand() { - throw new CommandNotSupportedException(Command.MIRROR); - } - - /** - * @since 2.26.0 - */ - public FileLockCommand getFileLockCommand() { - throw new CommandNotSupportedException(Command.FILE_LOCK); - } - - /** - * @since 2.28.0 - */ public BranchDetailsCommand getBranchDetailsCommand() { throw new CommandNotSupportedException(Command.BRANCH_DETAILS); } + public BrowseCommand getBrowseCommand() { + throw new CommandNotSupportedException(Command.BROWSE); + } + + public BundleCommand getBundleCommand() { + throw new CommandNotSupportedException(Command.BUNDLE); + } + + public CatCommand getCatCommand() { + throw new CommandNotSupportedException(Command.CAT); + } + public ChangesetsCommand getChangesetsCommand() { throw new CommandNotSupportedException(Command.CHANGESETS); } + + public DiffCommand getDiffCommand() { + throw new CommandNotSupportedException(Command.DIFF); + } + + public DiffResultCommand getDiffResultCommand() { + throw new CommandNotSupportedException(Command.DIFF_RESULT); + } + + public FileLockCommand getFileLockCommand() { + throw new CommandNotSupportedException(Command.FILE_LOCK); + } + + public FullHealthCheckCommand getFullHealthCheckCommand() { + throw new CommandNotSupportedException(Command.FULL_HEALTH_CHECK); + } + + public IncomingCommand getIncomingCommand() { + throw new CommandNotSupportedException(Command.INCOMING); + } + + public LogCommand getLogCommand() { + throw new CommandNotSupportedException(Command.LOG); + } + + public LookupCommand getLookupCommand() { + throw new CommandNotSupportedException(Command.LOOKUP); + } + + public MergeCommand getMergeCommand() { + throw new CommandNotSupportedException(Command.MERGE); + } + + public MirrorCommand getMirrorCommand() { + throw new CommandNotSupportedException(Command.MIRROR); + } + + public ModificationsCommand getModificationsCommand() { + throw new CommandNotSupportedException(Command.MODIFICATIONS); + } + + public ModifyCommand getModifyCommand() { + throw new CommandNotSupportedException(Command.MODIFY); + } + + public OutgoingCommand getOutgoingCommand() { + throw new CommandNotSupportedException(Command.OUTGOING); + } + + public PullCommand getPullCommand() { + throw new CommandNotSupportedException(Command.PULL); + } + + public PushCommand getPushCommand() { + throw new CommandNotSupportedException(Command.PUSH); + } + + public RevertCommand getRevertCommand() { + throw new CommandNotSupportedException(Command.REVERT); + } + + public TagsCommand getTagsCommand() { + throw new CommandNotSupportedException(Command.TAGS); + } + + public TagCommand getTagCommand() { + throw new CommandNotSupportedException(Command.TAG); + } + + public UnbundleCommand getUnbundleCommand() { + throw new CommandNotSupportedException(Command.UNBUNDLE); + } } diff --git a/scm-core/src/main/java/sonia/scm/repository/spi/Resetable.java b/scm-core/src/main/java/sonia/scm/repository/spi/Resetable.java index c1e685318b..ca2d6d4bfa 100644 --- a/scm-core/src/main/java/sonia/scm/repository/spi/Resetable.java +++ b/scm-core/src/main/java/sonia/scm/repository/spi/Resetable.java @@ -17,10 +17,12 @@ package sonia.scm.repository.spi; /** + * @deprecated This interface may get removed at some point in the future. * @since 1.17 */ +@Deprecated(since = "3.8") public interface Resetable { - public void reset(); + void reset(); } diff --git a/scm-core/src/main/java/sonia/scm/repository/spi/RevertCommand.java b/scm-core/src/main/java/sonia/scm/repository/spi/RevertCommand.java new file mode 100644 index 0000000000..a355084f6f --- /dev/null +++ b/scm-core/src/main/java/sonia/scm/repository/spi/RevertCommand.java @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2020 - present Cloudogu GmbH + * + * This program is free software: you can redistribute it and/or modify it under + * the terms of the GNU Affero General Public License as published by the Free + * Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS + * FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more + * details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +package sonia.scm.repository.spi; + +import sonia.scm.repository.api.RevertCommandResult; + +/** + * Removes the changes from a particular changeset as a revert. This, in turn, will result a new changeset. + * + * @since 3.8 + */ +public interface RevertCommand { + + /** + * Executes a revert. + * @param request parameter set for this command. + * @see RevertCommand + * @return result set of the executed command (see {@link RevertCommandResult}). + */ + RevertCommandResult revert(RevertCommandRequest request); +} diff --git a/scm-core/src/main/java/sonia/scm/repository/spi/RevertCommandRequest.java b/scm-core/src/main/java/sonia/scm/repository/spi/RevertCommandRequest.java new file mode 100644 index 0000000000..047dbf6f7b --- /dev/null +++ b/scm-core/src/main/java/sonia/scm/repository/spi/RevertCommandRequest.java @@ -0,0 +1,69 @@ +/* + * Copyright (c) 2020 - present Cloudogu GmbH + * + * This program is free software: you can redistribute it and/or modify it under + * the terms of the GNU Affero General Public License as published by the Free + * Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS + * FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more + * details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +package sonia.scm.repository.spi; + +import lombok.Getter; +import lombok.Setter; +import lombok.ToString; +import sonia.scm.Validateable; + +import sonia.scm.repository.Person; +import sonia.scm.repository.util.AuthorUtil.CommandWithAuthor; + +import java.util.Optional; + +/** + * This class contains the information to run {@link RevertCommand#revert(RevertCommandRequest)}. + + * @since 3.8 + */ +@Setter +@ToString +public class RevertCommandRequest implements Validateable, CommandWithAuthor { + + @Getter + private Person author; + + @Getter + private String revision; + + /** + * Reverts can be signed with a GPG key. This is set as true by default. + */ + @Getter + private boolean sign = true; + + private String branch; + + private String message; + + public Optional getBranch() { + return Optional.ofNullable(branch); + } + + public Optional getMessage() { + return Optional.ofNullable(message); + } + + @Override + public boolean isValid() { + boolean validBranch = branch == null || !branch.isEmpty(); + boolean validMessage = message == null || !message.isEmpty(); + return revision != null && author != null && validBranch && validMessage; + } + +} diff --git a/scm-core/src/main/java/sonia/scm/repository/util/AuthorUtil.java b/scm-core/src/main/java/sonia/scm/repository/util/AuthorUtil.java index acba72d36c..2b0cbc61be 100644 --- a/scm-core/src/main/java/sonia/scm/repository/util/AuthorUtil.java +++ b/scm-core/src/main/java/sonia/scm/repository/util/AuthorUtil.java @@ -20,29 +20,61 @@ import jakarta.annotation.Nullable; import org.apache.shiro.SecurityUtils; import org.apache.shiro.subject.Subject; import sonia.scm.repository.Person; +import sonia.scm.user.DisplayUser; import sonia.scm.user.EMail; import sonia.scm.user.User; +/** + * Contains convenience methods to manage {@link CommandWithAuthor} classes. + */ public class AuthorUtil { + /** + * @see AuthorUtil#createAuthorFromSubject(EMail) + * @param request {@link CommandWithAuthor} + */ public static void setAuthorIfNotAvailable(CommandWithAuthor request) { setAuthorIfNotAvailable(request, null); } + /** + * @see AuthorUtil#createAuthorFromSubject(EMail) + * @param request {@link CommandWithAuthor} + * @param eMail {@link EMail} + */ public static void setAuthorIfNotAvailable(CommandWithAuthor request, @Nullable EMail eMail) { if (request.getAuthor() == null) { request.setAuthor(createAuthorFromSubject(eMail)); } } - private static Person createAuthorFromSubject(@Nullable EMail eMail) { - Subject subject = SecurityUtils.getSubject(); - User user = subject.getPrincipals().oneByType(User.class); + /** + * Depending on the mail input, the {@link Person} is either created by the given nullable {@link EMail} + * or the information from {@link DisplayUser} if the mail remains null. + * @param user {@link DisplayUser} + * @param eMail (nullable) {@link EMail} + * @return {@link Person} + */ + public static Person createAuthorWithMailFallback(DisplayUser user, @Nullable EMail eMail) { String name = user.getDisplayName(); String mailAddress = eMail != null ? eMail.getMailOrFallback(user) : user.getMail(); return new Person(name, mailAddress); } + /** + * Creates an author from the Apache Shiro {@link Subject} given by the {@link SecurityUtils}. + * @param eMail {@link EMail} + * @return {@link Person} + */ + private static Person createAuthorFromSubject(@Nullable EMail eMail) { + Subject subject = SecurityUtils.getSubject(); + User user = subject.getPrincipals().oneByType(User.class); + return createAuthorWithMailFallback(DisplayUser.from(user), eMail); + } + + /** + * Command whose execution includes an author as a {@link Person}. + */ public interface CommandWithAuthor { Person getAuthor(); diff --git a/scm-core/src/main/java/sonia/scm/user/DisplayUser.java b/scm-core/src/main/java/sonia/scm/user/DisplayUser.java index a38a6483d3..6619ab8233 100644 --- a/scm-core/src/main/java/sonia/scm/user/DisplayUser.java +++ b/scm-core/src/main/java/sonia/scm/user/DisplayUser.java @@ -16,8 +16,10 @@ package sonia.scm.user; +import lombok.EqualsAndHashCode; import sonia.scm.ReducedModelObject; +@EqualsAndHashCode public class DisplayUser implements ReducedModelObject { private final String id; diff --git a/scm-core/src/main/java/sonia/scm/web/VndMediaType.java b/scm-core/src/main/java/sonia/scm/web/VndMediaType.java index b4e06be0ab..9d807626a1 100644 --- a/scm-core/src/main/java/sonia/scm/web/VndMediaType.java +++ b/scm-core/src/main/java/sonia/scm/web/VndMediaType.java @@ -40,6 +40,7 @@ public class VndMediaType { public static final String REPOSITORY_PERMISSION = PREFIX + "repositoryPermission" + SUFFIX; public static final String CHANGESET = PREFIX + "changeset" + SUFFIX; public static final String CHANGESET_COLLECTION = PREFIX + "changesetCollection" + SUFFIX; + public static final String REVERT = PREFIX + "revert" + SUFFIX; public static final String MODIFICATIONS = PREFIX + "modifications" + SUFFIX; public static final String TAG = PREFIX + "tag" + SUFFIX; public static final String TAG_COLLECTION = PREFIX + "tagCollection" + SUFFIX; diff --git a/scm-core/src/test/java/sonia/scm/repository/api/RevertCommandBuilderTest.java b/scm-core/src/test/java/sonia/scm/repository/api/RevertCommandBuilderTest.java new file mode 100644 index 0000000000..0fa1aaaefd --- /dev/null +++ b/scm-core/src/test/java/sonia/scm/repository/api/RevertCommandBuilderTest.java @@ -0,0 +1,123 @@ +/* + * Copyright (c) 2020 - present Cloudogu GmbH + * + * This program is free software: you can redistribute it and/or modify it under + * the terms of the GNU Affero General Public License as published by the Free + * Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS + * FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more + * details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +package sonia.scm.repository.api; + +import org.apache.shiro.subject.PrincipalCollection; +import org.apache.shiro.subject.Subject; +import org.apache.shiro.util.ThreadContext; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import sonia.scm.repository.spi.RevertCommand; +import sonia.scm.repository.spi.RevertCommandRequest; +import sonia.scm.user.DisplayUser; +import sonia.scm.user.EMail; +import sonia.scm.user.User; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class RevertCommandBuilderTest { + + @Mock + private RevertCommand revertCommand; + @Mock + private EMail eMail; + + @InjectMocks + private RevertCommandBuilder revertCommandBuilder; + + @BeforeEach + void prepareCommandBuilder() { + revertCommandBuilder.setRevision("irrelevant"); + } + + @Test + void shouldUseMailAddressFromEMailFallback() { + User user = new User("dent", "Arthur Dent", null); + DisplayUser author = DisplayUser.from(user); + when(eMail.getMailOrFallback(author)).thenReturn("dent@hitchhiker.com"); + revertCommandBuilder.setAuthor(author); + revertCommandBuilder.execute(); + + verify(revertCommand).revert(argThat(revertCommandRequest -> { + assertThat(revertCommandRequest.getAuthor().getMail()).isEqualTo("dent@hitchhiker.com"); + return true; + })); + } + + @Test + void shouldSetAuthorFromShiroSubjectIfNotSet() { + User user = new User("dent", "Arthur Dent", null);DisplayUser author = DisplayUser.from(user); + when(eMail.getMailOrFallback(author)).thenReturn("dent@hitchhiker.com"); + mockLoggedInUser(user); + revertCommandBuilder.execute(); + RevertCommandRequest request = revertCommandBuilder.getRequest(); + + assertThat(request.getAuthor().getName()).isEqualTo("Arthur Dent"); + assertThat(request.getAuthor().getMail()).isEqualTo("dent@hitchhiker.com"); + + mockLogout(); + } + + @Test + void shouldSetAllFieldsInRequest() { + User user = new User("dent", "Arthur Dent", null); + DisplayUser author = DisplayUser.from(user); + when(eMail.getMailOrFallback(author)).thenReturn("dent@hitchhiker.com"); + revertCommandBuilder.setAuthor(author); + revertCommandBuilder.setBranch("someBranch"); + revertCommandBuilder.setMessage("someMessage"); + + RevertCommandRequest request = revertCommandBuilder.getRequest(); + + assertThat(request.getAuthor().getName()).isEqualTo(author.getDisplayName()); + assertThat(request.getBranch()).contains("someBranch"); + assertThat(request.getMessage()).contains("someMessage"); + assertThat(request.getRevision()).isEqualTo("irrelevant"); + } + + @Test + void shouldNotExecuteInvalidRequestDueToEmptyBranch() { + User user = new User("dent", "Arthur Dent", "dent@hitchhiker.com"); + revertCommandBuilder.setAuthor(DisplayUser.from(user)); + revertCommandBuilder.setBranch(""); + assertThatThrownBy(() -> revertCommandBuilder.execute()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Revert request is invalid, request was: RevertCommandRequest(author=Arthur Dent, revision=irrelevant, sign=true, branch=Optional[], message=Optional.empty)"); + } + + private void mockLoggedInUser(User loggedInUser) { + Subject subject = mock(Subject.class); + ThreadContext.bind(subject); + PrincipalCollection principals = mock(PrincipalCollection.class); + when(subject.getPrincipals()).thenReturn(principals); + when(principals.oneByType(User.class)).thenReturn(loggedInUser); + } + + private void mockLogout() { + ThreadContext.unbindSubject(); + } +} diff --git a/scm-core/src/test/java/sonia/scm/repository/util/AuthorUtilTest.java b/scm-core/src/test/java/sonia/scm/repository/util/AuthorUtilTest.java index 63b6fae494..16ef5e0841 100644 --- a/scm-core/src/test/java/sonia/scm/repository/util/AuthorUtilTest.java +++ b/scm-core/src/test/java/sonia/scm/repository/util/AuthorUtilTest.java @@ -26,6 +26,7 @@ import org.mockito.Answers; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import sonia.scm.repository.Person; +import sonia.scm.user.DisplayUser; import sonia.scm.user.EMail; import sonia.scm.user.User; @@ -54,9 +55,9 @@ class AuthorUtilTest { @Test void shouldCreateMailAddressFromEmail() { User trillian = new User("trillian"); - when(subject.getPrincipals().oneByType(User.class)).thenReturn(trillian); - when(eMail.getMailOrFallback(trillian)).thenReturn("tricia@hitchhicker.com"); + when(subject.getPrincipals().oneByType(User.class)).thenReturn(trillian); + when(eMail.getMailOrFallback(DisplayUser.from(trillian))).thenReturn("tricia@hitchhicker.com"); Command command = new Command(null); AuthorUtil.setAuthorIfNotAvailable(command, eMail); diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/GitUtil.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/GitUtil.java index f2087ac5fe..9f8b358c42 100644 --- a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/GitUtil.java +++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/GitUtil.java @@ -71,10 +71,10 @@ import static java.util.Optional.ofNullable; public final class GitUtil { - private static final GitUserAgentProvider GIT_USER_AGENT_PROVIDER = new GitUserAgentProvider(); public static final String REF_HEAD = "HEAD"; public static final String REF_HEAD_PREFIX = "refs/heads/"; public static final String REF_MAIN = "main"; + private static final GitUserAgentProvider GIT_USER_AGENT_PROVIDER = new GitUserAgentProvider(); private static final String DIRECTORY_DOTGIT = ".git"; private static final String DIRECTORY_OBJETCS = "objects"; private static final String DIRECTORY_REFS = "refs"; @@ -84,15 +84,13 @@ public final class GitUtil { private static final String REMOTE_REF = "refs/remote/scm/%s/%s"; private static final int TIMEOUT = 5; - private static final Logger logger = LoggerFactory.getLogger(GitUtil.class); private static final String REF_SPEC = "refs/heads/*:refs/heads/*"; - + private static final String GPG_HEADER = "-----BEGIN PGP SIGNATURE-----"; private GitUtil() { } - public static void close(org.eclipse.jgit.lib.Repository repo) { if (repo != null) { repo.close(); @@ -181,7 +179,6 @@ public final class GitUtil { } } - public static String getBranch(Ref ref) { String branch = null; @@ -234,7 +231,6 @@ public final class GitUtil { } } - public static Ref getBranchId(org.eclipse.jgit.lib.Repository repo, String branchName) throws IOException { @@ -291,22 +287,43 @@ public final class GitUtil { /** * Returns the commit for the given ref. * If the given ref is for a tag, the commit that this tag belongs to is returned instead. + * + * @param repository jgit repository + * @param revWalk rev walk + * @param ref commit/tag ref + * @return {@link RevCommit} + * @throws IOException exception */ public static RevCommit getCommit(org.eclipse.jgit.lib.Repository repository, RevWalk revWalk, Ref ref) throws IOException { - RevCommit commit = null; ObjectId id = ref.getPeeledObjectId(); if (id == null) { id = ref.getObjectId(); } + return getCommit(repository, revWalk, id); + } + + /** + * Returns the commit for the given object id. The id is expected to be a commit and not a tag. + * + * @param repository jgit repository + * @param revWalk rev walk + * @param id commit id + * @return {@link RevCommit} + * @throws IOException exception + * @since 3.8.0 + */ + public static RevCommit getCommit(org.eclipse.jgit.lib.Repository repository, + RevWalk revWalk, ObjectId id) throws IOException { + RevCommit commit = null; + if (id != null) { if (revWalk == null) { revWalk = new RevWalk(repository); } - commit = revWalk.parseCommit(id); } @@ -330,7 +347,6 @@ public final class GitUtil { return tag; } - public static long getCommitTime(RevCommit commit) { long date = commit.getCommitTime(); @@ -339,7 +355,6 @@ public final class GitUtil { return date; } - public static String getId(AnyObjectId objectId) { String id = Util.EMPTY_STRING; @@ -350,7 +365,6 @@ public final class GitUtil { return id; } - public static Ref getRefForCommit(org.eclipse.jgit.lib.Repository repository, ObjectId id) throws IOException { @@ -415,7 +429,6 @@ public final class GitUtil { .findFirst(); } - public static ObjectId getRevisionId(org.eclipse.jgit.lib.Repository repo, String revision) throws IOException { @@ -430,7 +443,6 @@ public final class GitUtil { return revId; } - public static String getScmRemoteRefName(Repository repository, Ref localBranch) { return getScmRemoteRefName(repository, localBranch.getName()); @@ -463,7 +475,6 @@ public final class GitUtil { return tagName; } - public static String getTagName(Ref ref) { String name = ref.getName(); @@ -474,8 +485,6 @@ public final class GitUtil { return name; } - private static final String GPG_HEADER = "-----BEGIN PGP SIGNATURE-----"; - public static Optional getTagSignature(RevObject revObject, GPG gpg, RevWalk revWalk) throws IOException { if (revObject instanceof RevTag) { final byte[] messageBytes = revWalk.getObjectReader().open(revObject.getId()).getBytes(); 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 b2f32b9a86..b40c206379 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 @@ -49,7 +49,8 @@ public class GitRepositoryServiceProvider extends RepositoryServiceProvider { Command.MIRROR, Command.FILE_LOCK, Command.BRANCH_DETAILS, - Command.CHANGESETS + Command.CHANGESETS, + Command.REVERT ); protected static final Set FEATURES = EnumSet.of( @@ -184,6 +185,11 @@ public class GitRepositoryServiceProvider extends RepositoryServiceProvider { return injector.getInstance(GitChangesetsCommand.Factory.class).create(context); } + @Override + public RevertCommand getRevertCommand() { + return injector.getInstance(GitRevertCommand.Factory.class).create(context); + } + @Override public Set getSupportedCommands() { return COMMANDS; diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitRevertCommand.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitRevertCommand.java new file mode 100644 index 0000000000..b004db0fb7 --- /dev/null +++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitRevertCommand.java @@ -0,0 +1,172 @@ +/* + * Copyright (c) 2020 - present Cloudogu GmbH + * + * This program is free software: you can redistribute it and/or modify it under + * the terms of the GNU Affero General Public License as published by the Free + * Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS + * FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more + * details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +package sonia.scm.repository.spi; + +import com.google.inject.Inject; +import com.google.inject.assistedinject.Assisted; +import lombok.extern.slf4j.Slf4j; +import org.eclipse.jgit.api.errors.CanceledException; +import org.eclipse.jgit.api.errors.UnsupportedSigningFormatException; +import org.eclipse.jgit.lib.ObjectId; +import org.eclipse.jgit.lib.Repository; +import org.eclipse.jgit.merge.MergeStrategy; +import org.eclipse.jgit.merge.RecursiveMerger; +import org.eclipse.jgit.revwalk.RevCommit; +import org.eclipse.jgit.revwalk.RevWalk; +import sonia.scm.NoChangesMadeException; +import sonia.scm.NotFoundException; +import sonia.scm.repository.GitUtil; +import sonia.scm.repository.MultipleParentsNotAllowedException; +import sonia.scm.repository.NoParentException; +import sonia.scm.repository.RepositoryManager; +import sonia.scm.repository.api.RevertCommandResult; + +import java.io.IOException; + +import static sonia.scm.ContextEntry.ContextBuilder.entity; + +@Slf4j +public class GitRevertCommand extends AbstractGitCommand implements RevertCommand { + + private final RepositoryManager repositoryManager; + private final GitRepositoryHookEventFactory eventFactory; + + @Inject + GitRevertCommand(@Assisted GitContext context, RepositoryManager repositoryManager, GitRepositoryHookEventFactory eventFactory) { + super(context); + this.repositoryManager = repositoryManager; + this.eventFactory = eventFactory; + } + + @Override + public RevertCommandResult revert(RevertCommandRequest request) { + log.debug("revert {} on {} in repository {}", + request.getRevision(), + request.getBranch().orElse("default branch"), + repository.getName()); + + try (Repository jRepository = context.open(); + RevWalk revWalk = new RevWalk(jRepository)) { + + ObjectId sourceRevision = getSourceRevision(request, jRepository, repository); + ObjectId targetRevision = getTargetRevision(request, jRepository, repository); + + RevCommit parent = getParentRevision(revWalk, sourceRevision, jRepository); + + RecursiveMerger merger = (RecursiveMerger) MergeStrategy.RECURSIVE.newMerger(jRepository, true); + merger.setBase(sourceRevision); + + boolean mergeSucceeded = merger.merge(targetRevision, parent); + + if (!mergeSucceeded) { + log.info("revert merge fail: {} on {} in repository {}", + sourceRevision.getName(), targetRevision.getName(), repository.getName()); + return RevertCommandResult.failure(MergeHelper.getFailingPaths(merger)); + } + + ObjectId oldTreeId = revWalk.parseCommit(targetRevision).getTree().toObjectId(); + ObjectId newTreeId = merger.getResultTreeId(); + if (oldTreeId.equals(newTreeId)) { + throw new NoChangesMadeException(repository); + } + + log.debug("revert {} on {} in repository {} successful, preparing commit", + sourceRevision.getName(), targetRevision.getName(), repository.getName()); + CommitHelper commitHelper = new CommitHelper(context, repositoryManager, eventFactory); + ObjectId commitId = commitHelper.createCommit( + newTreeId, + request.getAuthor(), + request.getAuthor(), + determineMessage(request, GitUtil.getCommit(jRepository, revWalk, sourceRevision)), + request.isSign(), + targetRevision + ); + + commitHelper.updateBranch( + request.getBranch().orElseGet(() -> context.getConfig().getDefaultBranch()), commitId, targetRevision + ); + + return RevertCommandResult.success(commitId.getName()); + + } catch (CanceledException | IOException | UnsupportedSigningFormatException e) { + throw new RuntimeException(e); + } + } + + private ObjectId getSourceRevision(RevertCommandRequest request, + Repository jRepository, + sonia.scm.repository.Repository sRepository) throws IOException { + ObjectId sourceRevision = GitUtil.getRevisionId(jRepository, request.getRevision()); + + if (sourceRevision == null) { + log.error("source revision not found!"); + throw NotFoundException.notFound(entity(ObjectId.class, request.getRevision()).in(sRepository)); + } + + log.debug("got source revision {} for repository {}", sourceRevision.getName(), jRepository.getIdentifier()); + return sourceRevision; + } + + private ObjectId getTargetRevision(RevertCommandRequest request, + Repository jRepository, + sonia.scm.repository.Repository sRepository) throws IOException { + if (request.getBranch().isEmpty() || request.getBranch().get().isEmpty()) { + ObjectId targetRevision = GitUtil.getRepositoryHead(jRepository); + log.debug("given target branch is empty, returning HEAD revision for repository {}", jRepository.getIdentifier()); + return targetRevision; + } + + ObjectId targetRevision = GitUtil.getRevisionId(jRepository, request.getBranch().get()); + if (targetRevision == null) { + log.error("target revision not found!"); + throw NotFoundException.notFound(entity(ObjectId.class, request.getBranch().get()).in(sRepository)); + } + + log.debug("got target revision {} for repository {}", targetRevision.getName(), jRepository.getIdentifier()); + return targetRevision; + } + + private RevCommit getParentRevision(RevWalk revWalk, ObjectId sourceRevision, Repository jRepository) throws IOException { + RevCommit source = revWalk.parseCommit(sourceRevision); + int sourceParents = source.getParentCount(); + + if (sourceParents == 0) { + throw new NoParentException(sourceRevision.getName()); + } else if (sourceParents > 1) { + throw new MultipleParentsNotAllowedException(sourceRevision.getName()); + } + + RevCommit parent = source.getParent(0); + + log.debug("got parent revision {} of revision {} for repository {}", parent.getName(), sourceRevision.getName(), jRepository.getIdentifier()); + return parent; + } + + private String determineMessage(RevertCommandRequest request, RevCommit revertedCommit) { + return request.getMessage().orElseGet(() -> { + log.debug("no custom message given, choose default message"); + return String.format(""" + Revert "%s" + + This reverts commit %s.""", revertedCommit.getShortMessage(), revertedCommit.getId().getName()); + }); + } + + public interface Factory { + RevertCommand create(GitContext context); + } +} diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/MergeHelper.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/MergeHelper.java index 8422f86665..ac574374f0 100644 --- a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/MergeHelper.java +++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/MergeHelper.java @@ -22,6 +22,7 @@ import org.eclipse.jgit.api.errors.CanceledException; import org.eclipse.jgit.api.errors.UnsupportedSigningFormatException; import org.eclipse.jgit.lib.ObjectId; import org.eclipse.jgit.lib.Repository; +import org.eclipse.jgit.merge.RecursiveMerger; import org.eclipse.jgit.merge.ResolveMerger; import org.eclipse.jgit.revwalk.RevCommit; import org.eclipse.jgit.revwalk.RevWalk; @@ -80,6 +81,15 @@ class MergeHelper { this.message = request.getMessage(); } + static Collection getFailingPaths(ResolveMerger merger) { + return merger.getMergeResults() + .entrySet() + .stream() + .filter(entry -> entry.getValue().containsConflicts()) + .map(Map.Entry::getKey) + .toList(); + } + ObjectId getTargetRevision() { return targetRevision; } @@ -107,15 +117,6 @@ class MergeHelper { } } - Collection getFailingPaths(ResolveMerger merger) { - return merger.getMergeResults() - .entrySet() - .stream() - .filter(entry -> entry.getValue().containsConflicts()) - .map(Map.Entry::getKey) - .toList(); - } - boolean isMergedInto(ObjectId baseRevision, ObjectId revisionToCheck) { try (RevWalk revWalk = new RevWalk(context.open())) { RevCommit baseCommit = revWalk.parseCommit(baseRevision); diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/web/GitServletModule.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/web/GitServletModule.java index f68367090f..9e66ce0485 100644 --- a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/web/GitServletModule.java +++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/web/GitServletModule.java @@ -57,6 +57,7 @@ import sonia.scm.repository.spi.GitModifyCommand; import sonia.scm.repository.spi.GitOutgoingCommand; import sonia.scm.repository.spi.GitPullCommand; import sonia.scm.repository.spi.GitPushCommand; +import sonia.scm.repository.spi.GitRevertCommand; import sonia.scm.repository.spi.GitTagCommand; import sonia.scm.repository.spi.GitTagsCommand; import sonia.scm.repository.spi.GitUnbundleCommand; @@ -70,6 +71,7 @@ import sonia.scm.repository.spi.OutgoingCommand; import sonia.scm.repository.spi.PostReceiveRepositoryHookEventFactory; import sonia.scm.repository.spi.PullCommand; import sonia.scm.repository.spi.PushCommand; +import sonia.scm.repository.spi.RevertCommand; import sonia.scm.repository.spi.SimpleGitWorkingCopyFactory; import sonia.scm.repository.spi.TagCommand; import sonia.scm.repository.spi.TagsCommand; @@ -119,7 +121,6 @@ public class GitServletModule extends ServletModule { install(new FactoryModuleBuilder().implement(FileLockCommand.class, GitFileLockCommand.class).build(GitFileLockCommand.Factory.class)); install(new FactoryModuleBuilder().implement(BranchDetailsCommand.class, GitBranchDetailsCommand.class).build(GitBranchDetailsCommand.Factory.class)); install(new FactoryModuleBuilder().implement(ChangesetsCommand.class, GitChangesetsCommand.class).build(GitChangesetsCommand.Factory.class)); - - + install(new FactoryModuleBuilder().implement(RevertCommand.class, GitRevertCommand.class).build(GitRevertCommand.Factory.class)); } } diff --git a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/AbstractGitCommandTestBase.java b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/AbstractGitCommandTestBase.java index 097162a788..7114b9b9e3 100644 --- a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/AbstractGitCommandTestBase.java +++ b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/AbstractGitCommandTestBase.java @@ -18,53 +18,49 @@ package sonia.scm.repository.spi; import org.junit.After; +import org.junit.jupiter.api.AfterEach; import sonia.scm.api.v2.resources.GitRepositoryConfigStoreProvider; import sonia.scm.repository.GitConfig; import sonia.scm.repository.GitRepositoryConfig; -import sonia.scm.store.InMemoryConfigurationStoreFactory; +import sonia.scm.store.InMemoryByteConfigurationStoreFactory; -public class AbstractGitCommandTestBase extends ZippedRepositoryTestBase -{ +public class AbstractGitCommandTestBase extends ZippedRepositoryTestBase { - @After - public void close() - { + private GitContext context; + + @After + @AfterEach + public void close() { if (context != null) { context.setConfig(new GitRepositoryConfig()); context.close(); } } - - protected GitContext createContext() - { - if (context == null) - { + protected GitContext createContext() { + return createContext("main"); + } + + protected GitContext createContext(String defaultBranch) { + if (context == null) { GitConfig config = new GitConfig(); - config.setDefaultBranch("master"); - context = new GitContext(repositoryDirectory, repository, new GitRepositoryConfigStoreProvider(InMemoryConfigurationStoreFactory.create()), config); + config.setDefaultBranch(defaultBranch); + GitRepositoryConfigStoreProvider storeProvider = new GitRepositoryConfigStoreProvider(new InMemoryByteConfigurationStoreFactory()); + storeProvider.setDefaultBranch(repository, defaultBranch); + context = new GitContext(repositoryDirectory, repository, storeProvider, config); } return context; } - - @Override - protected String getType() - { + protected String getType() { return "git"; } - @Override - protected String getZippedRepositoryResource() - { + protected String getZippedRepositoryResource() { return "sonia/scm/repository/spi/scm-git-spi-test.zip"; } - - //~--- fields --------------------------------------------------------------- - - private GitContext context; } diff --git a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitBranchesCommandTest.java b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitBranchesCommandTest.java index 1c5be30da7..65c95d31cd 100644 --- a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitBranchesCommandTest.java +++ b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitBranchesCommandTest.java @@ -35,9 +35,9 @@ public class GitBranchesCommandTest extends AbstractGitCommandTestBase { List branches = branchesCommand.getBranches(); - assertThat(findBranch(branches, "master")).isEqualTo( + assertThat(findBranch(branches, "main")).isEqualTo( defaultBranch( - "master", + "main", "fcd0ef1831e4002ac43ea539f4094334c79ea9ec", 1339428655000L, new Person("Zaphod Beeblebrox", "zaphod.beeblebrox@hitchhiker.com") diff --git a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitBrowseCommand_BrokenSubmoduleTest.java b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitBrowseCommand_BrokenSubmoduleTest.java index be7c67cb85..27e17c0897 100644 --- a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitBrowseCommand_BrokenSubmoduleTest.java +++ b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitBrowseCommand_BrokenSubmoduleTest.java @@ -44,7 +44,7 @@ public class GitBrowseCommand_BrokenSubmoduleTest extends AbstractGitCommandTest @Before public void createCommand() { - command = new GitBrowseCommand(createContext(), lfsBlobStoreFactory, synchronousExecutor()); + command = new GitBrowseCommand(createContext("master"), lfsBlobStoreFactory, synchronousExecutor()); } @Test diff --git a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitBrowseCommand_RecursiveDirectoryNameTest.java b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitBrowseCommand_RecursiveDirectoryNameTest.java index a12f07549c..2edf3320f1 100644 --- a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitBrowseCommand_RecursiveDirectoryNameTest.java +++ b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitBrowseCommand_RecursiveDirectoryNameTest.java @@ -71,6 +71,6 @@ public class GitBrowseCommand_RecursiveDirectoryNameTest extends AbstractGitComm } private GitBrowseCommand createCommand() { - return new GitBrowseCommand(createContext(), lfsBlobStoreFactory, synchronousExecutor()); + return new GitBrowseCommand(createContext("master"), lfsBlobStoreFactory, synchronousExecutor()); } } diff --git a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitLogCommandTest.java b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitLogCommandTest.java index faf02fc03b..21e6accc3f 100644 --- a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitLogCommandTest.java +++ b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitLogCommandTest.java @@ -68,7 +68,7 @@ public class GitLogCommandTest extends AbstractGitCommandTestBase assertEquals("86a6645eceefe8b9a247db5eb16e3d89a7e6e6d1", result.getChangesets().get(1).getId()); assertEquals("592d797cd36432e591416e8b2b98154f4f163411", result.getChangesets().get(2).getId()); assertEquals("435df2f061add3589cb326cc64be9b9c3897ceca", result.getChangesets().get(3).getId()); - assertEquals("master", result.getBranchName()); + assertEquals("main", result.getBranchName()); assertTrue(result.getChangesets().stream().allMatch(r -> r.getBranches().isEmpty())); // set default branch and fetch again @@ -271,15 +271,6 @@ public class GitLogCommandTest extends AbstractGitCommandTestBase assertEquals("3f76a12f08a6ba0dc988c68b7f0b2cd190efc3c4", c.getId()); } - @Test - public void shouldFindDefaultBranchFromHEAD() throws Exception { - setRepositoryHeadReference("ref: refs/heads/test-branch"); - - ChangesetPagingResult changesets = createCommand().getChangesets(new LogCommandRequest()); - - assertEquals("test-branch", changesets.getBranchName()); - } - @Test public void shouldFindMasterBranchWhenHEADisNoRef() throws Exception { setRepositoryHeadReference("592d797cd36432e591416e8b2b98154f4f163411"); diff --git a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitModifyCommandTestBase.java b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitModifyCommandTestBase.java index 21b1f2dc1f..0f2fa55561 100644 --- a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitModifyCommandTestBase.java +++ b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitModifyCommandTestBase.java @@ -67,7 +67,7 @@ class GitModifyCommandTestBase extends AbstractGitCommandTestBase { RepositoryHookEvent postReceiveEvent = mockEvent(POST_RECEIVE); when(eventFactory.createPostReceiveEvent(any(), any(), any(), any())).thenReturn(postReceiveEvent); return new GitModifyCommand( - createContext(), + createContext("master"), lfsBlobStoreFactory, repositoryManager, eventFactory diff --git a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitRevertCommandTest.java b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitRevertCommandTest.java new file mode 100644 index 0000000000..21efd136c0 --- /dev/null +++ b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitRevertCommandTest.java @@ -0,0 +1,460 @@ +/* + * Copyright (c) 2020 - present Cloudogu GmbH + * + * This program is free software: you can redistribute it and/or modify it under + * the terms of the GNU Affero General Public License as published by the Free + * Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS + * FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more + * details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +package sonia.scm.repository.spi; + +import org.apache.shiro.subject.SimplePrincipalCollection; +import org.apache.shiro.subject.Subject; +import org.eclipse.jgit.lib.GpgConfig; +import org.eclipse.jgit.lib.ObjectId; +import org.eclipse.jgit.lib.PersonIdent; +import org.eclipse.jgit.lib.Repository; +import org.eclipse.jgit.lib.Signers; +import org.eclipse.jgit.revwalk.RevCommit; +import org.eclipse.jgit.revwalk.RevWalk; +import org.github.sdorra.jse.ShiroExtension; +import org.github.sdorra.jse.SubjectAware; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import sonia.scm.NoChangesMadeException; +import sonia.scm.NotFoundException; +import sonia.scm.repository.GitTestHelper; +import sonia.scm.repository.GitUtil; +import sonia.scm.repository.MultipleParentsNotAllowedException; +import sonia.scm.repository.NoParentException; +import sonia.scm.repository.Person; +import sonia.scm.repository.RepositoryManager; +import sonia.scm.repository.api.RevertCommandResult; +import sonia.scm.user.User; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.Assert.assertThrows; + +@ExtendWith({MockitoExtension.class, ShiroExtension.class}) +class GitRevertCommandTest extends AbstractGitCommandTestBase { + + static final String HEAD_REVISION = "18e22df410df66f027dc49bf0f229f4b9efb8ce5"; + static final String HEAD_MINUS_0_REVISION = "9d39c9f59030fd4e3d37e1d3717bcca43a9a5eef"; + static final String CONFLICTING_TARGET_BRANCH = "conflictingTargetBranch"; + static final String CONFLICTING_SOURCE_REVISION = "0d5be1f22687d75916c82ce10eb592375ba0fb21"; + static final String PARENTLESS_REVISION = "190bc4670197edeb724f0ee1e49d3a5307635228"; + static final String DIVERGING_BRANCH = "divergingBranch"; + static final String DIVERGING_MAIN_LATEST_ANCESTOR = "0d5be1f22687d75916c82ce10eb592375ba0fb21"; + static final String DIVERGING_BRANCH_LATEST_COMMIT = "e77fd7c8cd45be992e19a6d22170ead4fcd5f9ce"; + static final String MERGED_REVISION = "00da9cca94a507346c5b8284983f8a69840cc277"; + + @Mock + RepositoryManager repositoryManager; + @Mock + GitRepositoryHookEventFactory gitRepositoryHookEventFactory; + + @Override + protected String getZippedRepositoryResource() { + return "sonia/scm/repository/spi/scm-git-spi-revert-test.zip"; + } + + @Nested + class Revert { + + @BeforeAll + public static void setSigner() { + Signers.set(GpgConfig.GpgFormat.OPENPGP, new GitTestHelper.SimpleGpgSigner()); + } + + /** + * We expect the newly created revision to be merged into the given branch. + */ + @Test + void shouldBeTipOfHeadBranchAfterRevert() throws IOException { + GitRevertCommand command = createCommand(); + RevertCommandRequest request = new RevertCommandRequest(); + request.setRevision(HEAD_REVISION); + request.setAuthor(new Person("Luke Skywalker", "luke@je.di")); + RevertCommandResult result = command.revert(request); + + try ( + GitContext context = createContext(); + Repository jRepository = context.open()) { + assertThat(GitUtil.getBranchId(jRepository, "main").getObjectId().getName()).isEqualTo(result.getRevision()); + } + } + + @Test + void shouldBeTipOfDifferentBranchAfterRevert() throws IOException { + GitRevertCommand command = createCommand(); + RevertCommandRequest request = new RevertCommandRequest(); + request.setRevision(DIVERGING_MAIN_LATEST_ANCESTOR); + request.setAuthor(new Person("Luke Skywalker", "luke@je.di")); + request.setBranch(DIVERGING_BRANCH); + RevertCommandResult result = command.revert(request); + + try ( + GitContext context = createContext(); + Repository jRepository = context.open()) { + assertThat(GitUtil.getBranchId(jRepository, DIVERGING_BRANCH).getObjectId().getName()).isEqualTo(result.getRevision()); + } + } + + @Test + void shouldNotRevertWithoutChange() { + GitRevertCommand command = createCommand(); + RevertCommandRequest request = new RevertCommandRequest(); + request.setRevision(HEAD_REVISION); + request.setAuthor(new Person("Luke Skywalker", "luke@je.di")); + + command.revert(request); + + assertThrows(NoChangesMadeException.class, () -> command.revert(request)); + } + + /** + * Reverting this very commit. + */ + @Test + void shouldRevertHeadCommit() throws IOException { + GitRevertCommand command = createCommand(); + RevertCommandRequest request = new RevertCommandRequest(); + request.setRevision(HEAD_REVISION); + request.setAuthor(new Person("Luke Skywalker", "luke@je.di")); + request.setBranch("main"); + RevertCommandResult result = command.revert(request); + + assertThat(result.isSuccessful()).isTrue(); + try (GitContext context = createContext()) { + GitDiffCommand diffCommand = new GitDiffCommand(context); + DiffCommandRequest diffRequest = new DiffCommandRequest(); + diffRequest.setRevision(result.getRevision()); + diffRequest.setPath("hitchhiker"); + try (ByteArrayOutputStream baos = new ByteArrayOutputStream()) { + diffCommand.getDiffResult(diffRequest).accept(baos); + assertThat(baos.toString()).contains("George Lucas\n-Darth Vader"); + } + } + } + + /** + * Reverting this very commit. + * The branch is not explicitly set, so we expect the default branch. + */ + @Test + void shouldRevertHeadCommitImplicitly() throws IOException { + GitRevertCommand command = createCommand(); + RevertCommandRequest request = new RevertCommandRequest(); + request.setRevision(HEAD_REVISION); + request.setAuthor(new Person("Luke Skywalker", "luke@je.di")); + RevertCommandResult result = command.revert(request); + + assertThat(result.isSuccessful()).isTrue(); + try (GitContext context = createContext()) { + GitDiffCommand diffCommand = new GitDiffCommand(context); + DiffCommandRequest diffRequest = new DiffCommandRequest(); + diffRequest.setRevision(result.getRevision()); + diffRequest.setPath("hitchhiker"); + try (ByteArrayOutputStream baos = new ByteArrayOutputStream()) { + diffCommand.getDiffResult(diffRequest).accept(baos); + assertThat(baos.toString()).contains("George Lucas\n-Darth Vader"); + } + } + } + + /** + * Reverting a change from one commit ago. + */ + @Test + void shouldRevertPreviousHistoryCommit() throws IOException { + GitRevertCommand command = createCommand(); + RevertCommandRequest request = new RevertCommandRequest(); + request.setRevision(HEAD_MINUS_0_REVISION); + request.setAuthor(new Person("Luke Skywalker", "luke@je.di")); + request.setBranch("main"); + RevertCommandResult result = command.revert(request); + + assertThat(result.isSuccessful()).isTrue(); + try (GitContext context = createContext()) { + GitDiffCommand diffCommand = new GitDiffCommand(context); + DiffCommandRequest diffRequest = new DiffCommandRequest(); + diffRequest.setRevision(result.getRevision()); + diffRequest.setPath("kerbal"); + try (ByteArrayOutputStream baos = new ByteArrayOutputStream()) { + diffCommand.getDiffResult(diffRequest).accept(baos); + assertThat(baos.toString()).contains("-deathstar\n+kerbin"); + } + } + } + + @Test + void shouldRevertCommitOnDifferentBranch() throws IOException { + GitRevertCommand command = createCommand(); + RevertCommandRequest request = new RevertCommandRequest(); + request.setRevision(DIVERGING_MAIN_LATEST_ANCESTOR); + request.setAuthor(new Person("Luke Skywalker", "luke@je.di")); + request.setBranch(DIVERGING_BRANCH); + RevertCommandResult result = command.revert(request); + assertThat(result.isSuccessful()).isTrue(); + + try ( + GitContext context = createContext(); + Repository jRepository = context.open(); + RevWalk revWalk = new RevWalk(jRepository)) { + ObjectId objectId = GitUtil.getRevisionId(jRepository, result.getRevision()); + RevCommit commit = GitUtil.getCommit(jRepository, revWalk, objectId); + assertThat(commit.getParent(0).getName()).isEqualTo(DIVERGING_BRANCH_LATEST_COMMIT); + + GitDiffCommand diffCommand = new GitDiffCommand(context); + DiffCommandRequest diffRequest = new DiffCommandRequest(); + + diffRequest.setRevision(result.getRevision()); + diffRequest.setAncestorChangeset(DIVERGING_BRANCH_LATEST_COMMIT); + diffRequest.setPath("hitchhiker"); + try (ByteArrayOutputStream baos = new ByteArrayOutputStream()) { + diffCommand.getDiffResult(diffRequest).accept(baos); + assertThat(baos.toString()).contains(""" + -George Lucas + +Douglas Adams""" + ); + } + } + } + + @Test + void shouldRevertTwiceOnDiffHeads() throws IOException { + GitRevertCommand command = createCommand(); + RevertCommandRequest request = new RevertCommandRequest(); + request.setRevision(HEAD_MINUS_0_REVISION); + request.setAuthor(new Person("Luke Skywalker", "luke@je.di")); + request.setBranch("main"); + RevertCommandResult result1 = command.revert(request); + + assertThat(result1.isSuccessful()).isTrue(); + + request.setRevision(result1.getRevision()); + RevertCommandResult result2 = command.revert(request); + + assertThat(result2.isSuccessful()).isTrue(); + + try (GitContext context = createContext()) { + GitDiffCommand diffCommand = new GitDiffCommand(context); + DiffCommandRequest diffRequest = new DiffCommandRequest(); + + // Check against original head; should be the same + diffRequest.setRevision(HEAD_REVISION); + diffRequest.setPath("kerbal"); + try (ByteArrayOutputStream baos = new ByteArrayOutputStream()) { + diffCommand.getDiffResult(diffRequest).accept(baos); + // no difference, thus empty + assertThat(baos.toString()).isEmpty(); + } + } + } + + @Test + void shouldReportCorrectFilesAfterMergeConflict() { + GitRevertCommand command = createCommand(); + RevertCommandRequest request = new RevertCommandRequest(); + request.setRevision(CONFLICTING_SOURCE_REVISION); + request.setAuthor(new Person("Luke Skywalker", "luke@je.di")); + request.setBranch(CONFLICTING_TARGET_BRANCH); + RevertCommandResult result = command.revert(request); + + assertThat(result.isSuccessful()).isFalse(); + assertThat(result.getFilesWithConflict()).containsExactly("hitchhiker"); + } + + @Test + void shouldSetCustomMessageIfGiven() throws IOException { + GitRevertCommand command = createCommand(); + RevertCommandRequest request = new RevertCommandRequest(); + request.setRevision(HEAD_REVISION); + request.setAuthor(new Person("Luke Skywalker", "luke@je.di")); + request.setBranch("main"); + request.setMessage("I will never join you!"); + RevertCommandResult result = command.revert(request); + + assertThat(result.isSuccessful()).isTrue(); + + try ( + GitContext context = createContext(); + Repository jRepository = context.open(); + RevWalk revWalk = new RevWalk(jRepository)) { + ObjectId objectId = GitUtil.getRevisionId(jRepository, result.getRevision()); + RevCommit commit = GitUtil.getCommit(jRepository, revWalk, objectId); + assertThat(commit.getShortMessage()).isEqualTo("I will never join you!"); + } + } + + @Test + void shouldSetDefaultMessageIfNoCustomMessageGiven() throws IOException { + GitRevertCommand command = createCommand(); + RevertCommandRequest request = new RevertCommandRequest(); + request.setRevision(HEAD_REVISION); + request.setAuthor(new Person("Luke Skywalker", "luke@je.di")); + request.setBranch("main"); + RevertCommandResult result = command.revert(request); + + try ( + GitContext context = createContext(); + Repository jRepository = context.open(); + RevWalk revWalk = new RevWalk(jRepository)) { + + ObjectId revertedCommitId = GitUtil.getRevisionId(jRepository, request.getRevision()); + RevCommit revertedCommit = GitUtil.getCommit(jRepository, revWalk, revertedCommitId); + ObjectId newCommitId = GitUtil.getRevisionId(jRepository, result.getRevision()); + RevCommit newCommit = GitUtil.getCommit(jRepository, revWalk, newCommitId); + + String expectedFullMessage = String.format(""" + Revert "%s" + + This reverts commit %s.""", + revertedCommit.getShortMessage(), revertedCommit.getName()); + + assertThat(newCommit.getShortMessage()).isEqualTo( + "Revert \"" + revertedCommit.getShortMessage() + "\""); + assertThat(newCommit.getFullMessage()).isEqualTo(expectedFullMessage); + } + } + + @Test + void shouldSignRevertCommit() throws IOException { + GitRevertCommand command = createCommand(); + RevertCommandRequest request = new RevertCommandRequest(); + request.setRevision(HEAD_REVISION); + request.setAuthor(new Person("Luke Skywalker", "luke@je.di")); + RevertCommandResult result = command.revert(request); + + try ( + GitContext context = createContext(); + Repository jRepository = context.open(); + RevWalk revWalk = new RevWalk(jRepository)) { + + ObjectId newCommitId = GitUtil.getRevisionId(jRepository, result.getRevision()); + RevCommit newCommit = GitUtil.getCommit(jRepository, revWalk, newCommitId); + + assertThat(newCommit.getRawGpgSignature()).isNotEmpty(); + assertThat(newCommit.getRawGpgSignature()).isEqualTo(GitTestHelper.SimpleGpgSigner.getSignature()); + } + } + + @Test + void shouldSignNoRevertCommitIfSigningIsDisabled() throws IOException { + GitRevertCommand command = createCommand(); + RevertCommandRequest request = new RevertCommandRequest(); + request.setRevision(HEAD_REVISION); + request.setAuthor(new Person("Luke Skywalker", "luke@je.di")); + request.setSign(false); + RevertCommandResult result = command.revert(request); + + try ( + GitContext context = createContext(); + Repository jRepository = context.open(); + RevWalk revWalk = new RevWalk(jRepository)) { + + ObjectId newCommitId = GitUtil.getRevisionId(jRepository, result.getRevision()); + RevCommit newCommit = GitUtil.getCommit(jRepository, revWalk, newCommitId); + + assertThat(newCommit.getRawGpgSignature()).isNullOrEmpty(); + } + } + + @Test + @SubjectAware(value = "admin", permissions = "*:*:*") + void shouldTakeAuthorFromSubjectIfNotSet() throws IOException { + SimplePrincipalCollection principals = new SimplePrincipalCollection(); + principals.add("admin", "AdminRealm"); + principals.add(new User("hitchhiker", "Douglas Adams", "ga@la.xy"), "AdminRealm"); + setSubject(new Subject.Builder() + .principals(principals) + .authenticated(true) + .buildSubject()); + GitRevertCommand command = createCommand(); + RevertCommandRequest request = new RevertCommandRequest(); + request.setRevision(HEAD_REVISION); + + RevertCommandResult result = command.revert(request); + + assertThat(result.isSuccessful()).isTrue(); + + try ( + GitContext context = createContext(); + Repository jRepository = context.open(); + RevWalk revWalk = new RevWalk(jRepository)) { + + ObjectId newCommitId = GitUtil.getRevisionId(jRepository, result.getRevision()); + RevCommit newCommit = GitUtil.getCommit(jRepository, revWalk, newCommitId); + + PersonIdent author = newCommit.getAuthorIdent(); + assertThat(author.getName()).isEqualTo("Douglas Adams"); + assertThat(author.getEmailAddress()).isEqualTo("ga@la.xy"); + } + } + + @Test + void shouldThrowNotFoundExceptionWhenBranchNotExist() { + GitRevertCommand command = createCommand(); + RevertCommandRequest request = new RevertCommandRequest(); + request.setRevision(HEAD_REVISION); + request.setAuthor(new Person("Luke Skywalker", "luke@je.di")); + request.setBranch("BogusBranch"); + assertThatThrownBy(() -> command.revert(request)) + .isInstanceOf(NotFoundException.class) + .hasMessage("could not find objectid with id BogusBranch in repository with id hitchhiker/HeartOfGold"); + } + + @Test + void shouldThrowNotFoundExceptionWhenRevisionNotExist() { + GitRevertCommand command = createCommand(); + RevertCommandRequest request = new RevertCommandRequest(); + request.setRevision("BogusRevision"); + request.setAuthor(new Person("Luke Skywalker", "luke@je.di")); + assertThatThrownBy(() -> command.revert(request)) + .isInstanceOf(NotFoundException.class) + .hasMessage("could not find objectid with id BogusRevision in repository with id hitchhiker/HeartOfGold"); + } + + @Test + void shouldThrowNoParentExceptionWhenParentNotExist() { + GitRevertCommand command = createCommand(); + RevertCommandRequest request = new RevertCommandRequest(); + request.setRevision(PARENTLESS_REVISION); + request.setAuthor(new Person("Luke Skywalker", "luke@je.di")); + assertThatThrownBy(() -> command.revert(request)) + .isInstanceOf(NoParentException.class) + .hasMessage(PARENTLESS_REVISION + " has no parent."); + } + + @Test + void shouldThrowMultipleParentsExceptionWhenPickingMergedCommit() { + GitRevertCommand command = createCommand(); + RevertCommandRequest request = new RevertCommandRequest(); + request.setRevision(MERGED_REVISION); + request.setAuthor(new Person("Luke Skywalker", "luke@je.di")); + assertThatThrownBy(() -> command.revert(request)) + .isInstanceOf(MultipleParentsNotAllowedException.class) + .hasMessage(MERGED_REVISION + " has more than one parent changeset, which is not allowed with this request."); + } + + private GitRevertCommand createCommand() { + return new GitRevertCommand(createContext("main"), repositoryManager, gitRepositoryHookEventFactory); + } + } +} diff --git a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/SimpleGitWorkingCopyFactoryTest.java b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/SimpleGitWorkingCopyFactoryTest.java index 0e753ffd40..d76bdd3fa2 100644 --- a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/SimpleGitWorkingCopyFactoryTest.java +++ b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/SimpleGitWorkingCopyFactoryTest.java @@ -162,10 +162,10 @@ public class SimpleGitWorkingCopyFactoryTest extends AbstractGitCommandTestBase File workdir = createExistingClone(factory); GitContext context = createContext(); - context.getGlobalConfig().setDefaultBranch("master"); + context.getGlobalConfig().setDefaultBranch("main"); factory.reclaim(context, workdir, null); - assertBranchCheckedOutAndClean(workdir, "master"); + assertBranchCheckedOutAndClean(workdir, "main"); } @Test diff --git a/scm-plugins/scm-git-plugin/src/test/resources/sonia/scm/repository/spi/README.md b/scm-plugins/scm-git-plugin/src/test/resources/sonia/scm/repository/spi/README.md new file mode 100644 index 0000000000..3101f51876 --- /dev/null +++ b/scm-plugins/scm-git-plugin/src/test/resources/sonia/scm/repository/spi/README.md @@ -0,0 +1,6 @@ +You can properly zip a new repository with: + +``` +ZIP_NAME=your name +(cd scm-git-${ZIP_NAME}-test && zip -r ../scm-git-${ZIP_NAME}-test.zip .) +``` diff --git a/scm-plugins/scm-git-plugin/src/test/resources/sonia/scm/repository/spi/scm-git-spi-revert-test.zip b/scm-plugins/scm-git-plugin/src/test/resources/sonia/scm/repository/spi/scm-git-spi-revert-test.zip new file mode 100644 index 0000000000..faaf70007f Binary files /dev/null and b/scm-plugins/scm-git-plugin/src/test/resources/sonia/scm/repository/spi/scm-git-spi-revert-test.zip differ diff --git a/scm-test/src/main/java/sonia/scm/AbstractTestBase.java b/scm-test/src/main/java/sonia/scm/AbstractTestBase.java index fae2ac6682..6ad10b1458 100644 --- a/scm-test/src/main/java/sonia/scm/AbstractTestBase.java +++ b/scm-test/src/main/java/sonia/scm/AbstractTestBase.java @@ -29,7 +29,6 @@ import org.junit.Before; import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; -import sonia.scm.repository.InitialRepositoryLocationResolver; import sonia.scm.repository.RepositoryLocationResolver; import sonia.scm.util.IOUtil; import sonia.scm.util.MockUtil; @@ -39,7 +38,6 @@ import java.io.IOException; import java.util.UUID; import java.util.logging.Logger; -import static java.util.Collections.emptySet; import static org.junit.Assert.assertTrue; @@ -62,7 +60,6 @@ public class AbstractTestBase UUID.randomUUID().toString()); assertTrue(tempDirectory.mkdirs()); contextProvider = MockUtil.getSCMContextProvider(tempDirectory); - InitialRepositoryLocationResolver initialRepoLocationResolver = new InitialRepositoryLocationResolver(emptySet()); repositoryLocationResolver = new TempDirRepositoryLocationResolver(tempDirectory); postSetUp(); } diff --git a/scm-test/src/main/java/sonia/scm/repository/spi/ZippedRepositoryTestBase.java b/scm-test/src/main/java/sonia/scm/repository/spi/ZippedRepositoryTestBase.java index 36d4ed35ef..1f3a7691e2 100644 --- a/scm-test/src/main/java/sonia/scm/repository/spi/ZippedRepositoryTestBase.java +++ b/scm-test/src/main/java/sonia/scm/repository/spi/ZippedRepositoryTestBase.java @@ -18,118 +18,66 @@ package sonia.scm.repository.spi; import com.google.common.io.Resources; - import org.junit.Before; import org.junit.Rule; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.io.TempDir; import org.junit.rules.TemporaryFolder; - import sonia.scm.AbstractTestBase; import sonia.scm.repository.Repository; import sonia.scm.repository.RepositoryTestData; import sonia.scm.util.IOUtil; -import static org.junit.Assert.*; - import java.io.File; import java.io.FileOutputStream; import java.io.IOException; import java.io.OutputStream; - import java.net.URL; - import java.util.zip.ZipEntry; import java.util.zip.ZipInputStream; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; -public abstract class ZippedRepositoryTestBase extends AbstractTestBase -{ + +public abstract class ZippedRepositoryTestBase extends AbstractTestBase { + /** + * This folder is used in JUnit 4-based tests. + */ @Rule public TemporaryFolder tempFolder = new TemporaryFolder(); + /** + * This folder is used in JUnit 5-based tests. + */ + @TempDir + public File tempDir; + protected Repository repository = createRepository(); protected File repositoryDirectory; - - protected abstract String getType(); - - - protected abstract String getZippedRepositoryResource(); - - - @Before - public void before() - { - repositoryDirectory = createRepositoryDirectory(); - } - - - protected void checkDate(long date) - { - assertNotNull(date); - assertTrue("Date should not be older than current date", - date < System.currentTimeMillis()); - } - - - protected Repository createRepository() - { - return RepositoryTestData.createHeartOfGold(getType()); - } - - - protected File createRepositoryDirectory() - { - File folder = null; - - try - { - folder = tempFolder.newFolder(); - folder.mkdirs(); - extract(folder); - } - catch (IOException ex) - { - fail(ex.getMessage()); - } - - return folder; - } - - - private void extract(File folder) throws IOException - { - String zippedRepositoryResource = getZippedRepositoryResource(); - extract(folder, zippedRepositoryResource); - } public static void extract(File targetFolder, String zippedRepositoryResource) throws IOException { URL url = Resources.getResource(zippedRepositoryResource); - try (ZipInputStream zip = new ZipInputStream(url.openStream());) - { + try (ZipInputStream zip = new ZipInputStream(url.openStream())) { ZipEntry entry = zip.getNextEntry(); - while (entry != null) - { + while (entry != null) { File file = new File(targetFolder, entry.getName()); File parent = file.getParentFile(); if (!IOUtil.isChild(parent, file)) { throw new IOException("invalid zip entry name"); } - if (!parent.exists()) - { + if (!parent.exists()) { parent.mkdirs(); } - if (entry.isDirectory()) - { + if (entry.isDirectory()) { file.mkdirs(); - } - else - { - try (OutputStream output = new FileOutputStream(file)) - { + } else { + try (OutputStream output = new FileOutputStream(file)) { IOUtil.copy(zip, output); } } @@ -140,4 +88,63 @@ public abstract class ZippedRepositoryTestBase extends AbstractTestBase } } + protected abstract String getType(); + + protected abstract String getZippedRepositoryResource(); + + @Before + public void before() { + repositoryDirectory = createRepositoryDirectory(); + } + + @BeforeEach + public void beforeEach() { + repositoryDirectory = createJUnit5RepositoryDirectory(); + } + + @SuppressWarnings("java:S5960") // no production code + protected void checkDate(long date) { + assertTrue("Date should not be older than current date", + date < System.currentTimeMillis()); + } + + protected Repository createRepository() { + return RepositoryTestData.createHeartOfGold(getType()); + } + + protected File createRepositoryDirectory() { + File folder = null; + + try { + folder = tempFolder.newFolder(); + folder.mkdirs(); + extract(folder); + } catch (IOException ex) { + fail(ex.getMessage()); + } + + return folder; + } + + protected File createJUnit5RepositoryDirectory() { + File folder = null; + + try { + folder = tempDir; + if (!folder.isDirectory()) { + fail("Temporary JUnit 5 folder not created"); + } + extract(folder); + } catch (IOException ex) { + fail(ex.getMessage()); + } + + return folder; + } + + private void extract(File folder) throws IOException { + String zippedRepositoryResource = getZippedRepositoryResource(); + extract(folder, zippedRepositoryResource); + } + } diff --git a/scm-test/src/main/java/sonia/scm/web/RestDispatcher.java b/scm-test/src/main/java/sonia/scm/web/RestDispatcher.java index 1dc499044b..3b52d178b0 100644 --- a/scm-test/src/main/java/sonia/scm/web/RestDispatcher.java +++ b/scm-test/src/main/java/sonia/scm/web/RestDispatcher.java @@ -36,6 +36,7 @@ import sonia.scm.AlreadyExistsException; import sonia.scm.BadRequestException; import sonia.scm.BranchAlreadyExistsException; import sonia.scm.ConcurrentModificationException; +import sonia.scm.ConflictException; import sonia.scm.NotFoundException; import sonia.scm.ScmConstraintViolationException; @@ -83,6 +84,7 @@ public class RestDispatcher { registerException(AlreadyExistsException.class, Status.CONFLICT); registerException(BranchAlreadyExistsException.class, Status.CONFLICT); registerException(ConcurrentModificationException.class, Status.CONFLICT); + registerException(ConflictException.class, Status.CONFLICT); registerException(UnauthorizedException.class, Status.FORBIDDEN); registerException(AuthorizationException.class, Status.FORBIDDEN); registerException(AuthenticationException.class, Status.UNAUTHORIZED); diff --git a/scm-ui/ui-api/src/index.ts b/scm-ui/ui-api/src/index.ts index d213c87c76..1acf4784b5 100644 --- a/scm-ui/ui-api/src/index.ts +++ b/scm-ui/ui-api/src/index.ts @@ -33,6 +33,7 @@ export * from "./repositories"; export * from "./namespaces"; export * from "./branches"; export * from "./changesets"; +export * from "./revert"; export * from "./tags"; export * from "./config"; export * from "./admin"; diff --git a/scm-ui/ui-api/src/revert.test.ts b/scm-ui/ui-api/src/revert.test.ts new file mode 100644 index 0000000000..7f6dde2599 --- /dev/null +++ b/scm-ui/ui-api/src/revert.test.ts @@ -0,0 +1,83 @@ +/* + * Copyright (c) 2020 - present Cloudogu GmbH + * + * This program is free software: you can redistribute it and/or modify it under + * the terms of the GNU Affero General Public License as published by the Free + * Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS + * FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more + * details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +import fetchMock from "fetch-mock-jest"; +import { Changeset } from "@scm-manager/ui-types"; +import { renderHook } from "@testing-library/react-hooks"; +import createWrapper from "./tests/createWrapper"; +import { RevertRequest, RevertResponse, useRevert } from "./revert"; +import createInfiniteCachingClient from "./tests/createInfiniteCachingClient"; + +const queryClient = createInfiniteCachingClient(); + +const mockChangeset: Changeset = { + id: "0f3cdd", + description: "Awesome change", + date: new Date(), + author: { + name: "Arthur Dent", + }, + _embedded: {}, + _links: { + revert: { + href: "/hitchhiker/heart-of-gold/changesets/0f3cdd/revert", + }, + }, +}; + +const expectedRevision: RevertResponse = { + revision: "a3ffde", +}; + +const revertRequest: RevertRequest = { + branch: "captain/kirk", + message: "Hello World!", +}; + +beforeEach(() => queryClient.clear()); + +afterEach(() => { + fetchMock.reset(); +}); + +describe("useRevert tests", () => { + const fetchRevert = async (changeset: Changeset, request: RevertRequest) => { + fetchMock.postOnce("api/v2/hitchhiker/heart-of-gold/changesets/0f3cdd/revert", expectedRevision); + const { result: useRevertResult, waitFor } = renderHook(() => useRevert(changeset), { + wrapper: createWrapper(undefined, queryClient), + }); + + await waitFor(() => { + return !!useRevertResult.current.revert || !!useRevertResult.current.error; + }); + + const { waitFor: waitForRevert } = renderHook(() => useRevertResult.current.revert(request), { + wrapper: createWrapper(undefined, queryClient), + }); + + await waitForRevert(() => { + return !!useRevertResult.current.revision || !!useRevertResult.current.error; + }); + + return useRevertResult.current; + }; + + it("should return revision from revert", async () => { + const { revision, error } = await fetchRevert(mockChangeset, revertRequest); + expect(revision).toEqual(expectedRevision); + expect(error).toBeNull(); + }); +}); diff --git a/scm-ui/ui-api/src/revert.ts b/scm-ui/ui-api/src/revert.ts new file mode 100644 index 0000000000..70bb6feac3 --- /dev/null +++ b/scm-ui/ui-api/src/revert.ts @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2020 - present Cloudogu GmbH + * + * This program is free software: you can redistribute it and/or modify it under + * the terms of the GNU Affero General Public License as published by the Free + * Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS + * FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more + * details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +import { requiredLink } from "./links"; +import { Changeset } from "@scm-manager/ui-types"; +import { apiClient } from "./apiclient"; +import { useMutation } from "react-query"; + +export type RevertRequest = { + branch: string; + message: string; +}; + +export type RevertResponse = { + revision: string; +}; + +export const useRevert = (changeset: Changeset) => { + const link = requiredLink(changeset, "revert"); + const { isLoading, error, mutate, data } = useMutation({ + mutationFn: async (request: RevertRequest) => { + const response = await apiClient.post(link, request, "application/vnd.scmm-revert+json;v=2"); + return await response.json(); + }, + }); + return { + revert: mutate, + isLoading, + error, + revision: data, + }; +}; diff --git a/scm-ui/ui-components/package.json b/scm-ui/ui-components/package.json index 8c1f215e14..339616a657 100644 --- a/scm-ui/ui-components/package.json +++ b/scm-ui/ui-components/package.json @@ -47,6 +47,7 @@ "@storybook/builder-webpack5": "^6.5.10", "@storybook/manager-webpack5": "^6.5.10", "@storybook/react": "^6.5.10", + "@testing-library/react": "^12.1.5", "storybook-addon-i18next": "^1.3.0", "storybook-addon-themes": "^6.1.0", "@types/classnames": "^2.3.1", @@ -108,4 +109,4 @@ "publishConfig": { "access": "public" } -} \ No newline at end of file +} diff --git a/scm-ui/ui-components/src/buttons/Button.tsx b/scm-ui/ui-components/src/buttons/Button.tsx index 896f28d2bd..f9cb9a9cae 100644 --- a/scm-ui/ui-components/src/buttons/Button.tsx +++ b/scm-ui/ui-components/src/buttons/Button.tsx @@ -41,7 +41,7 @@ type Props = ButtonProps & { }; /** - * @deprecated Use {@link ui-buttons/src/Button.tsx} instead + * @deprecated Use {@link ui-core/src/base/buttons/Button} instead */ const Button = React.forwardRef( ( diff --git a/scm-ui/ui-components/src/repos/changesets/ChangesetButtonGroup.test.tsx b/scm-ui/ui-components/src/repos/changesets/ChangesetButtonGroup.test.tsx new file mode 100644 index 0000000000..d42162f553 --- /dev/null +++ b/scm-ui/ui-components/src/repos/changesets/ChangesetButtonGroup.test.tsx @@ -0,0 +1,72 @@ +/* + * Copyright (c) 2020 - present Cloudogu GmbH + * + * This program is free software: you can redistribute it and/or modify it under + * the terms of the GNU Affero General Public License as published by the Free + * Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS + * FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more + * details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +import * as changesets from "./changesets"; +import { render } from "@testing-library/react"; +import { Branch, Changeset, Repository } from "@scm-manager/ui-types"; +import ChangesetButtonGroup from "./ChangesetButtonGroup"; +import React from "react"; +import { BrowserRouter } from "react-router-dom"; +import { stubI18Next } from "@scm-manager/ui-tests"; + +const createChangesetLink = jest.spyOn(changesets, "createChangesetLink"); +const createChangesetLinkByBranch = jest.spyOn(changesets, "createChangesetLinkByBranch"); + +afterEach(() => { + jest.resetAllMocks(); +}); + +describe("ChangesetButtonGroup", () => { + test("shouldCallCreateChangesetLinkWithoutBranch", async () => { + stubI18Next(); + const { repository, changeset } = createTestData(); + render( + + + + ); + expect(createChangesetLink).toHaveBeenCalled(); + expect(createChangesetLinkByBranch).toHaveBeenCalledTimes(0); + }); + + test("shouldCallCreateChangesetLinkByBranchWithBranch", async () => { + stubI18Next(); + const { repository, changeset, branch } = createTestData(); + render( + + + + ); + expect(createChangesetLinkByBranch).toHaveBeenCalled(); + expect(createChangesetLink).toHaveBeenCalledTimes(0); + }); +}); + +// TODO centralized test data +function createTestData() { + const repository: Repository = { _links: {}, name: "", namespace: "", type: "" }; + const changeset: Changeset = { + _links: {}, + author: { + name: "", + }, + date: new Date(), + description: "", + id: "", + }; + const branch: Branch = { _links: {}, name: "", revision: "" }; + return { repository, changeset, branch }; +} diff --git a/scm-ui/ui-components/src/repos/changesets/ChangesetButtonGroup.tsx b/scm-ui/ui-components/src/repos/changesets/ChangesetButtonGroup.tsx index d2ae0878ba..14e18d937c 100644 --- a/scm-ui/ui-components/src/repos/changesets/ChangesetButtonGroup.tsx +++ b/scm-ui/ui-components/src/repos/changesets/ChangesetButtonGroup.tsx @@ -15,21 +15,24 @@ */ import React from "react"; -import { Changeset, File, Repository } from "@scm-manager/ui-types"; -import { ButtonAddons, Button } from "../../buttons"; -import { createChangesetLink, createSourcesLink } from "./changesets"; +import { Branch, Changeset, File, Repository } from "@scm-manager/ui-types"; +import { Button, ButtonAddons } from "../../buttons"; +import { createChangesetLink, createChangesetLinkByBranch, createSourcesLink } from "./changesets"; import { useTranslation } from "react-i18next"; type Props = { repository: Repository; changeset: Changeset; file?: File; + branch?: Branch; }; const ChangesetButtonGroup = React.forwardRef( - ({ repository, changeset, file }, ref) => { + ({ repository, changeset, file, branch }, ref) => { const [t] = useTranslation("repos"); - const changesetLink = createChangesetLink(repository, changeset); + const changesetLink = branch + ? createChangesetLinkByBranch(repository, changeset, branch) + : createChangesetLink(repository, changeset); const sourcesLink = createSourcesLink(repository, changeset, file); return ( diff --git a/scm-ui/ui-components/src/repos/changesets/ChangesetList.tsx b/scm-ui/ui-components/src/repos/changesets/ChangesetList.tsx index 4a3ec610e6..ee3ac54a3a 100644 --- a/scm-ui/ui-components/src/repos/changesets/ChangesetList.tsx +++ b/scm-ui/ui-components/src/repos/changesets/ChangesetList.tsx @@ -16,20 +16,23 @@ import ChangesetRow from "./ChangesetRow"; import React, { FC } from "react"; -import { Changeset, File, Repository } from "@scm-manager/ui-types"; +import { Branch, Changeset, File, Repository } from "@scm-manager/ui-types"; import { KeyboardIterator } from "@scm-manager/ui-shortcuts"; type Props = { repository: Repository; changesets: Changeset[]; file?: File; + branch?: Branch; }; -const ChangesetList: FC = ({ repository, changesets, file }) => { +const ChangesetList: FC = ({ repository, changesets, file, branch }) => { return ( {changesets.map((changeset) => { - return ; + return ( + + ); })} ); diff --git a/scm-ui/ui-components/src/repos/changesets/ChangesetRow.tsx b/scm-ui/ui-components/src/repos/changesets/ChangesetRow.tsx index 2514953a74..c966d1cb76 100644 --- a/scm-ui/ui-components/src/repos/changesets/ChangesetRow.tsx +++ b/scm-ui/ui-components/src/repos/changesets/ChangesetRow.tsx @@ -18,7 +18,7 @@ import React, { FC } from "react"; import classNames from "classnames"; import styled from "styled-components"; import { ExtensionPoint, extensionPoints } from "@scm-manager/ui-extensions"; -import { Changeset, File, Repository } from "@scm-manager/ui-types"; +import { Branch, Changeset, File, Repository } from "@scm-manager/ui-types"; import ChangesetButtonGroup from "./ChangesetButtonGroup"; import SingleChangeset from "./SingleChangeset"; import { useKeyboardIteratorTarget } from "@scm-manager/ui-shortcuts"; @@ -27,11 +27,13 @@ type Props = { repository: Repository; changeset: Changeset; file?: File; + branch?: Branch; }; const Wrapper = styled.div` // & references parent rule // have a look at https://cssinjs.org/jss-plugin-nested?v=v10.0.0-alpha.9 + & + & { margin-top: 1rem; padding-top: 1rem; @@ -39,7 +41,7 @@ const Wrapper = styled.div` } `; -const ChangesetRow: FC = ({ repository, changeset, file }) => { +const ChangesetRow: FC = ({ repository, changeset, file, branch }) => { const ref = useKeyboardIteratorTarget(); return ( @@ -48,7 +50,7 @@ const ChangesetRow: FC = ({ repository, changeset, file }) => {
- + name="changeset.right" props={{ diff --git a/scm-ui/ui-components/src/repos/changesets/changesets.test.ts b/scm-ui/ui-components/src/repos/changesets/changesets.test.ts index fdd6d816f0..e239de57d2 100644 --- a/scm-ui/ui-components/src/repos/changesets/changesets.test.ts +++ b/scm-ui/ui-components/src/repos/changesets/changesets.test.ts @@ -14,9 +14,38 @@ * along with this program. If not, see https://www.gnu.org/licenses/. */ -import { parseDescription } from "./changesets"; +import { createChangesetLink, createChangesetLinkByBranch, parseDescription } from "./changesets"; +import { Branch, Changeset, Repository } from "@scm-manager/ui-types"; -describe("parseDescription tests", () => { +describe("createChangesetLink", () => { + it("should return a changeset link", () => { + const { repository, changeset } = createTestData(); + const link = createChangesetLink(repository, changeset); + expect(link).toBe("/repo/sandbox/anarchy/code/changeset/4f153aa670d4b27c"); + }); +}); + +describe("createChangesetLinkByBranch", () => { + it("should return a changeset link with a branch query with given branch", () => { + const { repository, changeset, branch } = createTestData(); + const link = createChangesetLinkByBranch(repository, changeset, branch); + expect(link).toBe("/repo/sandbox/anarchy/code/changeset/4f153aa670d4b27c?branch=resonanceCascade"); + }); + it("should return no branch query parameter with empty string", () => { + const { repository, changeset, branch } = createTestData(); + branch.name = ""; + const link = createChangesetLinkByBranch(repository, changeset, branch); + expect(link).toBe("/repo/sandbox/anarchy/code/changeset/4f153aa670d4b27c"); + }); + it("should escape a branch with a slash inside", () => { + const { repository, changeset, branch } = createTestData(); + branch.name = "feature/rescueWorld"; + const link = createChangesetLinkByBranch(repository, changeset, branch); + expect(link).toBe("/repo/sandbox/anarchy/code/changeset/4f153aa670d4b27c?branch=feature%2FrescueWorld"); + }); +}); + +describe("parseDescription", () => { it("should return a description with title and message", () => { const desc = parseDescription("Hello\nTrillian"); expect(desc.title).toBe("Hello"); @@ -34,3 +63,34 @@ describe("parseDescription tests", () => { expect(desc.message).toBe(""); }); }); + +function createTestData() { + const repository: Repository = { + name: "anarchy", + namespace: "sandbox", + type: "git", + _links: {}, + }; + + const changeset: Changeset = { + author: { + name: "Gordon Freeman", + }, + date: new Date(), + description: "Some repository.", + id: "4f153aa670d4b27c", + _links: {}, + }; + + const branch: Branch = { + name: "resonanceCascade", + revision: "4f153aa670d4b27c", + _links: {}, + }; + + return { + repository, + changeset, + branch, + }; +} diff --git a/scm-ui/ui-components/src/repos/changesets/changesets.ts b/scm-ui/ui-components/src/repos/changesets/changesets.ts index 4269dd246a..ec1467e23c 100644 --- a/scm-ui/ui-components/src/repos/changesets/changesets.ts +++ b/scm-ui/ui-components/src/repos/changesets/changesets.ts @@ -14,7 +14,7 @@ * along with this program. If not, see https://www.gnu.org/licenses/. */ -import { Changeset, File, Repository } from "@scm-manager/ui-types"; +import { Branch, Changeset, File, Repository } from "@scm-manager/ui-types"; export type Description = { title: string; @@ -25,6 +25,16 @@ export function createChangesetLink(repository: Repository, changeset: Changeset return `/repo/${repository.namespace}/${repository.name}/code/changeset/${changeset.id}`; } +export function createChangesetLinkByBranch(repository: Repository, changeset: Changeset, branch: Branch) { + if (!branch.name) { + return `/repo/${repository.namespace}/${repository.name}/code/changeset/${changeset.id}`; + } else { + return `/repo/${repository.namespace}/${repository.name}/code/changeset/${changeset.id}?branch=${encodeURIComponent( + branch.name + )}`; + } +} + export function createSourcesLink(repository: Repository, changeset: Changeset, file?: File) { let url = `/repo/${repository.namespace}/${repository.name}/code/sources/${changeset.id}`; @@ -50,6 +60,6 @@ export function parseDescription(description?: string): Description { return { title, - message + message, }; } diff --git a/scm-ui/ui-tests/enzyme-router.ts b/scm-ui/ui-tests/enzyme-router.ts index fbd5049dbc..be577a4572 100644 --- a/scm-ui/ui-tests/enzyme-router.ts +++ b/scm-ui/ui-tests/enzyme-router.ts @@ -20,8 +20,9 @@ import { createMount, createShallow } from "enzyme-context"; import { routerContext } from "enzyme-context-react-router-4"; const plugins = { - history: routerContext() + history: routerContext(), }; +// TODO Enzyme is not going to be supported in React 19+.: https://testing-library.com/docs/react-testing-library/migrate-from-enzyme/ export const mount = createMount(plugins); export const shallow = createShallow(plugins); diff --git a/scm-ui/ui-tests/i18n.ts b/scm-ui/ui-tests/i18n.ts index d7eb7098b9..2084643a55 100644 --- a/scm-ui/ui-tests/i18n.ts +++ b/scm-ui/ui-tests/i18n.ts @@ -14,16 +14,32 @@ * along with this program. If not, see https://www.gnu.org/licenses/. */ -export const jestMock = jest.mock("react-i18next", () => ({ - // this mock makes sure any components using the translate HoC receive the t function as a prop - withTranslation: () => (Component: any) => { - Component.defaultProps = { - ...Component.defaultProps, - t: (key: string) => key - }; - return Component; - }, - useTranslation: (ns: string) => { - return [(key: string) => key]; - } -})); +import { initReactI18next } from "react-i18next"; +import i18n from "i18next"; + +/** + * This provides a minimum i18next scaffold during initialization of a unit test. + * + * It does not connect to the i18next information used in production, + * but avoids warnings emerging due to i18next being uninitialized. + * + * More information: https://react.i18next.com/misc/testing + */ +export function stubI18Next() { + // TODO should be changed to async/await + i18n.use(initReactI18next).init({ + lng: "de", + fallbackLng: "en", + + ns: ["translationsNS"], + defaultNS: "translationsNS", + + debug: false, + + interpolation: { + escapeValue: false, + }, + + resources: { en: { translationsNS: {} } }, + }); +} diff --git a/scm-ui/ui-tests/package.json b/scm-ui/ui-tests/package.json index 15e4cdc445..f482fec762 100644 --- a/scm-ui/ui-tests/package.json +++ b/scm-ui/ui-tests/package.json @@ -14,7 +14,9 @@ "enzyme": "^3.11.0", "enzyme-context": "^1.1.2", "enzyme-context-react-router-4": "^2.0.0", - "raf": "^3.4.1" + "i18next": "21", + "raf": "^3.4.1", + "react-i18next": "11" }, "peerDependencies": { "@scm-manager/tsconfig": "^2.13.0", @@ -25,4 +27,4 @@ "publishConfig": { "access": "public" } -} \ No newline at end of file +} diff --git a/scm-ui/ui-webapp/public/locales/de/repos.json b/scm-ui/ui-webapp/public/locales/de/repos.json index ed255214ce..9deb5a1265 100644 --- a/scm-ui/ui-webapp/public/locales/de/repos.json +++ b/scm-ui/ui-webapp/public/locales/de/repos.json @@ -347,6 +347,18 @@ "tag": { "create": "Tag erstellen" }, + "revert": { + "button": "Revert", + "modal": { + "title": "Changeset zurücksetzen", + "description": "Sie wenden einen Revert an von Commit {{commit}} auf", + "branch": "Branch", + "commitMessage": "Commit-Nachricht", + "commitMessagePlaceholder": "Revert \"{{description}}\"\n\nThis reverts commit {{id}}.", + "submit": "Revert", + "cancel": "Abbrechen" + } + }, "containedInTags": { "containedInTag_one": "Enthalten in {{count}} Tag", "containedInTag_other": "Enthalten in {{count}} Tags", diff --git a/scm-ui/ui-webapp/public/locales/en/repos.json b/scm-ui/ui-webapp/public/locales/en/repos.json index 44b0df9e97..42065e5fc4 100644 --- a/scm-ui/ui-webapp/public/locales/en/repos.json +++ b/scm-ui/ui-webapp/public/locales/en/repos.json @@ -347,6 +347,18 @@ "tag": { "create": "Create Tag" }, + "revert": { + "button": "Revert", + "modal": { + "title": "Revert Changeset", + "description": "You are going to apply a revert for commit {{commit}} on", + "branch": "Branch", + "commitMessage": "Commit Message", + "commitMessagePlaceholder": "Revert \"{{description}}\"\n\nThis reverts commit {{id}}.", + "submit": "Revert", + "cancel": "Cancel" + } + }, "containedInTags": { "containedInTag_one": "Contained in {{count}} tag", "containedInTag_other": "Contained in {{count}} tags", diff --git a/scm-ui/ui-webapp/src/groups/components/navLinks/EditGroupNavLink.test.tsx b/scm-ui/ui-webapp/src/groups/components/navLinks/EditGroupNavLink.test.tsx index 924a3c91f9..779687655f 100644 --- a/scm-ui/ui-webapp/src/groups/components/navLinks/EditGroupNavLink.test.tsx +++ b/scm-ui/ui-webapp/src/groups/components/navLinks/EditGroupNavLink.test.tsx @@ -15,28 +15,30 @@ */ import React from "react"; -import { shallow } from "enzyme"; import "@scm-manager/ui-tests"; import EditGroupNavLink from "./EditGroupNavLink"; +import { mount } from "@scm-manager/ui-tests"; -it("should render nothing, if the edit link is missing", () => { - const group = { - _links: {} - }; +describe("EditGroupNavLink tests", () => { + it("should render nothing, if the edit link is missing", () => { + const group = { + _links: {}, + }; - const navLink = shallow(); - expect(navLink.text()).toBe(""); -}); - -it("should render the navLink", () => { - const group = { - _links: { - update: { - href: "/groups" - } - } - }; - - const navLink = shallow(); - expect(navLink.text()).not.toBe(""); + const navLink = mount(); + expect(navLink.text()).toBe(""); + }); + + it("should render the navLink", () => { + const group = { + _links: { + update: { + href: "/groups", + }, + }, + }; + + const navLink = mount(); + expect(navLink.text()).not.toBe(""); + }); }); diff --git a/scm-ui/ui-webapp/src/repos/components/PermissionsNavLink.test.tsx b/scm-ui/ui-webapp/src/repos/components/PermissionsNavLink.test.tsx index 579b91ffff..6f26457f35 100644 --- a/scm-ui/ui-webapp/src/repos/components/PermissionsNavLink.test.tsx +++ b/scm-ui/ui-webapp/src/repos/components/PermissionsNavLink.test.tsx @@ -15,17 +15,21 @@ */ import React from "react"; -import { mount, shallow } from "@scm-manager/ui-tests"; +import { mount } from "@scm-manager/ui-tests"; import "@scm-manager/ui-tests"; import PermissionsNavLink from "./PermissionsNavLink"; +afterEach(() => { + jest.resetAllMocks(); +}); + describe("PermissionsNavLink", () => { it("should render nothing, if the modify link is missing", () => { const repository = { _links: {}, }; - const navLink = shallow(); + const navLink = mount(); expect(navLink.text()).toBe(""); }); diff --git a/scm-ui/ui-webapp/src/repos/components/changesets/ChangesetDetails.tsx b/scm-ui/ui-webapp/src/repos/components/changesets/ChangesetDetails.tsx index e129c366e1..ed8fff6d12 100644 --- a/scm-ui/ui-webapp/src/repos/components/changesets/ChangesetDetails.tsx +++ b/scm-ui/ui-webapp/src/repos/components/changesets/ChangesetDetails.tsx @@ -33,12 +33,13 @@ import { FileControlFactory, SignatureIcon, } from "@scm-manager/ui-components"; -import { Tooltip, SubSubtitle } from "@scm-manager/ui-core"; +import { SubSubtitle } from "@scm-manager/ui-core"; import { Button, Icon } from "@scm-manager/ui-buttons"; import ContributorTable from "./ContributorTable"; import { Link, Link as ReactLink } from "react-router-dom"; import CreateTagModal from "./CreateTagModal"; import { useContainedInTags } from "@scm-manager/ui-api"; +import RevertModal from "./RevertModal"; type Props = { changeset: Changeset; @@ -72,36 +73,56 @@ const SeparatedParents = styled.div` } `; -const Contributors: FC<{ changeset: Changeset }> = ({ changeset }) => { +const Contributors: FC<{ changeset: Changeset; repository: Repository }> = ({ changeset, repository }) => { const [t] = useTranslation("repos"); const [open, setOpen] = useState(false); + const [tagCreationModalVisible, setTagCreationModalVisible] = useState(false); const signatureIcon = changeset?.signatures && changeset.signatures.length > 0 ? ( ) : ( <>  ); + const showCreateTagButton = "tag" in changeset._links; return ( -
setOpen(!open)}> - - {open ? ( - <> - angle-down - {t("changeset.contributors.list")} {signatureIcon} - - ) : ( - <> - angle-right{" "} - - - - {signatureIcon}{" "} - {t("changeset.contributors.count", { count: countContributors(changeset) })} - +
+
setOpen(!open)}> + + {open ? ( + <> + angle-down + {t("changeset.contributors.list")} {signatureIcon} + + ) : ( + <> + angle-right{" "} + + + + {signatureIcon}{" "} + {t("changeset.contributors.count", { count: countContributors(changeset) })} + + )} + + +
+
+ + {showCreateTagButton && ( + )} -
- -
+
+ {tagCreationModalVisible && ( + setTagCreationModalVisible(false)} + /> + )} + ); }; @@ -142,7 +163,7 @@ const ContainedInTags: FC<{ changeset: Changeset; repository: Repository }> = ({ }; const ChangesetDetails: FC = ({ changeset, repository, fileControlFactory }) => { - const [isTagCreationModalVisible, setTagCreationModalVisible] = useState(false); + const [revertModalVisible, setRevertModalVisible] = useState(false); const [t] = useTranslation("repos"); const description = changesets.parseDescription(changeset.description); @@ -153,7 +174,7 @@ const ChangesetDetails: FC = ({ changeset, repository, fileControlFactory {parent.id.substring(0, 7)} )); - const showCreateButton = "tag" in changeset._links; + const showRevertButton = "revert" in changeset._links; return ( <> @@ -176,10 +197,10 @@ const ChangesetDetails: FC = ({ changeset, repository, fileControlFactory

-
- +
+ -
+

@@ -189,28 +210,19 @@ const ChangesetDetails: FC = ({ changeset, repository, fileControlFactory {parents} ) : null} -
-
-
- -
- - {showCreateButton && ( -
- - - + )}
- )} - {isTagCreationModalVisible && ( - setTagCreationModalVisible(false)} - /> +
+ {revertModalVisible && ( + setRevertModalVisible(false)} /> )}

diff --git a/scm-ui/ui-webapp/src/repos/components/changesets/RevertModal.test.tsx b/scm-ui/ui-webapp/src/repos/components/changesets/RevertModal.test.tsx new file mode 100644 index 0000000000..40659a4ce8 --- /dev/null +++ b/scm-ui/ui-webapp/src/repos/components/changesets/RevertModal.test.tsx @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2020 - present Cloudogu GmbH + * + * This program is free software: you can redistribute it and/or modify it under + * the terms of the GNU Affero General Public License as published by the Free + * Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS + * FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more + * details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +import { getSelectedBranch } from "./RevertModal"; + +describe("getSelectedBranch", () => { + it("should return the correct branch from a query", () => { + const output = getSelectedBranch({ branch: "scotty" }); + expect(output).toBe("scotty"); + }); + it("should return an empty string if given no branch query", () => { + const output = getSelectedBranch({}); + expect(output).toBe(""); + }); + // slash escaping is observed to happen before, so it isn't tested here. +}); diff --git a/scm-ui/ui-webapp/src/repos/components/changesets/RevertModal.tsx b/scm-ui/ui-webapp/src/repos/components/changesets/RevertModal.tsx new file mode 100644 index 0000000000..86132d4e0b --- /dev/null +++ b/scm-ui/ui-webapp/src/repos/components/changesets/RevertModal.tsx @@ -0,0 +1,140 @@ +/* + * Copyright (c) 2020 - present Cloudogu GmbH + * + * This program is free software: you can redistribute it and/or modify it under + * the terms of the GNU Affero General Public License as published by the Free + * Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS + * FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more + * details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +import React, { FC, useRef, useState } from "react"; +import { useHistory } from "react-router-dom"; +import { useTranslation } from "react-i18next"; +import queryString from "query-string"; +import { useBranches, useRevert } from "@scm-manager/ui-api"; +import { Select, Textarea } from "@scm-manager/ui-forms"; +import { Modal } from "@scm-manager/ui-components"; +import { Button, ErrorNotification, Label, Loading, RequiredMarker } from "@scm-manager/ui-core"; +import { Changeset, Repository } from "@scm-manager/ui-types"; + +type Props = { + changeset: Changeset; + repository: Repository; + onClose: () => void; +}; + +const RevertModal: FC = ({ repository, changeset, onClose }) => { + const [t] = useTranslation("repos"); + const history = useHistory(); + const { isLoading: isBranchesLoading, error: branchesError, data: branchData } = useBranches(repository); + const { revert, isLoading: isRevertLoading, error: revertError } = useRevert(changeset); + const ref = useRef(null); + const queryParams = queryString.parse(window.location.search); + const [selectedBranch, setSelectedBranch] = useState(getSelectedBranch(queryParams)); + const [textareaValue, setTextareaValue] = useState( + changeset?.description.length > 0 + ? t("changeset.revert.modal.commitMessagePlaceholder", { + description: changeset.description.split("\n")[0], + id: changeset.id, + }) + : "" + ); + + const mappedBranches = [ + { label: "", value: "", hidden: true }, + ...(branchData?._embedded?.branches?.map((branch) => ({ + label: branch.name, + value: branch.name, + })) || []), + ]; + + const handleSelectChange = (event: React.ChangeEvent) => { + setSelectedBranch(event.target.value); + }; + + const handleTextareaChange = (event: React.ChangeEvent) => { + setTextareaValue(event.target.value); + }; + + let body; + if (isRevertLoading) { + body = ; + } else if (revertError) { + body = ; + } else if (branchesError) { + body = ; + } else { + body = ( + <> +

+

+ {t("changeset.revert.modal.description", { + commit: changeset.id.substring(0, 7), + })} +

+