diff --git a/CHANGELOG.md b/CHANGELOG.md index 3584cb1836..ec380f402c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,53 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [3.7.4] - 2025-03-12 +### Fixed +- Possibility to configure 'maxFormKeys' and 'maxFormContentSize' in Jetty + +## [3.7.3] - 2025-02-24 +### Fixed +- Keep original timestamp on rebase +- Title in tags (for example for "done" tags in review plugin) + +## [3.7.1] - 2025-01-28 +### Fixed +- Internal server error when creating tags in combination with some plugins + +## [3.7.0] - 2025-01-23 +### Added +- Performance improvements for git modifications +- New button variant called "info" +- Link to repo page in repo header +- Extension point below the title of a repository +- 'uploadpack.allowFilter = true' set for all new and existing Git repositories + +### Fixed +- Remove superfluous alt text for decorative images +- Accessible details for contributors and tags in changesets +- Whitespace dropdown is now correctly displayed after pr create + +### Changed +- Clickable tags are based on the HTML button. +- Upgrade JGit to 7.1.0.202411261347-r +- Set focus to first input element in repository, user, group, branch and repository role creation forms +- Replace title behavior with `useDocumentTitle` hook for setting descriptive document titles + +### Removed +- Unused class `IterableQueue` + +## [3.6.1] - 2025-01-17 +### Fixed +- Removed the API token error log message that was being printed when the API token was invalid (fix from 2.46.5) + +## [2.48.4] - 2025-01-17 +### Fixed +- Fixes from version 2.46.2, 2.46.3, 2.46.4, and 2.46.5 + +## [2.46.5] - 2025-01-17 +### Fixed +- Removed the API token error log message that was being printed when the API token was invalid + ## [3.6.0] - 2024-12-05 ### Added - Extension point for contributor row in contributor table @@ -1636,10 +1683,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 [2.46.2]: https://scm-manager.org/download/2.46.2 [2.46.3]: https://scm-manager.org/download/2.46.3 [2.46.4]: https://scm-manager.org/download/2.46.4 +[2.46.5]: https://scm-manager.org/download/2.46.5 [2.47.0]: https://scm-manager.org/download/2.47.0 [2.48.0]: https://scm-manager.org/download/2.48.0 [2.48.1]: https://scm-manager.org/download/2.48.1 [2.48.2]: https://scm-manager.org/download/2.48.2 +[2.48.3]: https://scm-manager.org/download/2.48.3 +[2.48.4]: https://scm-manager.org/download/2.48.4 [3.0.0]: https://scm-manager.org/download/3.0.0 [3.0.1]: https://scm-manager.org/download/3.0.1 [3.0.2]: https://scm-manager.org/download/3.0.2 @@ -1655,3 +1705,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 [3.4.2]: https://scm-manager.org/download/3.4.2 [3.5.0]: https://scm-manager.org/download/3.5.0 [3.6.0]: https://scm-manager.org/download/3.6.0 +[3.6.1]: https://scm-manager.org/download/3.6.1 +[3.7.0]: https://scm-manager.org/download/3.7.0 +[3.7.1]: https://scm-manager.org/download/3.7.1 +[3.7.3]: https://scm-manager.org/download/3.7.3 +[3.7.4]: https://scm-manager.org/download/3.7.4 diff --git a/Jenkinsfile b/Jenkinsfile index 7159878b3d..7904079aa7 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -81,9 +81,9 @@ pipeline { steps { // To rerun integration tests with each build, add '-PrerunIntegrationTests' to the gradle command gradle 'integrationTest' - junit allowEmptyResults: true, testResults: 'scm-it/build/test-results/javaIntegrationTests/*.xml,scm-ui/build/reports/e2e/*.xml' - archiveArtifacts allowEmptyArchive: true, artifacts: 'scm-ui/e2e-tests/cypress/videos/*.mp4' - archiveArtifacts allowEmptyArchive: true, artifacts: 'scm-ui/e2e-tests/cypress/screenshots/**/*.png' + junit allowEmptyResults: true, testResults: 'scm-it/build/test-results/javaIntegrationTests/*.xml,scm-ui/build/reports/e2e/*.xml,scm-ui/build/target/cypress/reports/*.xml' + archiveArtifacts allowEmptyArchive: true, artifacts: 'scm-ui/build/target/cypress/videos/*.mp4' + archiveArtifacts allowEmptyArchive: true, artifacts: 'scm-ui/build/target/cypress/screenshots/**/*.png' } } diff --git a/README.md b/README.md index 13e5d3f20a..8184dcf7e2 100644 --- a/README.md +++ b/README.md @@ -60,15 +60,15 @@ gradlew.bat taskname This following tables describes some high level tasks, which should cover most of the daily work. -| Name | Description | -| ---- | ----------- | -| run | Starts an SCM-Manager with enabled livereload for the ui | -| build | Executes all checks, tests and builds the smp inclusive javadoc and source jar | -| distribution | Builds all distribution packages of scm-packaging | -| check | Executes all registered checks and tests | -| test | Run all unit tests | -| integrationTest | Run all integration tests of scm-it | -| clean | Deletes the build directory | +| Name | Description | +| ---- |--------------------------------------------------------------------------------------------------------------------| +| run | Starts an SCM-Manager with enabled livereload for the ui | +| build | Executes all checks, tests and builds the smp inclusive [javadoc](https://scm-manager.org/javadoc/) and source jar | +| distribution | Builds all distribution packages of scm-packaging | +| check | Executes all registered checks and tests | +| test | Run all unit tests | +| integrationTest | Run all integration tests of scm-it | +| clean | Deletes the build directory | The next table defines a few more tasks which are more relevant for CI servers. diff --git a/build-plugins/src/main/groovy/com/cloudogu/scm/JavaModulePlugin.groovy b/build-plugins/src/main/groovy/com/cloudogu/scm/JavaModulePlugin.groovy index a98fdc178a..47c6964c27 100644 --- a/build-plugins/src/main/groovy/com/cloudogu/scm/JavaModulePlugin.groovy +++ b/build-plugins/src/main/groovy/com/cloudogu/scm/JavaModulePlugin.groovy @@ -34,8 +34,7 @@ class JavaModulePlugin implements Plugin { toolchain { languageVersion = JavaLanguageVersion.of(17) } - //TODO Fix javadoc errors which breaks the build -// withJavadocJar() + withJavadocJar() withSourcesJar() } diff --git a/docs/de/user/admin/assets/administration-cloudogu-platform-confirmation.png b/docs/de/user/admin/assets/administration-cloudogu-platform-confirmation.png index 03026d75fb..c447517010 100644 Binary files a/docs/de/user/admin/assets/administration-cloudogu-platform-confirmation.png and b/docs/de/user/admin/assets/administration-cloudogu-platform-confirmation.png differ diff --git a/docs/de/user/admin/assets/administration-setings-connected.png b/docs/de/user/admin/assets/administration-setings-connected.png new file mode 100644 index 0000000000..9b326e56ec Binary files /dev/null and b/docs/de/user/admin/assets/administration-setings-connected.png differ diff --git a/docs/de/user/admin/assets/administration-setings-not-connected.png b/docs/de/user/admin/assets/administration-setings-not-connected.png new file mode 100644 index 0000000000..a1a0f43d09 Binary files /dev/null and b/docs/de/user/admin/assets/administration-setings-not-connected.png differ diff --git a/docs/de/user/admin/assets/administration-settings-connected.png b/docs/de/user/admin/assets/administration-settings-connected.png deleted file mode 100644 index 8559e299fd..0000000000 Binary files a/docs/de/user/admin/assets/administration-settings-connected.png and /dev/null differ diff --git a/docs/de/user/admin/assets/administration-settings-not-connected.png b/docs/de/user/admin/assets/administration-settings-not-connected.png deleted file mode 100644 index 17f4502027..0000000000 Binary files a/docs/de/user/admin/assets/administration-settings-not-connected.png and /dev/null differ diff --git a/docs/de/user/admin/assets/cloudogu-platform-Login.png b/docs/de/user/admin/assets/cloudogu-platform-Login.png deleted file mode 100644 index f83822e8c8..0000000000 Binary files a/docs/de/user/admin/assets/cloudogu-platform-Login.png and /dev/null differ diff --git a/docs/de/user/admin/assets/cloudogu-platform-login.png b/docs/de/user/admin/assets/cloudogu-platform-login.png new file mode 100644 index 0000000000..5806089757 Binary files /dev/null and b/docs/de/user/admin/assets/cloudogu-platform-login.png differ diff --git a/docs/de/user/admin/plugins.md b/docs/de/user/admin/plugins.md index 7bc0a399aa..2b6c90cc78 100644 --- a/docs/de/user/admin/plugins.md +++ b/docs/de/user/admin/plugins.md @@ -14,7 +14,7 @@ Einige besondere Plugins sind nur für Instanzen des SCM-Managers verfügbar, di ![Plugin-Center nicht verbunden, Button zur Verbindung mit der cloudogu platform](assets/administration-plugin-center-not-connected.png) Sie werden dann zur cloudogu platform-Login-Maske weitergeleitet. -![cloudogu platform-Login-Maske](assets/cloudogu-platform-Login.png) +![cloudogu platform-Login-Maske](assets/cloudogu-platform-login.png) Wenn Sie über ein cloudogu platform-Konto verfügen, können Sie sich einloggen. Ansonsten erstellen Sie über einen konföderierten Identitätsanbieter (Google oder github) oder Ihre Email-Adresse ein Konto. Anschließend werden Sie zurück zum SCM-Manager geleitet und können Details zur verbundenen Instanz und Konto überprüfen. Mit „Verbinden“ bestätigen Sie die Verbindung, mit „Abbrechen“ brechen Sie den Vorgang ab. ![Bestätigung der Verbindung mit mcloudogu platform](assets/administration-cloudogu-platform-confirmation.png) diff --git a/docs/de/user/admin/settings.md b/docs/de/user/admin/settings.md index 8e4b3ce5cf..4105744c03 100644 --- a/docs/de/user/admin/settings.md +++ b/docs/de/user/admin/settings.md @@ -24,13 +24,26 @@ Auf der Login-Seite des SCM-Managers werden hilfreiche Plugins und Features vorg Um Angriffe auf den SCM-Manager mit Cross Site Scripting (XSS / XSRF) zu erschweren. Dieses Feature ist noch experimentell. #### Plugin-Settings -Der SCM-Manager kann ein Plugin-Center anbinden, um schnell und bequem Plugins verwalten zu können. Um ein anderes SCM-Plugin-Center als das vorkonfigurierte zu verwenden, reicht es aus diese URL zu ändern. Läuft der SCM-Manager im Cloudogu EcoSystem kann die Plugin Center URL über einen Eintrag im etcd gesetzt werden. +Der SCM-Manager kann ein Plugin-Center anbinden, um schnell und bequem Plugins verwalten zu können. Um ein anderes SCM-Plugin-Center als das vorkonfigurierte zu verwenden, reicht es aus diese URL zu ändern. Läuft der SCM-Manager im Cloudogu EcoSystem, kann die Plugin-Center URL über einen Eintrag im etcd gesetzt werden. Wenn das vorkonfigurierte Plugin-Center verwendet wird, kann der SCM-Manager mit der cloudogu platform verbunden werden. + +Nach der initialen Einrichtung sind folgende Werte standardgemäß hinterlegt: +```markdown +Plugin Center URL: https://plugin-center-api.scm-manager.org/api/v1/plugins/{version}?os={os}&arch={arch}&jre={jre} +Plugin Center Authentication URL: https://plugin-center-api.scm-manager.org/api/v1/auth/oidc +``` + ![Einstellungen, Plugin-Center nicht mit der cloudogu platform verbunden](assets/administration-settings-not-connected.png) So können über das Plugin-Center besondere cloudogu platform-Plugins bezogen werden. Details sind in der Dokumentation des Plugin-Centers aufgeführt. Eine bestehende Verbindung zwischen dem SCM-Manager und der cloudogu platform kann hier aufgehoben werden. ![Einstellungen, Plugin-Center mit der cloudogu platform verbunden, Button zum Lösen der Verbindung](assets/administration-settings-connected.png) +### JWT Einstellungen +Benutzer erhalten einen JWT als Authentifizierungstoken nach einem erfolgreichen login. +Administratoren können die Lebensdauer dieser JWTs konfigurieren. +Falls die Lebensdauer verringert wird, wird jeder bisher ausgestellter JWT ungültig. +Sollte in der `config.yml` des Servers die Option "endless JWT" aktiviert sein, dann wird diese Einstellung ignoriert. + #### Anonyme Zugriff Der SCM-Manager 2 hat das Konzept für anonyme Zugriffe über einen "_anonymous"-Benutzer realisiert. Beim Aktivieren des anonymen Zugriffs wird ein neuer Benutzer erstellt mit dem Namen "_anonymous". Dieser Nutzer kann wie ein gewöhnlicher Benutzer für unterschiedliche Aktionen berechtigt werden. Bei einem Zugriff auf den SCM-Manager ohne Zugangsdaten wird dieser anonyme Benutzer verwendet. Ist der anonyme Zugriff nur für Protokoll aktiviert, können die REST API und die VCS Protokolle anonym genutzt werden. Wurde der anonyme Zugriff vollständig aktiviert, ist auch ein Zugriff über den Webclient anonym möglich. diff --git a/docs/de/user/alerts/assets/alerts-list.png b/docs/de/user/alerts/assets/alerts-list.png index e4c0340fb2..a47778c189 100644 Binary files a/docs/de/user/alerts/assets/alerts-list.png and b/docs/de/user/alerts/assets/alerts-list.png differ 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/de/user/shortcuts/index.md b/docs/de/user/shortcuts/index.md index 5e3a8eef3f..c377e7a39e 100644 --- a/docs/de/user/shortcuts/index.md +++ b/docs/de/user/shortcuts/index.md @@ -10,14 +10,14 @@ verfügbaren Tastenkürzel mittels der `?`-Taste aufrufen. ### Globale Tastenkürzel -| Key Combination | Description | -|-----------------|-------------------------------------| -| ? | Öffne die Tastaturkürzelübersicht | -| / | Fokussiere die globale Schnellsuche | -| alt r | Navigiere zur Repositoryübersicht | -| alt u | Navigiere zur Benutzerübersicht | -| alt g | Navigiere zur Gruppenübersicht | -| alt a | Navigiere zur Administration | +| Tastenkürzel | Beschreibung | +|--------------|-------------------------------------| +| ? | Öffne die Tastaturkürzelübersicht | +| / | Fokussiere die globale Schnellsuche | +| alt r | Navigiere zur Repositoryübersicht | +| alt u | Navigiere zur Benutzerübersicht | +| alt g | Navigiere zur Gruppenübersicht | +| alt a | Navigiere zur Administration | ### Navigation von Listen @@ -25,20 +25,25 @@ Einige Seiten mit Listen erlauben die Navigation per Tastatur. Wenn die Seite dieses unterstützt, tauchen die Tastaturkürzel in der Übersicht im SCM-Manager auf (`?`). -| Key Combination | Description | -|-----------------|---------------------------------------------------| -| j | Bewege den Fokus auf den nächsten Listeneintrag | -| k | Bewege den Fokus auf den vorherigen Listeneintrag | +| Tastenkürzel | Beschreibung | +|--------------|---------------------------------------------------| +| j | Bewege den Fokus auf den nächsten Listeneintrag | +| k | Bewege den Fokus auf den vorherigen Listeneintrag | ### Repositoryspezifische Tastenkürzel -| Key Combination | Description | -|-----------------|------------------------------| -| g i | Wechsel zur Repository-Info | -| g b | Wechsel zu den Branches | -| g t | Wechsel zu den Tags | -| g c | Wechsel zum Code | -| g s | Wechsel zu den Einstellungen | +| Tastenkürzel | Beschreibung | +|--------------|------------------------------| +| g i | Wechsel zur Repository-Info | +| g b | Wechsel zu den Branches | +| g t | Wechsel zu den Tags | +| g c | Wechsel zum Code | +| g s | Wechsel zu den Einstellungen | + +### Codespezifische Tastenkürzel +| Tastenkürzel | Beschreibung | +|--------------|------------------------| +| g f | Wechsel zur Dateisuche | ### Tastenkürzel aus Plugin diff --git a/docs/en/administration/jwt-configuration.md b/docs/en/administration/jwt-configuration.md deleted file mode 100644 index 51add16688..0000000000 --- a/docs/en/administration/jwt-configuration.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -title: JWT Configuration ---- - -SCM-Manager uses [JWT](https://datatracker.ietf.org/doc/html/rfc7519) to authenticate its users. -The creation of JWTs can be controlled via Java system properties. - -## Endless JWT - -Usually a JWT contains the exp claim. This claim determines how long a JWT is valid by defining an expiration time. -If the JWT does not contain this claim, then the JWT is valid forever until the secret for the signature changes. -Per default the JWT created by the SCM-Manager contain the exp claim with a duration of one hour. - -If needed, it is possible to configure the SCM-Manager, so that the JWT get created without the exp claim. -Therefore, the user session would be endless. - -We advise **against** this behavior, because limited lifespans for JWT improve security. -But if you really need it, you can enable endless JWT by starting the SCM-Manager with this flag: - -``` --Dscm.endlessJwt="true" -``` - -If you want to disable the feature, then restart the SCM-Manager without this flag. -If you want to invalidate already created endless JWT, then restarting the SCM-Manager, with the endless JWT feature disabled, is enough. -The SCM-Manager will automatically create new secrets for the JWT and therefore invalidate every already existing JWT. diff --git a/docs/en/development/javadoc.md b/docs/en/development/javadoc.md new file mode 100644 index 0000000000..2427121594 --- /dev/null +++ b/docs/en/development/javadoc.md @@ -0,0 +1,5 @@ +--- +title: Javadoc +--- + +You can find the Javadoc at [javadoc](https://scm-manager.org/javadoc/). diff --git a/docs/en/development/style-guide.md b/docs/en/development/styleguide.md similarity index 98% rename from docs/en/development/style-guide.md rename to docs/en/development/styleguide.md index 305032dbe4..c3b171e33f 100644 --- a/docs/en/development/style-guide.md +++ b/docs/en/development/styleguide.md @@ -1,5 +1,5 @@ --- -title: Style Guide +title: Styleguide --- Starting with version 2 of SCM-Manager we have decided to change the code style and conform to more common rules. Furthermore we abandon the rule, that everything needs to have a javadoc description. Nonetheless we have decided against a "big bang" adaption of the new rule, because this would have lead to enourmous problems for merges from 1.x to 2.x. @@ -107,4 +107,4 @@ more skill. If you feel overwhelmed by them, do not let them deter you. We love ## JavaScript -Take a look at our styleguide using `yarn serve` in [ui-styles](scm-ui/ui-styles) directory. +Take a look at our styleguide using `yarn serve` in `/scm-ui/ui-styles` directory. diff --git a/docs/en/migrate-scm-manager-from-v1/index.md b/docs/en/migrate-scm-manager-from-v1/index.md index d9aea6418a..2fa2dcb55d 100644 --- a/docs/en/migrate-scm-manager-from-v1/index.md +++ b/docs/en/migrate-scm-manager-from-v1/index.md @@ -53,25 +53,20 @@ If however you have to install plugins manually (for example because you cannot # Huge number of repositories -If you have more than 100 Repositories to migrate, you may have to adapt some configuration and increase the limit of jetty form keys. You can do this by setting the `maxFormKeys` and `maxFormContentSize` of the webapp in `conf/server-config.xml`. You have to add the keys to the `WebAppContext` with the id `"scm-webapp"` e.g.: +If you have more than 100 Repositories to migrate, you may have to adapt some configuration and increase the limit of jetty form keys. You can do this by setting the `maxFormKeys` and `maxFormContentSize` in your `conf/config.yml` file. You have to add the keys at top level of the yaml file: ``` - - /scm - - /var/webapp/scm-webapp.war - - - org.eclipse.jetty.servlet.Default.dirAllowed - false - - - /work/scm - - - 1000000 - 5000 - +# base server config +## Address to listen 0.0.0.0 means on every interface +addressBinding: 0.0.0.0 +port: 8080 +contextPath: /scm + +## Additions for the huge number of repositories: +maxFormContentSize: 1000000 +maxFormKeys: 5000 + +... ``` The value for `maxFormKeys` should be the count of your repositories * 3 + 10. The `maxFormContentSize` depends on the length of your repository namespace and name, but you should be safe with repository count * 100. diff --git a/docs/en/navigation.yml b/docs/en/navigation.yml index 0fb1231705..2987d18d1c 100644 --- a/docs/en/navigation.yml +++ b/docs/en/navigation.yml @@ -31,16 +31,21 @@ entries: - /development/intellij-idea-configuration/ - /development/build-from-source/ + - /development/architecture-overview/ - /development/ui-common-pitfall/ - /development/permission-concept/ - - /development/error-handling/ - - /development/i18n/ - - /development/definition-of-done/ - - /development/ui-dod/ - /development/decision-table/ - - /development/building-forms/ + - /development/error-handling/ + - /development/styleguide/ - /development/testing-guide/ - /development/integration-tests/ + - /development/ui-extensions/ + - /development/i18n/ + - /development/building-forms/ + - /development/javadoc/ + - /development/cli-guideline/ + - /development/definition-of-done/ + - /development/ui-dod/ - section: Plugin Development entries: diff --git a/docs/en/release-process.md b/docs/en/release-process.md index f30958c2f5..e1318516e0 100644 --- a/docs/en/release-process.md +++ b/docs/en/release-process.md @@ -109,7 +109,7 @@ Then apply your fixes (eg. by cherry picking the relevant commits) and update th have single changelog yaml files, you could use the `updateChangelog` like above). Add the `CHANGELOG.md`, remove the yamls, and push the hotfix branch: -``` +```bash git rm -rf gradle/changelog git add CHANGELOG.md git commit -m "Adjust changelog for release 2.30.1" @@ -125,7 +125,7 @@ Depending on whether you released a hotfix for an older version or the latest re the `main` branch to the new tag. So in our example, if there is no version `2.31.x` yet, the new version `2.30.1` is the latest version and we have to update `main`: -``` +```bash git checkout main git merge 2.30.1 git push origin main @@ -136,7 +136,7 @@ that there are no changes that all changes are part of the current `develop` sta to merge conflicts, because the version on `develop` has been set to a new `SNAPSHOT`, while the version of the hotfix has been updated to the new release version. So you have to merge all these conflicts manually. -``` +```bash git checkout develop git merge main ``` diff --git a/docs/en/user/admin/assets/administration-cloudogu-platform-confirmation.png b/docs/en/user/admin/assets/administration-cloudogu-platform-confirmation.png index 7982b07b4b..c447517010 100644 Binary files a/docs/en/user/admin/assets/administration-cloudogu-platform-confirmation.png and b/docs/en/user/admin/assets/administration-cloudogu-platform-confirmation.png differ diff --git a/docs/en/user/admin/assets/administration-setings-connected.png b/docs/en/user/admin/assets/administration-setings-connected.png new file mode 100644 index 0000000000..c104240b2f Binary files /dev/null and b/docs/en/user/admin/assets/administration-setings-connected.png differ diff --git a/docs/en/user/admin/assets/administration-setings-not-connected.png b/docs/en/user/admin/assets/administration-setings-not-connected.png index 09663483ed..ef227f5b98 100644 Binary files a/docs/en/user/admin/assets/administration-setings-not-connected.png and b/docs/en/user/admin/assets/administration-setings-not-connected.png differ diff --git a/docs/en/user/admin/assets/administration-settings-connected.png b/docs/en/user/admin/assets/administration-settings-connected.png deleted file mode 100644 index c3616ac9f3..0000000000 Binary files a/docs/en/user/admin/assets/administration-settings-connected.png and /dev/null differ diff --git a/docs/en/user/admin/assets/cloudogu-platform-login.png b/docs/en/user/admin/assets/cloudogu-platform-login.png index 2174cfa9a6..6fb578c816 100644 Binary files a/docs/en/user/admin/assets/cloudogu-platform-login.png and b/docs/en/user/admin/assets/cloudogu-platform-login.png differ diff --git a/docs/en/user/admin/settings.md b/docs/en/user/admin/settings.md index 53c75dd52c..cc6131e574 100644 --- a/docs/en/user/admin/settings.md +++ b/docs/en/user/admin/settings.md @@ -26,10 +26,23 @@ Activate this option to make attacks using cross site scripting (XSS / XSRF) on #### Plugin-Settings A plugin center can be used to conveniently manage plugins. If you want to use a plugin center that is not the default one, you only have to change this URL. If SCM-Manager is operated as part of a Cloudogu EcoSystem, the plugin center URL can be changed in the etcd. If the default plugin center is used, the SCM-Manager may be connected to the cloudogu platform to receive special cloudogu platform-Plugins. Details can be found in the plugin-center documentation. + +After the initial setup, the following values are set by default: +```markdown +Plugin Center URL: https://plugin-center-api.scm-manager.org/api/v1/plugins/{version}?os={os}&arch={arch}&jre={jre} +Plugin Center Authentication URL: https://plugin-center-api.scm-manager.org/api/v1/auth/oidc +``` + ![Plugin center settings, not connected to the cloudogu platform](assets/administration-setings-not-connected.png) An existing connection between a SCM-Manager and the cloudogu platform may be severed here. ![Plugin center settings, button sever connection to the cloudogu platform](assets/administration-settings-connected.png) +#### JWT settings +Users receive a JWT as an authentication token, after a successful login. +Administrators can configure the amount of hours until a JWT expires. +If the amount of hours get reduced, each created JWT will be invalidated. +This setting will be ignored, if the endless JWT option is set to true in the server `config.yml`. + #### Anonymous Access In SCM-Manager 2 the access for anonymous access is realized by using an "_anonymous" user. When the feature is activated, a new user with the name "_anonymous" is created. This user can be authorized just like any other user. This user is used for access to SCM-Manager without login credentials. If the anonymous mode is protocol only you may access the SCM-Manager via the REST API and VCS protocols. With fully enabled anonymous access you can also use the webclient without credentials. diff --git a/docs/en/user/alerts/assets/alerts-list.png b/docs/en/user/alerts/assets/alerts-list.png index afd27cbfba..5b0a5fd662 100644 Binary files a/docs/en/user/alerts/assets/alerts-list.png and b/docs/en/user/alerts/assets/alerts-list.png differ 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/docs/en/user/shortcuts/index.md b/docs/en/user/shortcuts/index.md index 4e092827b6..aa691b9f29 100644 --- a/docs/en/user/shortcuts/index.md +++ b/docs/en/user/shortcuts/index.md @@ -39,6 +39,12 @@ If the page supports this feature, the shortcuts show up in the shortcut overvie | g c | Switch to code | | g s | Switch to settings | +### Code-specific Shortcuts + +| Key Combination | Description | +|-----------------|---------------------------| +| g f | Switch to file search | + ### Plugin Shortcuts Plugins can introduce new shortcuts. diff --git a/gradle.properties b/gradle.properties index a835836ef1..d2736667e2 100644 --- a/gradle.properties +++ b/gradle.properties @@ -15,6 +15,6 @@ # group = sonia.scm -version = 3.6.1-SNAPSHOT +version = 3.7.5-SNAPSHOT org.gradle.jvmargs=-Xmx1024M org.gradle.caching=true diff --git a/gradle/changelog/accessible_changeset_details.yaml b/gradle/changelog/accessible_changeset_details.yaml deleted file mode 100644 index 45fbf795ec..0000000000 --- a/gradle/changelog/accessible_changeset_details.yaml +++ /dev/null @@ -1,2 +0,0 @@ -- type: fixed - description: Accessible details for contributors and tags in changesets diff --git a/gradle/changelog/accessible_clickable_tags.yaml b/gradle/changelog/accessible_clickable_tags.yaml deleted file mode 100644 index 284aef1998..0000000000 --- a/gradle/changelog/accessible_clickable_tags.yaml +++ /dev/null @@ -1,2 +0,0 @@ -- type: changed - description: Clickable tags are based on the HTML button. diff --git a/gradle/changelog/add-link-to-repo-header.yaml b/gradle/changelog/add-link-to-repo-header.yaml deleted file mode 100644 index feec083b63..0000000000 --- a/gradle/changelog/add-link-to-repo-header.yaml +++ /dev/null @@ -1,2 +0,0 @@ -- type: added - description: Link to repo page in repo header diff --git a/gradle/changelog/adjust_file_diff_tree_height.yaml b/gradle/changelog/adjust_file_diff_tree_height.yaml new file mode 100644 index 0000000000..74c19ca47b --- /dev/null +++ b/gradle/changelog/adjust_file_diff_tree_height.yaml @@ -0,0 +1,2 @@ +- type: fixed + description: Entries are shown correctly diff --git a/gradle/changelog/alert_styling.yaml b/gradle/changelog/alert_styling.yaml new file mode 100644 index 0000000000..60f8d3dc5c --- /dev/null +++ b/gradle/changelog/alert_styling.yaml @@ -0,0 +1,2 @@ +- type: fixed + description: Incorrect alert message styling for long version conditions diff --git a/gradle/changelog/auto-focus.yaml b/gradle/changelog/auto-focus.yaml deleted file mode 100644 index a82c309cb8..0000000000 --- a/gradle/changelog/auto-focus.yaml +++ /dev/null @@ -1,2 +0,0 @@ -- type: changed - description: Set focus to first input element in repository, user, group, branch and repository role creation forms diff --git a/gradle/changelog/document_title.yaml b/gradle/changelog/document_title.yaml deleted file mode 100644 index 873a13e1c1..0000000000 --- a/gradle/changelog/document_title.yaml +++ /dev/null @@ -1,2 +0,0 @@ -- type: changed - description: Replace title behavior with `useDocumentTitle` hook for setting descriptive document titles diff --git a/gradle/changelog/file_search.yaml b/gradle/changelog/file_search.yaml new file mode 100644 index 0000000000..8c9c0255b3 --- /dev/null +++ b/gradle/changelog/file_search.yaml @@ -0,0 +1,4 @@ +- type: added + description: Keyboard shortcut (g+f) within code view for file search +- type: changed + description: File search page design; in particular with regard to accessibility diff --git a/gradle/changelog/fix-whitespace-dropdown.yaml b/gradle/changelog/fix-whitespace-dropdown.yaml deleted file mode 100644 index 1b768d687d..0000000000 --- a/gradle/changelog/fix-whitespace-dropdown.yaml +++ /dev/null @@ -1,2 +0,0 @@ -- type: fixed - description: Whitespace dropdown is now correctly displayed after pr create 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/gradle/changelog/iterable_queue.yaml b/gradle/changelog/iterable_queue.yaml deleted file mode 100644 index fa6112e358..0000000000 --- a/gradle/changelog/iterable_queue.yaml +++ /dev/null @@ -1,2 +0,0 @@ -- type: removed - description: Unused class `IterableQueue` diff --git a/gradle/changelog/jwt_settings.yaml b/gradle/changelog/jwt_settings.yaml new file mode 100644 index 0000000000..6684af7a7e --- /dev/null +++ b/gradle/changelog/jwt_settings.yaml @@ -0,0 +1,2 @@ +- type: added + description: JWT expiration time in general settings diff --git a/gradle/changelog/remove_superfluous_alt_text.yaml b/gradle/changelog/remove_superfluous_alt_text.yaml deleted file mode 100644 index 3a84d2f642..0000000000 --- a/gradle/changelog/remove_superfluous_alt_text.yaml +++ /dev/null @@ -1,2 +0,0 @@ -- type: fixed - description: Remove superfluous alt text for decorative images diff --git a/gradle/changelog/repo_type.yaml b/gradle/changelog/repo_type.yaml new file mode 100644 index 0000000000..462021ec6c --- /dev/null +++ b/gradle/changelog/repo_type.yaml @@ -0,0 +1,2 @@ +- type: fixed + description: Selection of undefined type in import repository dialog diff --git a/gradle/changelog/repository-type-config-forms.yaml b/gradle/changelog/repository-type-config-forms.yaml new file mode 100644 index 0000000000..2b9f2aef86 --- /dev/null +++ b/gradle/changelog/repository-type-config-forms.yaml @@ -0,0 +1,10 @@ +- type: added + description: Support of description text for input fields and checkboxes +- type: added + description: aria-describedby for input fields and checkboxes +- type: changed + description: Disabling git repositories is now the first setting within the configuration form +- type: changed + description: Disabling hg repositories is now the first setting within the configuration form +- type: changed + description: Disabling svn repositories is now the first setting within the configuration form diff --git a/gradle/changelog/secondary_navigation_context.yaml b/gradle/changelog/secondary_navigation_context.yaml new file mode 100644 index 0000000000..8816fb76c7 --- /dev/null +++ b/gradle/changelog/secondary_navigation_context.yaml @@ -0,0 +1,2 @@ +- type: fixed + description: Loop in secondary navigation render cycle diff --git a/gradle/changelog/tertiary_buttons.yaml b/gradle/changelog/tertiary_buttons.yaml new file mode 100644 index 0000000000..b17d5a6f7e --- /dev/null +++ b/gradle/changelog/tertiary_buttons.yaml @@ -0,0 +1,2 @@ +- type: fixed + description: Usability and accessibility of tertiary button diff --git a/gradle/dependencies.gradle b/gradle/dependencies.gradle index 0e38a0125d..775935b374 100644 --- a/gradle/dependencies.gradle +++ b/gradle/dependencies.gradle @@ -13,16 +13,16 @@ ext { sspVersion = '1.3.0' jjwtVersion = '0.11.5' bouncycastleVersion = '2.73.6' - jettyVersion = '11.0.16' + jettyVersion = '11.0.24' luceneVersion = '8.11.4' junitJupiterVersion = '5.10.3' hamcrestVersion = '3.0' mockitoVersion = '5.14.2' - jerseyVersion = '3.1.3' + jerseyVersion = '3.1.9' micrometerVersion = '1.14.2' - nodeVersion = '21.6.2' + nodeVersion = '21.7.3' yarnVersion = '1.22.18' libraries = [ @@ -73,7 +73,7 @@ ext { edison: 'de.otto.edison:edison-hal:2.1.1', // openapi - swaggerJaxRs: 'io.swagger.core.v3:swagger-jaxrs2-jakarta:2.2.19', + swaggerJaxRs: 'io.swagger.core.v3:swagger-jaxrs2-jakarta:2.2.26', // dto mapping mapstruct: "org.mapstruct:mapstruct:${mapstructVersion}", @@ -96,8 +96,8 @@ ext { // utils guava: 'com.google.guava:guava:33.3.1-jre', commonsLang: 'commons-lang:commons-lang:2.6', - commonsCompress: 'org.apache.commons:commons-compress:1.23.0', - commonsIo: 'commons-io:commons-io:2.13.0', + commonsCompress: 'org.apache.commons:commons-compress:1.27.1', + commonsIo: 'commons-io:commons-io:2.18.0', commonsLang3: 'org.apache.commons:commons-lang3:3.13.0', // security 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/config/ScmConfiguration.java b/scm-core/src/main/java/sonia/scm/config/ScmConfiguration.java index e6ac897a3f..aa9d28698b 100644 --- a/scm-core/src/main/java/sonia/scm/config/ScmConfiguration.java +++ b/scm-core/src/main/java/sonia/scm/config/ScmConfiguration.java @@ -229,6 +229,22 @@ public class ScmConfiguration implements Configuration { @XmlElement(name = "mail-domain-name") private String mailDomainName = DEFAULT_MAIL_DOMAIN_NAME; + /** + * Time in hours for jwt expiration. + * + * @since 3.8.0 + */ + @XmlElement(name = "jwt-expiration-time") + private int jwtExpirationInH = 1; + + /** + * Enables endless jwt. + * + * @since 3.8.0 + */ + @XmlElement(name = "jwt-expiration-endless") + private boolean enabledJwtEndless = false; + /** * List of users that will be notified of administrative incidents. * @@ -278,6 +294,8 @@ public class ScmConfiguration implements Configuration { this.emergencyContacts = other.emergencyContacts; this.enabledUserConverter = other.enabledUserConverter; this.enabledApiKeys = other.enabledApiKeys; + this.jwtExpirationInH = other.jwtExpirationInH; + this.enabledJwtEndless = other.enabledJwtEndless; } /** @@ -448,6 +466,26 @@ public class ScmConfiguration implements Configuration { return anonymousMode; } + /** + * Returns Jwt expiration in {@code n} . + * + * @return Jwt expiration in {@code number} + * @since 3.8.0 + */ + public int getJwtExpirationInH() { + return jwtExpirationInH; + } + + /** + * Returns {@code true} if the cookie xsrf protection is enabled. + * + * @return {@code true} if the cookie xsrf protection is enabled + * @since 3.8.0 + */ + public boolean isJwtEndless() { + return enabledJwtEndless; + } + /** * Returns {@code true} if anonymous mode is enabled. * @@ -728,6 +766,26 @@ public class ScmConfiguration implements Configuration { this.enabledFileSearch = enabledFileSearch; } + /** + * Set {@code n} to configure jwt expiration time in hours + * + * @param jwtExpirationInH {@code n} to configure jwt expiration time in hours + * @since 3.8.0 + */ + public void setJwtExpirationInH(int jwtExpirationInH) { + this.jwtExpirationInH = jwtExpirationInH; + } + + /** + * Set {@code true} to enable endless jwt. + * + * @param enabledJwtEndless {@code true} to enable endless jwt. + * @since 2.45.0 + */ + public void setEnabledJwtExpiration(boolean enabledJwtEndless) { + this.enabledJwtEndless = enabledJwtEndless; + } + public void setNamespaceStrategy(String namespaceStrategy) { this.namespaceStrategy = namespaceStrategy; } 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-ui/ui-components/src/navigation/SecondaryNavigationContext.ts b/scm-core/src/main/java/sonia/scm/update/RepositoryPermissionUpdater.java similarity index 74% rename from scm-ui/ui-components/src/navigation/SecondaryNavigationContext.ts rename to scm-core/src/main/java/sonia/scm/update/RepositoryPermissionUpdater.java index 30a53807e2..50b5278394 100644 --- a/scm-ui/ui-components/src/navigation/SecondaryNavigationContext.ts +++ b/scm-core/src/main/java/sonia/scm/update/RepositoryPermissionUpdater.java @@ -14,6 +14,10 @@ * along with this program. If not, see https://www.gnu.org/licenses/. */ -import React from "react"; +package sonia.scm.update; -export const SecondaryNavigationContext = React.createContext(false); +import sonia.scm.repository.RepositoryPermissionHolder; + +public interface RepositoryPermissionUpdater { + void removePermission(RepositoryPermissionHolder permissionHolder, String permissionName); +} 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-dao-xml/src/main/java/sonia/scm/repository/xml/PathBasedRepositoryLocationResolver.java b/scm-dao-xml/src/main/java/sonia/scm/repository/xml/PathBasedRepositoryLocationResolver.java index 9572a39c6e..4f6668f735 100644 --- a/scm-dao-xml/src/main/java/sonia/scm/repository/xml/PathBasedRepositoryLocationResolver.java +++ b/scm-dao-xml/src/main/java/sonia/scm/repository/xml/PathBasedRepositoryLocationResolver.java @@ -130,6 +130,9 @@ public class PathBasedRepositoryLocationResolver extends BasicRepositoryLocation } private void modifyLocation(String repositoryId, Path newPath, Modifier modifier) throws RepositoryStorageException { + if (newPath.toFile().exists() && !newPath.toFile().canWrite()) { + throw new RepositoryStorageException("cannot create repository at new path " + newPath + "; path is not writable"); + } maintenanceCallbacks.fire().downForMaintenance(new DownForMaintenanceContext(repositoryId)); Path oldPath = pathById.get(repositoryId); pathById.remove(repositoryId); diff --git a/scm-dao-xml/src/main/java/sonia/scm/repository/xml/XmlRepositoryDAO.java b/scm-dao-xml/src/main/java/sonia/scm/repository/xml/XmlRepositoryDAO.java index d3487bd688..e69f024b91 100644 --- a/scm-dao-xml/src/main/java/sonia/scm/repository/xml/XmlRepositoryDAO.java +++ b/scm-dao-xml/src/main/java/sonia/scm/repository/xml/XmlRepositoryDAO.java @@ -16,13 +16,10 @@ package sonia.scm.repository.xml; - import com.google.common.collect.ImmutableList; import com.google.inject.Singleton; import jakarta.inject.Inject; import lombok.extern.slf4j.Slf4j; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; import sonia.scm.io.FileSystem; import sonia.scm.repository.InternalRepositoryException; import sonia.scm.repository.NamespaceAndName; @@ -37,7 +34,6 @@ import sonia.scm.store.StoreReadOnlyException; import java.io.IOException; import java.nio.file.Path; import java.util.Collection; -import java.util.Comparator; import java.util.HashMap; import java.util.Map; import java.util.TreeMap; @@ -45,8 +41,6 @@ import java.util.concurrent.locks.Lock; import java.util.concurrent.locks.ReadWriteLock; import java.util.concurrent.locks.ReentrantReadWriteLock; import java.util.function.Supplier; -import java.util.stream.Collectors; - @Singleton @Slf4j @@ -62,9 +56,6 @@ public class XmlRepositoryDAO implements RepositoryDAO { private final Map byNamespaceAndName; private final ReadWriteLock byNamespaceLock = new ReentrantReadWriteLock(); - private static final Logger LOG = LoggerFactory.getLogger(XmlRepositoryDAO.class); - - @Inject public XmlRepositoryDAO(PathBasedRepositoryLocationResolver repositoryLocationResolver, FileSystem fileSystem, RepositoryExportingCheck repositoryExportingCheck) { this.repositoryLocationResolver = repositoryLocationResolver; @@ -99,10 +90,10 @@ public class XmlRepositoryDAO implements RepositoryDAO { pathRepositoryLocationResolverInstance.forAllLocations((repositoryId, repositoryPath) -> { try { Repository repository = metadataStore.read(repositoryPath); - if (byNamespaceAndName.containsKey(repository.getNamespaceAndName())) { - LOG.warn("Duplicate repository found. Adding suffix DUPLICATE to repository {}", repository); - repository.setName(repository.getName() + "-" + repositoryId + "-DUPLICATE"); - } + if (byNamespaceAndName.containsKey(repository.getNamespaceAndName())) { + log.warn("Duplicate repository found. Adding suffix DUPLICATE to repository {}", repository); + repository.setName(repository.getName() + "-" + repositoryId + "-DUPLICATE"); + } byNamespaceAndName.put(repository.getNamespaceAndName(), repository); byId.put(repositoryId, repository); } catch (InternalRepositoryException e) { diff --git a/scm-dao-xml/src/test/java/sonia/scm/repository/xml/PathBasedRepositoryLocationResolverTest.java b/scm-dao-xml/src/test/java/sonia/scm/repository/xml/PathBasedRepositoryLocationResolverTest.java index 7795b3d561..bdb55e9829 100644 --- a/scm-dao-xml/src/test/java/sonia/scm/repository/xml/PathBasedRepositoryLocationResolverTest.java +++ b/scm-dao-xml/src/test/java/sonia/scm/repository/xml/PathBasedRepositoryLocationResolverTest.java @@ -233,8 +233,6 @@ class PathBasedRepositoryLocationResolverTest { assertThat(newPath).exists(); assertThat(oldPath).exists(); assertThat(resolverWithExistingData.create(Path.class).getLocation("existingId_1")).isEqualTo(oldPath); - verify(maintenanceCallback).downForMaintenance(new DownForMaintenanceContext("existingId_1")); - verify(maintenanceCallback).upAfterMaintenance(new UpAfterMaintenanceContext("existingId_1", oldPath)); } } diff --git a/scm-it/src/test/java/sonia/scm/it/AnonymousAccessITCase.java b/scm-it/src/test/java/sonia/scm/it/AnonymousAccessITCase.java index 4d1d634d34..091617841d 100644 --- a/scm-it/src/test/java/sonia/scm/it/AnonymousAccessITCase.java +++ b/scm-it/src/test/java/sonia/scm/it/AnonymousAccessITCase.java @@ -241,6 +241,8 @@ class AnonymousAccessITCase { .addNull("proxyUser") .add("realmDescription", "SONIA :: SCM Manager") .add("skipFailedAuthenticators", false) + .add("jwtExpirationInH", 1) + .add("enabledJwtEndless", false) .build().toString(); } } diff --git a/scm-it/src/test/java/sonia/scm/it/DiffITCase.java b/scm-it/src/test/java/sonia/scm/it/DiffITCase.java index 91541057b3..6923ad878d 100644 --- a/scm-it/src/test/java/sonia/scm/it/DiffITCase.java +++ b/scm-it/src/test/java/sonia/scm/it/DiffITCase.java @@ -122,7 +122,7 @@ class DiffITCase { assertDiffsAreEqual(svnDiff, expected); } - @Test + @RetryingTest(3) void svnUpdateFileDiffShouldBeConvertedToGitDiff() throws IOException { RepositoryUtil.createAndCommitFile(svnRepositoryClient, ADMIN_USERNAME, "a.txt", "content of a"); RepositoryUtil.createAndCommitFile(gitRepositoryClient, ADMIN_USERNAME, "a.txt", "content of a"); @@ -206,7 +206,7 @@ class DiffITCase { } - @Test + @RetryingTest(3) void svnRenameChangesDiffShouldBeConvertedToGitDiff() throws IOException, URISyntaxException { String fileName = "a.txt"; RepositoryUtil.createAndCommitFile(svnRepositoryClient, ADMIN_USERNAME, fileName, "content of a"); diff --git a/scm-it/src/test/java/sonia/scm/it/utils/TestData.java b/scm-it/src/test/java/sonia/scm/it/utils/TestData.java index 21bf88ae9a..00ae1bdc18 100644 --- a/scm-it/src/test/java/sonia/scm/it/utils/TestData.java +++ b/scm-it/src/test/java/sonia/scm/it/utils/TestData.java @@ -281,7 +281,9 @@ public class TestData { " \"loginInfoUrl\": \"https://login-info.scm-manager.org/api/v1/login-info\",\n" + " \"releaseFeedUrl\": \"https://scm-manager.org/download/rss.xml\",\n" + " \"mailDomainName\": \"scm-manager.local\", \n" + - " \"enabledApiKeys\": \"true\"\n" + + " \"enabledApiKeys\": \"true\",\n" + + " \"jwtExpirationInH\": 1,\n" + + " \"enabledJwtEndless\": false\n" + "}") .put(createResourceUrl("config")) .then() diff --git a/scm-packaging/docker/Dockerfile.alpine b/scm-packaging/docker/Dockerfile.alpine index b0a9b3978c..a136ea4a3f 100644 --- a/scm-packaging/docker/Dockerfile.alpine +++ b/scm-packaging/docker/Dockerfile.alpine @@ -15,7 +15,7 @@ # # Create minimal java version -FROM alpine:3.19.3 as jre-build +FROM alpine:3.21.3 as jre-build RUN set -x \ && apk add --no-cache openjdk17-jdk openjdk17-jmods binutils \ @@ -31,7 +31,7 @@ RUN set -x \ # --- # SCM-Manager runtime -FROM alpine:3.19.3 as runtime +FROM alpine:3.21.3 as runtime ENV SCM_HOME /var/lib/scm ENV CACHE_DIR /var/cache/scm/work diff --git a/scm-packaging/docker/Dockerfile.debian b/scm-packaging/docker/Dockerfile.debian index b6d1366e9a..d2487a1008 100644 --- a/scm-packaging/docker/Dockerfile.debian +++ b/scm-packaging/docker/Dockerfile.debian @@ -28,7 +28,7 @@ RUN jlink \ # --- # SCM-Manager runtime -FROM debian:11.7-slim as runtime +FROM --platform=linux/amd64 debian:11.11-slim as runtime ENV SCM_HOME /var/lib/scm ENV CACHE_DIR /var/cache/scm/work diff --git a/scm-plugins/scm-git-plugin/build.gradle b/scm-plugins/scm-git-plugin/build.gradle index f79349dca2..fc66dce344 100644 --- a/scm-plugins/scm-git-plugin/build.gradle +++ b/scm-plugins/scm-git-plugin/build.gradle @@ -18,7 +18,7 @@ plugins { id 'org.scm-manager.smp' version '0.17.0' } -def jgitVersion = '6.7.0.202309050840-r-scm1-jakarta' +def jgitVersion = '7.1.0.202411261347-r-scm1' dependencies { // required by scm-it diff --git a/scm-plugins/scm-git-plugin/package.json b/scm-plugins/scm-git-plugin/package.json index 6b75889e2f..2b92a92731 100644 --- a/scm-plugins/scm-git-plugin/package.json +++ b/scm-plugins/scm-git-plugin/package.json @@ -1,7 +1,7 @@ { "name": "@scm-manager/scm-git-plugin", "private": true, - "version": "3.6.1-SNAPSHOT", + "version": "3.7.5-SNAPSHOT", "license": "AGPL-3.0-only", "main": "./src/main/js/index.ts", "scripts": { @@ -11,7 +11,7 @@ "typecheck": "tsc" }, "dependencies": { - "@scm-manager/ui-plugins": "3.6.1-SNAPSHOT" + "@scm-manager/ui-plugins": "3.7.5-SNAPSHOT" }, "devDependencies": { "@scm-manager/babel-preset": "^2.13.1", diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/GitConfigHelper.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/GitConfigHelper.java index 30a789792d..bcaa3eff89 100644 --- a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/GitConfigHelper.java +++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/GitConfigHelper.java @@ -25,10 +25,11 @@ public class GitConfigHelper { private static final String CONFIG_SECTION_SCMM = "scmm"; private static final String CONFIG_KEY_REPOSITORY_ID = "repositoryid"; - public void createScmmConfig(Repository repository, org.eclipse.jgit.lib.Repository gitRepository) throws IOException { - StoredConfig config = gitRepository.getConfig(); - config.setString(CONFIG_SECTION_SCMM, null, CONFIG_KEY_REPOSITORY_ID, repository.getId()); - config.save(); + public void createScmmConfig(Repository scmmRepository, org.eclipse.jgit.lib.Repository gitRepository) throws IOException { + StoredConfig gitConfig = gitRepository.getConfig(); + gitConfig.setString(CONFIG_SECTION_SCMM, null, CONFIG_KEY_REPOSITORY_ID, scmmRepository.getId()); + gitConfig.setBoolean("uploadpack", null, "allowFilter", true); + gitConfig.save(); } public String getRepositoryId(StoredConfig gitConfig) { diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/GitHeadModifier.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/GitHeadModifier.java index 0eb4620739..34f32d01d8 100644 --- a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/GitHeadModifier.java +++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/GitHeadModifier.java @@ -48,11 +48,10 @@ public class GitHeadModifier { * repositories head points already to the given branch. * * @param repository repository to modify - * @param newHead branch which should be the new head of the repository - * + * @param newHead branch which should be the new head of the repository * @return {@code true} if the head has changed */ - public boolean ensure(Repository repository, String newHead) { + public boolean ensure(Repository repository, String newHead) { try (org.eclipse.jgit.lib.Repository gitRepository = open(repository)) { String currentHead = resolve(gitRepository); if (!Objects.equals(currentHead, newHead)) { @@ -65,8 +64,8 @@ public class GitHeadModifier { } private String resolve(org.eclipse.jgit.lib.Repository gitRepository) throws IOException { - Ref ref = gitRepository.getRefDatabase().getRef(Constants.HEAD); - if ( ref.isSymbolic() ) { + Ref ref = gitRepository.getRefDatabase().findRef(Constants.HEAD); + if (ref.isSymbolic()) { ref = ref.getTarget(); } return GitUtil.getBranch(ref); diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/GitRepositoryModifyListener.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/GitRepositoryConfigChangeListener.java similarity index 90% rename from scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/GitRepositoryModifyListener.java rename to scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/GitRepositoryConfigChangeListener.java index c7eb785ebe..e767eb484b 100644 --- a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/GitRepositoryModifyListener.java +++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/GitRepositoryConfigChangeListener.java @@ -30,13 +30,13 @@ import sonia.scm.plugin.Extension; */ @Extension @EagerSingleton -public class GitRepositoryModifyListener { +public class GitRepositoryConfigChangeListener { private final GitHeadModifier headModifier; private final GitRepositoryConfigStoreProvider storeProvider; @Inject - public GitRepositoryModifyListener(GitHeadModifier headModifier, GitRepositoryConfigStoreProvider storeProvider) { + public GitRepositoryConfigChangeListener(GitHeadModifier headModifier, GitRepositoryConfigStoreProvider storeProvider) { this.headModifier = headModifier; this.storeProvider = storeProvider; } 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 f9c8b6f139..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,14 +231,11 @@ public final class GitUtil { } } - public static Ref getBranchId(org.eclipse.jgit.lib.Repository repo, String branchName) throws IOException { Ref ref = null; - if (!branchName.startsWith(REF_HEAD)) { - branchName = PREFIX_HEADS.concat(branchName); - } + branchName = getRevString(branchName); checkBranchName(repo, branchName); @@ -258,6 +252,13 @@ public final class GitUtil { return ref; } + public static String getRevString(String branchName) { + if (!branchName.startsWith(REF_HEAD)) { + return PREFIX_HEADS.concat(branchName); + } + return branchName; + } + /** * @since 2.5.0 */ @@ -286,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); } @@ -325,7 +347,6 @@ public final class GitUtil { return tag; } - public static long getCommitTime(RevCommit commit) { long date = commit.getCommitTime(); @@ -334,7 +355,6 @@ public final class GitUtil { return date; } - public static String getId(AnyObjectId objectId) { String id = Util.EMPTY_STRING; @@ -345,7 +365,6 @@ public final class GitUtil { return id; } - public static Ref getRefForCommit(org.eclipse.jgit.lib.Repository repository, ObjectId id) throws IOException { @@ -410,7 +429,6 @@ public final class GitUtil { .findFirst(); } - public static ObjectId getRevisionId(org.eclipse.jgit.lib.Repository repo, String revision) throws IOException { @@ -425,7 +443,6 @@ public final class GitUtil { return revId; } - public static String getScmRemoteRefName(Repository repository, Ref localBranch) { return getScmRemoteRefName(repository, localBranch.getName()); @@ -458,7 +475,6 @@ public final class GitUtil { return tagName; } - public static String getTagName(Ref ref) { String name = ref.getName(); @@ -469,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/ScmGpgSigner.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/ScmGpgSigner.java index c3572f99a0..fda764f799 100644 --- a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/ScmGpgSigner.java +++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/ScmGpgSigner.java @@ -18,16 +18,18 @@ package sonia.scm.repository; import jakarta.inject.Inject; import org.eclipse.jgit.api.errors.CanceledException; -import org.eclipse.jgit.lib.CommitBuilder; +import org.eclipse.jgit.api.errors.UnsupportedSigningFormatException; +import org.eclipse.jgit.lib.GpgConfig; import org.eclipse.jgit.lib.GpgSignature; -import org.eclipse.jgit.lib.GpgSigner; import org.eclipse.jgit.lib.PersonIdent; +import org.eclipse.jgit.lib.Repository; +import org.eclipse.jgit.lib.Signer; import org.eclipse.jgit.transport.CredentialsProvider; import sonia.scm.security.GPG; -import java.io.UnsupportedEncodingException; +import java.io.IOException; -public class ScmGpgSigner extends GpgSigner { +public class ScmGpgSigner implements Signer { private final GPG gpg; @@ -37,17 +39,13 @@ public class ScmGpgSigner extends GpgSigner { } @Override - public void sign(CommitBuilder commitBuilder, String keyId, PersonIdent personIdent, CredentialsProvider credentialsProvider) throws CanceledException { - try { - final byte[] signature = this.gpg.getPrivateKey().sign(commitBuilder.build()); - commitBuilder.setGpgSignature(new GpgSignature(signature)); - } catch (UnsupportedEncodingException e) { - throw new IllegalStateException(e); - } + public GpgSignature sign(Repository repository, GpgConfig gpgConfig, byte[] bytes, PersonIdent personIdent, String s, CredentialsProvider credentialsProvider) throws CanceledException, IOException, UnsupportedSigningFormatException { + final byte[] signature = this.gpg.getPrivateKey().sign(bytes); + return new GpgSignature(signature); } @Override - public boolean canLocateSigningKey(String keyId, PersonIdent personIdent, CredentialsProvider credentialsProvider) throws CanceledException { + public boolean canLocateSigningKey(Repository repository, GpgConfig gpgConfig, PersonIdent personIdent, String s, CredentialsProvider credentialsProvider) throws CanceledException { return true; } } diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/ScmGpgSignerInitializer.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/ScmGpgSignerInitializer.java index 901b4fee76..3e485436f7 100644 --- a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/ScmGpgSignerInitializer.java +++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/ScmGpgSignerInitializer.java @@ -19,7 +19,8 @@ package sonia.scm.repository; import jakarta.inject.Inject; import jakarta.servlet.ServletContextEvent; import jakarta.servlet.ServletContextListener; -import org.eclipse.jgit.lib.GpgSigner; +import org.eclipse.jgit.lib.GpgConfig; +import org.eclipse.jgit.lib.Signers; import sonia.scm.plugin.Extension; @Extension @@ -34,7 +35,7 @@ public class ScmGpgSignerInitializer implements ServletContextListener { @Override public void contextInitialized(ServletContextEvent servletContextEvent) { - GpgSigner.setDefault(scmGpgSigner); + Signers.set(GpgConfig.GpgFormat.OPENPGP, scmGpgSigner); } @Override diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/AbstractGitCommand.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/AbstractGitCommand.java index 02d8d24e25..2f6d68a794 100644 --- a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/AbstractGitCommand.java +++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/AbstractGitCommand.java @@ -78,7 +78,7 @@ class AbstractGitCommand { this.context = context; } - Repository open() throws IOException { + Repository open() { return context.open(); } diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/CommitHelper.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/CommitHelper.java new file mode 100644 index 0000000000..6fa9ac1bdb --- /dev/null +++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/CommitHelper.java @@ -0,0 +1,173 @@ +/* + * 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.extern.slf4j.Slf4j; +import org.apache.shiro.SecurityUtils; +import org.eclipse.jgit.api.errors.CanceledException; +import org.eclipse.jgit.api.errors.UnsupportedSigningFormatException; +import org.eclipse.jgit.lib.CommitBuilder; +import org.eclipse.jgit.lib.GpgConfig; +import org.eclipse.jgit.lib.ObjectId; +import org.eclipse.jgit.lib.ObjectInserter; +import org.eclipse.jgit.lib.PersonIdent; +import org.eclipse.jgit.lib.RefUpdate; +import org.eclipse.jgit.lib.Repository; +import org.eclipse.jgit.lib.Signer; +import org.eclipse.jgit.lib.Signers; +import org.eclipse.jgit.revwalk.RevCommit; +import org.eclipse.jgit.revwalk.RevWalk; +import org.eclipse.jgit.transport.CredentialsProvider; +import sonia.scm.ConcurrentModificationException; +import sonia.scm.repository.GitUtil; +import sonia.scm.repository.InternalRepositoryException; +import sonia.scm.repository.Person; +import sonia.scm.repository.RepositoryManager; +import sonia.scm.user.User; + +import java.io.IOException; +import java.util.List; + +import static java.util.Collections.emptyList; +import static sonia.scm.ContextEntry.ContextBuilder.entity; + +@Slf4j +class CommitHelper { + + private final Repository repository; + private final GitContext context; + private final RepositoryManager repositoryManager; + private final GitRepositoryHookEventFactory eventFactory; + + CommitHelper(GitContext context, RepositoryManager repositoryManager, GitRepositoryHookEventFactory eventFactory) { + this.repository = context.open(); + this.context = context; + this.repositoryManager = repositoryManager; + this.eventFactory = eventFactory; + } + + ObjectId createCommit(ObjectId treeId, + Person author, + Person committer, + String message, + boolean sign, + ObjectId... parentCommitIds) throws IOException, CanceledException, UnsupportedSigningFormatException { + return createCommit(treeId, createPersonIdent(author), committer, message, sign, parentCommitIds); + } + + ObjectId createCommit(ObjectId treeId, + PersonIdent author, + Person committer, + String message, + boolean sign, + ObjectId... parentCommitIds) throws IOException, CanceledException, UnsupportedSigningFormatException { + log.trace("create commit for tree {} and parent ids {} in repository {}", treeId, parentCommitIds, context.getRepository()); + try (ObjectInserter inserter = repository.newObjectInserter()) { + CommitBuilder commitBuilder = new CommitBuilder(); + commitBuilder.setTreeId(treeId); + commitBuilder.setParentIds(parentCommitIds); + commitBuilder.setAuthor(author); + commitBuilder.setCommitter(createPersonIdent(committer)); + commitBuilder.setMessage(message); + if (sign) { + sign(commitBuilder, createPersonIdent(committer)); + } + ObjectId commitId = inserter.insert(commitBuilder); + inserter.flush(); + log.trace("created commit with id {}", commitId); + return commitId; + } + } + + private PersonIdent createPersonIdent(Person person) { + if (person == null) { + User currentUser = SecurityUtils.getSubject().getPrincipals().oneByType(User.class); + return new PersonIdent(currentUser.getDisplayName(), currentUser.getMail()); + } + return new PersonIdent(person.getName(), person.getMail()); + } + + private void sign(CommitBuilder commit, PersonIdent committer) + throws CanceledException, IOException, UnsupportedSigningFormatException { + log.trace("sign commit"); + GpgConfig gpgConfig = new GpgConfig(repository.getConfig()); + Signer signer = Signers.get(gpgConfig.getKeyFormat()); + signer.signObject(repository, gpgConfig, commit, committer, "SCM-MANAGER-DEFAULT-KEY", CredentialsProvider.getDefault()); + } + + void updateBranch(String branchName, ObjectId newCommitId, ObjectId expectedOldObjectId) { + log.trace("update branch {} with new commit id {} in repository {}", branchName, newCommitId, context.getRepository()); + try { + RevCommit newCommit = findNewCommit(newCommitId); + firePreCommitHook(branchName, newCommit); + doUpdate(branchName, newCommitId, expectedOldObjectId); + firePostCommitHook(branchName, newCommit); + } catch (IOException e) { + throw new InternalRepositoryException(context.getRepository(), "could not update branch " + branchName, e); + } + } + + private RevCommit findNewCommit(ObjectId newCommitId) throws IOException { + RevCommit newCommit; + try (RevWalk revWalk = new RevWalk(repository)) { + newCommit = revWalk.parseCommit(newCommitId); + } + return newCommit; + } + + private void firePreCommitHook(String branchName, RevCommit newCommit) { + repositoryManager.fireHookEvent( + eventFactory.createPreReceiveEvent( + context, + List.of(branchName), + emptyList(), + () -> List.of(newCommit) + ) + ); + } + + private void doUpdate(String branchName, ObjectId newCommitId, ObjectId expectedOldObjectId) throws IOException { + RefUpdate refUpdate = repository.updateRef(GitUtil.getRevString(branchName)); + if (newCommitId == null) { + refUpdate.setExpectedOldObjectId(ObjectId.zeroId()); + } else { + refUpdate.setExpectedOldObjectId(expectedOldObjectId); + } + refUpdate.setNewObjectId(newCommitId); + refUpdate.setForceUpdate(false); + RefUpdate.Result result = refUpdate.update(); + + if (isSuccessfulUpdate(expectedOldObjectId, result)) { + throw new ConcurrentModificationException(entity("branch", branchName).in(context.getRepository()).build()); + } + } + + private void firePostCommitHook(String branchName, RevCommit newCommit) { + repositoryManager.fireHookEvent( + eventFactory.createPostReceiveEvent( + context, + List.of(branchName), + emptyList(), + () -> List.of(newCommit) + ) + ); + } + + private boolean isSuccessfulUpdate(ObjectId expectedOldObjectId, RefUpdate.Result result) { + return result != RefUpdate.Result.FAST_FORWARD && !(expectedOldObjectId == null && result == RefUpdate.Result.NEW); + } +} diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitBranchCommand.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitBranchCommand.java index 710eb35bf4..7c84fe521a 100644 --- a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitBranchCommand.java +++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitBranchCommand.java @@ -97,7 +97,7 @@ public class GitBranchCommand extends AbstractGitCommand implements BranchComman log.debug("got exception for invalid branch name {}", request.getNewBranch(), e); doThrow().violation("Invalid branch name", "name").when(true); return null; - } catch (GitAPIException | IOException ex) { + } catch (GitAPIException ex) { throw new InternalRepositoryException(repository, "could not create branch " + request.getNewBranch(), ex); } } @@ -116,7 +116,7 @@ public class GitBranchCommand extends AbstractGitCommand implements BranchComman eventBus.post(new PostReceiveRepositoryHookEvent(hookEvent)); } catch (CannotDeleteCurrentBranchException e) { throw new CannotDeleteDefaultBranchException(context.getRepository(), branchName); - } catch (GitAPIException | IOException ex) { + } catch (GitAPIException ex) { throw new InternalRepositoryException(entity(context.getRepository()), String.format("Could not delete branch: %s", branchName)); } } @@ -161,12 +161,7 @@ public class GitBranchCommand extends AbstractGitCommand implements BranchComman @Override public HookChangesetProvider getChangesetProvider() { - Repository gitRepo; - try { - gitRepo = context.open(); - } catch (IOException e) { - throw new InternalRepositoryException(repository, "failed to open repository for post receive hook after internal change", e); - } + Repository gitRepo = context.open(); Collection receiveCommands = asList(createReceiveCommand()); return x -> { diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitBranchesCommand.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitBranchesCommand.java index 0679741e29..11cc1a032f 100644 --- a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitBranchesCommand.java +++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitBranchesCommand.java @@ -53,7 +53,7 @@ public class GitBranchesCommand extends AbstractGitCommand implements BranchesCo } @Override - public List getBranches() throws IOException { + public List getBranches() { Git git = createGit(); String defaultBranchName = determineDefaultBranchName(git); @@ -72,7 +72,7 @@ public class GitBranchesCommand extends AbstractGitCommand implements BranchesCo } @VisibleForTesting - Git createGit() throws IOException { + Git createGit() { return new Git(open()); } diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitContext.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitContext.java index d123206231..04e29ed1ea 100644 --- a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitContext.java +++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitContext.java @@ -23,6 +23,7 @@ import sonia.scm.api.v2.resources.GitRepositoryConfigStoreProvider; import sonia.scm.repository.GitConfig; import sonia.scm.repository.GitRepositoryConfig; import sonia.scm.repository.GitUtil; +import sonia.scm.repository.InternalRepositoryException; import sonia.scm.repository.Repository; import sonia.scm.repository.RepositoryProvider; @@ -62,13 +63,17 @@ public class GitContext implements Closeable, RepositoryProvider } - public org.eclipse.jgit.lib.Repository open() throws IOException + public org.eclipse.jgit.lib.Repository open() { if (gitRepository == null) { logger.trace("open git repository {}", directory); - gitRepository = GitUtil.open(directory); + try { + gitRepository = GitUtil.open(directory); + } catch (IOException e) { + throw new InternalRepositoryException(repository, "could not open git repository", e); + } } return gitRepository; diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitFastForwardIfPossible.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitFastForwardIfPossible.java index ba2396b01c..d6ea523239 100644 --- a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitFastForwardIfPossible.java +++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitFastForwardIfPossible.java @@ -16,38 +16,41 @@ package sonia.scm.repository.spi; -import org.eclipse.jgit.api.Git; -import org.eclipse.jgit.api.MergeCommand; -import org.eclipse.jgit.api.MergeResult; +import lombok.extern.slf4j.Slf4j; +import org.eclipse.jgit.lib.ObjectId; import sonia.scm.repository.Repository; +import sonia.scm.repository.RepositoryManager; import sonia.scm.repository.api.MergeCommandResult; -import java.io.IOException; -import java.util.Collections; +@Slf4j +class GitFastForwardIfPossible { -class GitFastForwardIfPossible extends GitMergeStrategy { + private final MergeCommandRequest request; + private final MergeHelper mergeHelper; + private final GitMergeCommit fallbackMerge; + private final CommitHelper commitHelper; + private final Repository repository; - private GitMergeStrategy fallbackMerge; - - GitFastForwardIfPossible(Git clone, MergeCommandRequest request, GitContext context, Repository repository) { - super(clone, request, context, repository); - fallbackMerge = new GitMergeCommit(clone, request, context, repository); + GitFastForwardIfPossible(MergeCommandRequest request, GitContext context, RepositoryManager repositoryManager, GitRepositoryHookEventFactory eventFactory) { + this.request = request; + this.mergeHelper = new MergeHelper(context, request, repositoryManager, eventFactory); + this.fallbackMerge = new GitMergeCommit(request, context, repositoryManager, eventFactory); + this.commitHelper = new CommitHelper(context, repositoryManager, eventFactory); + this.repository = context.getRepository(); } - @Override - MergeCommandResult run() throws IOException { - MergeResult fastForwardResult = mergeWithFastForwardOnlyMode(); - if (fastForwardResult.getMergeStatus().isSuccessful()) { - push(); - return createSuccessResult(fastForwardResult.getNewHead().name()); + MergeCommandResult run() { + log.trace("try to fast forward branch {} onto {} in repository {}", request.getBranchToMerge(), request.getTargetBranch(), repository); + ObjectId sourceRevision = mergeHelper.getRevisionToMerge(); + ObjectId targetRevision = mergeHelper.getTargetRevision(); + + if (mergeHelper.isMergedInto(targetRevision, sourceRevision)) { + log.trace("fast forward branch {} onto {}", request.getBranchToMerge(), request.getTargetBranch()); + commitHelper.updateBranch(request.getTargetBranch(), sourceRevision, targetRevision); + return MergeCommandResult.success(targetRevision.name(), mergeHelper.getRevisionToMerge().name(), sourceRevision.name()); } else { + log.trace("fast forward is not possible, fallback to merge"); return fallbackMerge.run(); } } - - private MergeResult mergeWithFastForwardOnlyMode() throws IOException { - MergeCommand mergeCommand = getClone().merge(); - mergeCommand.setFastForward(MergeCommand.FastForwardMode.FF_ONLY); - return doMergeInClone(mergeCommand); - } } diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitImportHookContextProvider.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitImportHookContextProvider.java index 34c5e1bf22..723a05db66 100644 --- a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitImportHookContextProvider.java +++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitImportHookContextProvider.java @@ -17,6 +17,7 @@ package sonia.scm.repository.spi; import com.google.common.collect.ImmutableSet; +import org.eclipse.jgit.revwalk.RevCommit; import sonia.scm.repository.GitChangesetConverter; import sonia.scm.repository.Tag; import sonia.scm.repository.api.HookBranchProvider; @@ -27,17 +28,19 @@ import sonia.scm.repository.api.HookTagProvider; import java.util.Collections; import java.util.List; import java.util.Set; +import java.util.concurrent.Callable; +import java.util.function.Supplier; class GitImportHookContextProvider extends HookContextProvider { private final GitChangesetConverter converter; private final List newTags; - private final GitLazyChangesetResolver changesetResolver; + private final Supplier> changesetResolver; private final List newBranches; GitImportHookContextProvider(GitChangesetConverter converter, List newBranches, List newTags, - GitLazyChangesetResolver changesetResolver) { + Supplier> changesetResolver) { this.converter = converter; this.newTags = newTags; this.changesetResolver = changesetResolver; @@ -81,7 +84,7 @@ class GitImportHookContextProvider extends HookContextProvider { @Override public HookChangesetProvider getChangesetProvider() { - GitConvertingChangesetIterable changesets = new GitConvertingChangesetIterable(changesetResolver.call(), converter); + GitConvertingChangesetIterable changesets = new GitConvertingChangesetIterable(changesetResolver.get(), converter); return r -> new HookChangesetResponse(changesets); } } diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitLazyChangesetResolver.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitLazyChangesetResolver.java index 7a911682c9..942c696b3c 100644 --- a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitLazyChangesetResolver.java +++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitLazyChangesetResolver.java @@ -23,11 +23,11 @@ import sonia.scm.repository.InternalRepositoryException; import sonia.scm.repository.Repository; import java.io.IOException; -import java.util.concurrent.Callable; +import java.util.function.Supplier; import static sonia.scm.ContextEntry.ContextBuilder.entity; -class GitLazyChangesetResolver implements Callable> { +class GitLazyChangesetResolver implements Supplier> { private final Repository repository; private final Git git; @@ -37,7 +37,7 @@ class GitLazyChangesetResolver implements Callable> { } @Override - public Iterable call() { + public Iterable get() { try { return git.log().all().call(); } catch (IOException | GitAPIException e) { diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitLogCommand.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitLogCommand.java index cd98f05450..169264a085 100644 --- a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitLogCommand.java +++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitLogCommand.java @@ -31,7 +31,6 @@ import sonia.scm.repository.ChangesetPagingResult; import sonia.scm.repository.GitChangesetConverter; import sonia.scm.repository.GitChangesetConverterFactory; import sonia.scm.repository.GitUtil; -import sonia.scm.repository.InternalRepositoryException; import sonia.scm.util.IOUtil; import java.io.IOException; @@ -40,10 +39,9 @@ import static sonia.scm.ContextEntry.ContextBuilder.entity; import static sonia.scm.NotFoundException.notFound; -public class GitLogCommand extends AbstractGitCommand implements LogCommand -{ +public class GitLogCommand extends AbstractGitCommand implements LogCommand { + - private static final Logger logger = LoggerFactory.getLogger(GitLogCommand.class); public static final String REVISION = "Revision"; @@ -51,20 +49,16 @@ public class GitLogCommand extends AbstractGitCommand implements LogCommand @Inject - GitLogCommand(@Assisted GitContext context, GitChangesetConverterFactory converterFactory) - { + GitLogCommand(@Assisted GitContext context, GitChangesetConverterFactory converterFactory) { super(context); this.converterFactory = converterFactory; } - @Override @SuppressWarnings("java:S2093") - public Changeset getChangeset(String revision, LogCommandRequest request) - { - if (logger.isDebugEnabled()) - { + public Changeset getChangeset(String revision, LogCommandRequest request) { + if (logger.isDebugEnabled()) { logger.debug("fetch changeset {}", revision); } @@ -73,18 +67,15 @@ public class GitLogCommand extends AbstractGitCommand implements LogCommand GitChangesetConverter converter = null; RevWalk revWalk = null; - try - { + try { gr = open(); - if (!gr.getAllRefs().isEmpty()) - { + if (!gr.getAllRefs().isEmpty()) { revWalk = new RevWalk(gr); ObjectId id = GitUtil.getRevisionId(gr, revision); RevCommit commit = revWalk.parseCommit(id); - if (commit != null) - { + if (commit != null) { converter = converterFactory.create(gr, revWalk); if (isBranchRequested(request)) { @@ -98,23 +89,15 @@ public class GitLogCommand extends AbstractGitCommand implements LogCommand } else { changeset = converter.createChangeset(commit); } - } - else if (logger.isWarnEnabled()) - { + } else if (logger.isWarnEnabled()) { logger.warn("could not find revision {}", revision); } } - } - catch (IOException ex) - { + } catch (IOException ex) { logger.error("could not open repository: " + repository.getNamespaceAndName(), ex); - } - catch (NullPointerException e) - { + } catch (NullPointerException e) { throw notFound(entity(REVISION, revision).in(this.repository)); - } - finally - { + } finally { IOUtil.close(converter); GitUtil.release(revWalk); } @@ -138,14 +121,10 @@ public class GitLogCommand extends AbstractGitCommand implements LogCommand @Override @SuppressWarnings("java:S2093") public ChangesetPagingResult getChangesets(LogCommandRequest request) { - try { - if (Strings.isNullOrEmpty(request.getBranch())) { - request.setBranch(context.getConfig().getDefaultBranch()); - } - return new GitLogComputer(this.repository.getId(), open(), converterFactory).compute(request); - } catch (IOException e) { - throw new InternalRepositoryException(repository, "could not create change log", e); + if (Strings.isNullOrEmpty(request.getBranch())) { + request.setBranch(context.getConfig().getDefaultBranch()); } + return new GitLogComputer(this.repository.getId(), open(), converterFactory).compute(request); } public interface Factory { diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitMergeCommand.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitMergeCommand.java index 100edf8670..3b68a08004 100644 --- a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitMergeCommand.java +++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitMergeCommand.java @@ -32,6 +32,7 @@ import org.eclipse.jgit.treewalk.filter.PathFilter; import sonia.scm.repository.GitRepositoryHandler; import sonia.scm.repository.GitWorkingCopyFactory; import sonia.scm.repository.InternalRepositoryException; +import sonia.scm.repository.RepositoryManager; import sonia.scm.repository.api.MergeCommandResult; import sonia.scm.repository.api.MergeDryRunCommandResult; import sonia.scm.repository.api.MergePreventReason; @@ -56,6 +57,9 @@ public class GitMergeCommand extends AbstractGitCommand implements MergeCommand private final GitWorkingCopyFactory workingCopyFactory; private final AttributeAnalyzer attributeAnalyzer; + private final RepositoryManager repositoryManager; + private final GitRepositoryHookEventFactory eventFactory; + private static final Set STRATEGIES = Set.of( MergeStrategy.MERGE_COMMIT, MergeStrategy.FAST_FORWARD_IF_POSSIBLE, @@ -64,14 +68,24 @@ public class GitMergeCommand extends AbstractGitCommand implements MergeCommand ); @Inject - GitMergeCommand(@Assisted GitContext context, GitRepositoryHandler handler, AttributeAnalyzer attributeAnalyzer) { - this(context, handler.getWorkingCopyFactory(), attributeAnalyzer); + GitMergeCommand(@Assisted GitContext context, + GitRepositoryHandler handler, + AttributeAnalyzer attributeAnalyzer, + RepositoryManager repositoryManager, + GitRepositoryHookEventFactory eventFactory) { + this(context, handler.getWorkingCopyFactory(), attributeAnalyzer, repositoryManager, eventFactory); } - GitMergeCommand(@Assisted GitContext context, GitWorkingCopyFactory workingCopyFactory, AttributeAnalyzer attributeAnalyzer) { + GitMergeCommand(@Assisted GitContext context, + GitWorkingCopyFactory workingCopyFactory, + AttributeAnalyzer attributeAnalyzer, + RepositoryManager repositoryManager, + GitRepositoryHookEventFactory eventFactory) { super(context); this.workingCopyFactory = workingCopyFactory; this.attributeAnalyzer = attributeAnalyzer; + this.repositoryManager = repositoryManager; + this.eventFactory = eventFactory; } @Override @@ -85,22 +99,14 @@ public class GitMergeCommand extends AbstractGitCommand implements MergeCommand } private MergeCommandResult mergeWithStrategy(MergeCommandRequest request) { - switch (request.getMergeStrategy()) { - case SQUASH: - return inClone(clone -> new GitMergeWithSquash(clone, request, context, repository), workingCopyFactory, request.getTargetBranch()); - - case FAST_FORWARD_IF_POSSIBLE: - return inClone(clone -> new GitFastForwardIfPossible(clone, request, context, repository), workingCopyFactory, request.getTargetBranch()); - - case MERGE_COMMIT: - return inClone(clone -> new GitMergeCommit(clone, request, context, repository), workingCopyFactory, request.getTargetBranch()); - - case REBASE: - return inClone(clone -> new GitMergeRebase(clone, request, context, repository), workingCopyFactory, request.getTargetBranch()); - - default: - throw new MergeStrategyNotSupportedException(repository, request.getMergeStrategy()); - } + return switch (request.getMergeStrategy()) { + case SQUASH -> new GitMergeWithSquash(request, context, repositoryManager, eventFactory).run(); + case FAST_FORWARD_IF_POSSIBLE -> + new GitFastForwardIfPossible(request, context, repositoryManager, eventFactory).run(); + case MERGE_COMMIT -> new GitMergeCommit(request, context, repositoryManager, eventFactory).run(); + case REBASE -> new GitMergeRebase(request, context, repositoryManager, eventFactory).run(); + default -> throw new MergeStrategyNotSupportedException(repository, request.getMergeStrategy()); + }; } @Override diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitMergeCommit.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitMergeCommit.java index d06d4db962..98e930032f 100644 --- a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitMergeCommit.java +++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitMergeCommit.java @@ -16,39 +16,21 @@ package sonia.scm.repository.spi; -import org.eclipse.jgit.api.Git; -import org.eclipse.jgit.api.MergeCommand; -import org.eclipse.jgit.api.MergeResult; -import org.eclipse.jgit.revwalk.RevCommit; -import sonia.scm.NoChangesMadeException; -import sonia.scm.repository.Repository; +import org.eclipse.jgit.lib.ObjectId; +import sonia.scm.repository.RepositoryManager; import sonia.scm.repository.api.MergeCommandResult; -import java.io.IOException; -import java.util.Collections; -import java.util.Optional; +class GitMergeCommit { -import static sonia.scm.repository.spi.GitRevisionExtractor.extractRevisionFromRevCommit; + private final MergeCommandRequest request; + private final MergeHelper mergeHelper; -class GitMergeCommit extends GitMergeStrategy { - - GitMergeCommit(Git clone, MergeCommandRequest request, GitContext context, Repository repository) { - super(clone, request, context, repository); + GitMergeCommit(MergeCommandRequest request, GitContext context, RepositoryManager repositoryManager, GitRepositoryHookEventFactory eventFactory) { + this.request = request; + this.mergeHelper = new MergeHelper(context, request, repositoryManager, eventFactory); } - @Override - MergeCommandResult run() throws IOException { - MergeCommand mergeCommand = getClone().merge(); - mergeCommand.setFastForward(MergeCommand.FastForwardMode.NO_FF); - MergeResult result = doMergeInClone(mergeCommand); - - if (result.getMergeStatus().isSuccessful()) { - RevCommit revCommit = doCommit().orElseThrow(() -> new NoChangesMadeException(getRepository())); - push(); - return createSuccessResult(extractRevisionFromRevCommit(revCommit)); - } else { - return analyseFailure(result); - } + MergeCommandResult run() { + return mergeHelper.doRecursiveMerge(request, (sourceRevision, targetRevision) -> new ObjectId[]{targetRevision, sourceRevision}); } - } diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitMergeRebase.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitMergeRebase.java index 12d6269b8d..a2a2cd6614 100644 --- a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitMergeRebase.java +++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitMergeRebase.java @@ -16,73 +16,108 @@ package sonia.scm.repository.spi; -import org.eclipse.jgit.api.Git; -import org.eclipse.jgit.api.MergeCommand; -import org.eclipse.jgit.api.RebaseResult; -import org.eclipse.jgit.api.errors.GitAPIException; +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.slf4j.Logger; -import org.slf4j.LoggerFactory; +import org.eclipse.jgit.lib.PersonIdent; +import org.eclipse.jgit.merge.ResolveMerger; +import org.eclipse.jgit.revwalk.RevCommit; +import org.eclipse.jgit.revwalk.RevSort; +import org.eclipse.jgit.revwalk.RevWalk; import sonia.scm.repository.InternalRepositoryException; -import sonia.scm.repository.Repository; +import sonia.scm.repository.Person; +import sonia.scm.repository.RepositoryManager; import sonia.scm.repository.api.MergeCommandResult; import java.io.IOException; +import java.util.ArrayList; import java.util.Collections; -import java.util.Optional; +import java.util.Iterator; +import java.util.List; -public class GitMergeRebase extends GitMergeStrategy { +import static java.util.Optional.ofNullable; +import static org.eclipse.jgit.merge.MergeStrategy.RESOLVE; - private static final Logger logger = LoggerFactory.getLogger(GitMergeRebase.class); +@Slf4j +class GitMergeRebase { private final MergeCommandRequest request; + private final GitContext context; + private final MergeHelper mergeHelper; + private final CommitHelper commitHelper; + private final GitFastForwardIfPossible fastForwardMerge; - GitMergeRebase(Git clone, MergeCommandRequest request, GitContext context, Repository repository) { - super(clone, request, context, repository); + GitMergeRebase(MergeCommandRequest request, GitContext context, RepositoryManager repositoryManager, GitRepositoryHookEventFactory eventFactory) { this.request = request; + this.context = context; + this.mergeHelper = new MergeHelper(context, request, repositoryManager, eventFactory); + this.commitHelper = new CommitHelper(context, repositoryManager, eventFactory); + this.fastForwardMerge = new GitFastForwardIfPossible(request, context, repositoryManager, eventFactory); } - @Override - MergeCommandResult run() throws IOException { - RebaseResult result; - String branchToMerge = request.getBranchToMerge(); - String targetBranch = request.getTargetBranch(); - try { - checkOutBranch(branchToMerge); - result = - getClone() - .rebase() - .setUpstream(targetBranch) - .call(); - } catch (GitAPIException e) { - throw new InternalRepositoryException(getContext().getRepository(), "could not rebase branch " + branchToMerge + " onto " + targetBranch, e); + MergeCommandResult run() { + log.debug("rebase branch {} onto {}", request.getBranchToMerge(), request.getTargetBranch()); + + ObjectId sourceRevision = mergeHelper.getRevisionToMerge(); + ObjectId targetRevision = mergeHelper.getTargetRevision(); + if (mergeHelper.isMergedInto(targetRevision, sourceRevision)) { + log.trace("fast forward is possible; using fast forward merge"); + return fastForwardMerge.run(); } - if (result.getStatus().isSuccessful()) { - return fastForwardTargetBranch(branchToMerge, targetBranch, result); - } else { - logger.info("could not rebase branch {} into {} with rebase status '{}' due to ...", branchToMerge, targetBranch, result.getStatus()); - logger.info("... conflicts: {}", result.getConflicts()); - logger.info("... failing paths: {}", result.getFailingPaths()); - logger.info("... message: {}", result); - return MergeCommandResult.failure(branchToMerge, targetBranch, Optional.ofNullable(result.getConflicts()).orElse(Collections.singletonList("UNKNOWN"))); + try { + List commits = computeCommits(); + Collections.reverse(commits); + + for (RevCommit commit : commits) { + log.trace("rebase {} onto {}", commit, targetRevision); + ResolveMerger merger = (ResolveMerger) RESOLVE.newMerger(context.open(), true); // The recursive merger is always a RecursiveMerge + merger.setBase(commit.getParent(0)); + boolean mergeSucceeded = merger.merge(commit, targetRevision); + if (!mergeSucceeded) { + log.trace("could not merge {} into {}", commit, targetRevision); + return MergeCommandResult.failure(request.getBranchToMerge(), request.getTargetBranch(), ofNullable(merger.getUnmergedPaths()).orElse(Collections.singletonList("UNKNOWN"))); + } + ObjectId newTreeId = merger.getResultTreeId(); + log.trace("create commit for new tree {}", newTreeId); + + PersonIdent originalAuthor = commit.getAuthorIdent(); + targetRevision = commitHelper.createCommit( + newTreeId, + originalAuthor, + request.getAuthor(), + commit.getFullMessage(), + request.isSign(), + targetRevision + ); + log.trace("created {}", targetRevision); + } + log.trace("update branch {} to new revision {}", request.getTargetBranch(), targetRevision); + commitHelper.updateBranch(request.getTargetBranch(), targetRevision, mergeHelper.getTargetRevision()); + return MergeCommandResult.success(targetRevision.name(), mergeHelper.getRevisionToMerge().name(), targetRevision.name()); + } catch (IOException | CanceledException | UnsupportedSigningFormatException e) { + throw new InternalRepositoryException(context.getRepository(), "could not rebase branch " + request.getBranchToMerge() + " onto " + request.getTargetBranch(), e); } } - private MergeCommandResult fastForwardTargetBranch(String branchToMerge, String targetBranch, RebaseResult result) throws IOException { - try { - getClone().checkout().setName(targetBranch).call(); - ObjectId sourceRevision = resolveRevision(branchToMerge); - getClone() - .merge() - .setFastForward(MergeCommand.FastForwardMode.FF_ONLY) - .include(branchToMerge, sourceRevision) - .call(); - push(); - return createSuccessResult(sourceRevision.name()); - } catch (GitAPIException e) { - return MergeCommandResult.failure(branchToMerge, targetBranch, result.getConflicts()); - } + private List computeCommits() throws IOException { + List cherryPickList = new ArrayList<>(); + try (RevWalk revWalk = new RevWalk(context.open())) { + revWalk.sort(RevSort.TOPO_KEEP_BRANCH_TOGETHER, true); + revWalk.sort(RevSort.COMMIT_TIME_DESC, true); + revWalk.markUninteresting(revWalk.lookupCommit(mergeHelper.getTargetRevision())); + revWalk.markStart(revWalk.lookupCommit(mergeHelper.getRevisionToMerge())); + for (RevCommit commit : revWalk) { + if (commit.getParentCount() <= 1) { + log.trace("add {} to cherry pick list", commit); + cherryPickList.add(commit); + } else { + log.trace("skip {} because it has more than one parent", commit); + } + } + } + return cherryPickList; } } diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitMergeStrategy.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitMergeStrategy.java deleted file mode 100644 index 2d073577f0..0000000000 --- a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitMergeStrategy.java +++ /dev/null @@ -1,123 +0,0 @@ -/* - * 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.common.base.Strings; -import org.eclipse.jgit.api.Git; -import org.eclipse.jgit.api.MergeCommand; -import org.eclipse.jgit.api.MergeResult; -import org.eclipse.jgit.api.errors.GitAPIException; -import org.eclipse.jgit.lib.ObjectId; -import org.eclipse.jgit.revwalk.RevCommit; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import sonia.scm.repository.InternalRepositoryException; -import sonia.scm.repository.Person; -import sonia.scm.repository.api.MergeCommandResult; - -import java.io.IOException; -import java.text.MessageFormat; -import java.util.Optional; - -abstract class GitMergeStrategy extends AbstractGitCommand.GitCloneWorker { - - private static final Logger logger = LoggerFactory.getLogger(GitMergeStrategy.class); - - private static final String MERGE_COMMIT_MESSAGE_TEMPLATE = String.join("\n", - "Merge of branch {0} into {1}", - "", - "Automatic merge by SCM-Manager."); - - private final String targetBranch; - private final ObjectId targetRevision; - private final String branchToMerge; - private final ObjectId revisionToMerge; - private final Person author; - private final String messageTemplate; - private final String message; - private final boolean sign; - - GitMergeStrategy(Git clone, MergeCommandRequest request, GitContext context, sonia.scm.repository.Repository repository) { - super(clone, context, repository); - this.targetBranch = request.getTargetBranch(); - this.branchToMerge = request.getBranchToMerge(); - this.author = request.getAuthor(); - this.messageTemplate = request.getMessageTemplate(); - this.message = request.getMessage(); - this.sign = request.isSign(); - try { - this.targetRevision = resolveRevision(request.getTargetBranch()); - this.revisionToMerge = resolveRevision(request.getBranchToMerge()); - } catch (IOException e) { - throw new InternalRepositoryException(repository, "Could not resolve revisions of target branch or branch to merge", e); - } - } - - MergeResult doMergeInClone(MergeCommand mergeCommand) throws IOException { - MergeResult result; - try { - ObjectId sourceRevision = resolveRevision(branchToMerge); - mergeCommand - .setCommit(false) // we want to set the author manually - .include(branchToMerge, sourceRevision); - - result = mergeCommand.call(); - } catch (GitAPIException e) { - throw new InternalRepositoryException(getContext().getRepository(), "could not merge branch " + branchToMerge + " into " + targetBranch, e); - } - return result; - } - - Optional doCommit() { - logger.debug("merged branch {} into {}", branchToMerge, targetBranch); - return doCommit(determineMessage(), author, sign); - } - - MergeCommandResult createSuccessResult(String newRevision) { - return MergeCommandResult.success(targetRevision.name(), revisionToMerge.name(), newRevision); - } - - ObjectId getTargetRevision() { - return targetRevision; - } - - ObjectId getRevisionToMerge() { - return revisionToMerge; - } - - private String determineMessage() { - if (!Strings.isNullOrEmpty(message)) { - return message; - } else if (!Strings.isNullOrEmpty(messageTemplate)) { - return MessageFormat.format(messageTemplate, branchToMerge, targetBranch); - } else { - return MessageFormat.format(MERGE_COMMIT_MESSAGE_TEMPLATE, branchToMerge, targetBranch); - } - } - - MergeCommandResult analyseFailure(MergeResult result) { - logger.info("could not merge branch {} into {} with merge status '{}' due to ...", branchToMerge, targetBranch, result.getMergeStatus()); - logger.info("... conflicts: {}", result.getConflicts()); - logger.info("... checkout conflicts: {}", result.getCheckoutConflicts()); - logger.info("... failing paths: {}", result.getFailingPaths()); - logger.info("... message: {}", result); - if (result.getConflicts() == null) { - throw new UnexpectedMergeResultException(getRepository(), result); - } - return MergeCommandResult.failure(targetRevision.name(), revisionToMerge.name(), result.getConflicts().keySet()); - } -} diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitMergeWithSquash.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitMergeWithSquash.java index b2d45c9701..82b16083a9 100644 --- a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitMergeWithSquash.java +++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitMergeWithSquash.java @@ -16,36 +16,21 @@ package sonia.scm.repository.spi; -import org.eclipse.jgit.api.Git; -import org.eclipse.jgit.api.MergeCommand; -import org.eclipse.jgit.api.MergeResult; -import org.eclipse.jgit.revwalk.RevCommit; -import sonia.scm.NoChangesMadeException; -import sonia.scm.repository.Repository; +import org.eclipse.jgit.lib.ObjectId; +import sonia.scm.repository.RepositoryManager; import sonia.scm.repository.api.MergeCommandResult; -import java.io.IOException; +class GitMergeWithSquash { -import static sonia.scm.repository.spi.GitRevisionExtractor.extractRevisionFromRevCommit; + private final MergeCommandRequest request; + private final MergeHelper mergeHelper; -class GitMergeWithSquash extends GitMergeStrategy { - - GitMergeWithSquash(Git clone, MergeCommandRequest request, GitContext context, Repository repository) { - super(clone, request, context, repository); + GitMergeWithSquash(MergeCommandRequest request, GitContext context, RepositoryManager repositoryManager, GitRepositoryHookEventFactory eventFactory) { + this.request = request; + this.mergeHelper = new MergeHelper(context, request, repositoryManager, eventFactory); } - @Override - MergeCommandResult run() throws IOException { - MergeCommand mergeCommand = getClone().merge(); - mergeCommand.setSquash(true); - MergeResult result = doMergeInClone(mergeCommand); - - if (result.getMergeStatus().isSuccessful()) { - RevCommit revCommit = doCommit().orElseThrow(() -> new NoChangesMadeException(getRepository())); - push(); - return createSuccessResult(extractRevisionFromRevCommit(revCommit)); - } else { - return analyseFailure(result); - } + MergeCommandResult run() { + return mergeHelper.doRecursiveMerge(request, (sourceRevision, targetRevision) -> new ObjectId[]{targetRevision}); } } diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitModifyCommand.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitModifyCommand.java index a9e5dbc80d..815ef7b6de 100644 --- a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitModifyCommand.java +++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitModifyCommand.java @@ -16,198 +16,194 @@ package sonia.scm.repository.spi; -import com.google.common.util.concurrent.Striped; import com.google.inject.assistedinject.Assisted; import jakarta.inject.Inject; -import org.apache.commons.lang.StringUtils; -import org.eclipse.jgit.api.Git; -import org.eclipse.jgit.api.errors.GitAPIException; -import org.eclipse.jgit.attributes.FilterCommandRegistry; +import org.eclipse.jgit.api.errors.CanceledException; +import org.eclipse.jgit.api.errors.UnsupportedSigningFormatException; +import org.eclipse.jgit.attributes.AttributesNode; +import org.eclipse.jgit.attributes.AttributesRule; import org.eclipse.jgit.dircache.DirCache; +import org.eclipse.jgit.dircache.DirCacheBuilder; +import org.eclipse.jgit.dircache.DirCacheEntry; +import org.eclipse.jgit.dircache.InvalidPathException; +import org.eclipse.jgit.errors.DirCacheNameConflictException; +import org.eclipse.jgit.lib.Constants; +import org.eclipse.jgit.lib.FileMode; +import org.eclipse.jgit.lib.ObjectId; +import org.eclipse.jgit.lib.ObjectInserter; +import org.eclipse.jgit.lib.ObjectLoader; +import org.eclipse.jgit.lib.Repository; import org.eclipse.jgit.revwalk.RevCommit; +import org.eclipse.jgit.revwalk.RevWalk; +import org.eclipse.jgit.treewalk.TreeWalk; +import org.eclipse.jgit.treewalk.filter.PathFilter; import sonia.scm.ConcurrentModificationException; -import sonia.scm.ContextEntry; import sonia.scm.NoChangesMadeException; -import sonia.scm.api.v2.resources.GitRepositoryConfigStoreProvider; -import sonia.scm.repository.GitRepositoryHandler; -import sonia.scm.repository.GitWorkingCopyFactory; +import sonia.scm.repository.GitUtil; import sonia.scm.repository.InternalRepositoryException; -import sonia.scm.repository.Repository; +import sonia.scm.repository.RepositoryManager; import sonia.scm.web.lfs.LfsBlobStoreFactory; +import java.io.ByteArrayOutputStream; import java.io.File; +import java.io.FileInputStream; import java.io.IOException; -import java.nio.file.Path; -import java.util.Optional; -import java.util.concurrent.locks.Lock; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.HashMap; +import java.util.Map; + +import static sonia.scm.AlreadyExistsException.alreadyExists; +import static sonia.scm.ContextEntry.ContextBuilder.entity; +import static sonia.scm.NotFoundException.notFound; +import static sonia.scm.ScmConstraintViolationException.Builder.doThrow; public class GitModifyCommand extends AbstractGitCommand implements ModifyCommand { - private static final Striped REGISTER_LOCKS = Striped.lock(5); - - private final GitWorkingCopyFactory workingCopyFactory; + private final RepositoryManager repositoryManager; + private final GitRepositoryHookEventFactory eventFactory; private final LfsBlobStoreFactory lfsBlobStoreFactory; - private final GitRepositoryConfigStoreProvider gitRepositoryConfigStoreProvider; + + private RevCommit parentCommit; @Inject - GitModifyCommand(@Assisted GitContext context, GitRepositoryHandler repositoryHandler, LfsBlobStoreFactory lfsBlobStoreFactory, GitRepositoryConfigStoreProvider gitRepositoryConfigStoreProvider) { - this(context, repositoryHandler.getWorkingCopyFactory(), lfsBlobStoreFactory, gitRepositoryConfigStoreProvider); + GitModifyCommand(@Assisted GitContext context, LfsBlobStoreFactory lfsBlobStoreFactory, RepositoryManager repositoryManager, GitRepositoryHookEventFactory eventFactory) { + super(context); + this.repositoryManager = repositoryManager; + this.eventFactory = eventFactory; + this.lfsBlobStoreFactory = lfsBlobStoreFactory; } - GitModifyCommand(@Assisted GitContext context, GitWorkingCopyFactory workingCopyFactory, LfsBlobStoreFactory lfsBlobStoreFactory, GitRepositoryConfigStoreProvider gitRepositoryConfigStoreProvider) { - super(context); - this.workingCopyFactory = workingCopyFactory; - this.lfsBlobStoreFactory = lfsBlobStoreFactory; - this.gitRepositoryConfigStoreProvider = gitRepositoryConfigStoreProvider; + private interface TreeChange { + boolean keepOriginalEntry(String path, ObjectId blob); + + default void finish(TreeHelper treeHelper) { + } } @Override public String execute(ModifyCommandRequest request) { - return inClone(clone -> new ModifyWorker(clone, request), workingCopyFactory, request.getBranch()); + try { + org.eclipse.jgit.lib.Repository repository = context.open(); + CommitHelper commitHelper = new CommitHelper(context, repositoryManager, eventFactory); + String branchToChange = request.getBranch() == null ? context.getGlobalConfig().getDefaultBranch() : request.getBranch(); + ObjectId parentCommitId = repository.resolve(GitUtil.getRevString(branchToChange)); + if (parentCommitId == null && request.getBranch() != null && repository.resolve("HEAD") != null) { + throw notFound(entity("Branch", branchToChange).in(this.repository)); + } + if (request.getExpectedRevision() != null && !parentCommitId.name().equals(request.getExpectedRevision())) { + throw new ConcurrentModificationException(entity("Branch", branchToChange).in(this.repository).build()); + } + + InPlaceWorker inPlaceWorker = new InPlaceWorker(repository); + + try (RevWalk revWalk = new RevWalk(repository)) { + parentCommit = parentCommitId == null ? null : revWalk.parseCommit(parentCommitId); + } + + for (ModifyCommandRequest.PartialRequest r : request.getRequests()) { + r.execute(inPlaceWorker); + } + + TreeHelper treeHelper = new TreeHelper(repository); + if (parentCommitId != null) { + treeHelper.initialize(parentCommitId, inPlaceWorker.changes); + } + + inPlaceWorker.finish(treeHelper); + + ObjectId treeId = treeHelper.flush(); + + if (parentCommitId != null) { + if (parentCommit.getTree().equals(treeId)) { + throw new NoChangesMadeException(GitModifyCommand.this.repository, branchToChange); + } + } + + ObjectId commitId = commitHelper.createCommit( + treeId, + request.getAuthor(), + request.getAuthor(), + request.getCommitMessage(), + request.isSign(), + parentCommitId == null ? new ObjectId[0] : new ObjectId[]{parentCommitId} + ); + + commitHelper.updateBranch(branchToChange, commitId, parentCommitId); + + return commitId.name(); + } catch (IOException | CanceledException | UnsupportedSigningFormatException e) { + throw new InternalRepositoryException(repository, "Error during modification", e); + } } - private class ModifyWorker extends GitCloneWorker implements ModifyWorkerHelper { + private static String removeStartingSlash(String toBeCreated) { + return toBeCreated.startsWith("/") ? toBeCreated.substring(1) : toBeCreated; + } - private final File workDir; - private final ModifyCommandRequest request; + private class TreeHelper { - ModifyWorker(Git clone, ModifyCommandRequest request) { - super(clone, context, repository); - this.workDir = clone.getRepository().getWorkTree(); - this.request = request; + private final org.eclipse.jgit.lib.Repository repository; + private final DirCacheBuilder builder; + private final ObjectInserter inserter; + private final DirCache dirCache = DirCache.newInCore(); + + TreeHelper(Repository repository) { + this.repository = repository; + this.inserter = repository.newObjectInserter(); + this.builder = dirCache.builder(); } - @Override - String run() throws IOException { - getClone().getRepository().getFullBranch(); + private void initialize(ObjectId parentCommitId, Collection changes) throws IOException { + ObjectId parentTreeId = getTreeId(parentCommitId); + try (TreeWalk treeWalk = new TreeWalk(repository)) { - boolean initialCommit = getClone().getRepository().getRefDatabase().getRefs().isEmpty(); + treeWalk.addTree(parentTreeId); + treeWalk.setRecursive(true); - if (!StringUtils.isEmpty(request.getExpectedRevision()) - && !request.getExpectedRevision().equals(getCurrentObjectId().getName())) { - throw new ConcurrentModificationException(ContextEntry.ContextBuilder.entity("Branch", request.getBranch() == null ? "default" : request.getBranch()).in(repository).build()); - } - for (ModifyCommandRequest.PartialRequest r : request.getRequests()) { - r.execute(this); - } - failIfNotChanged(() -> new NoChangesMadeException(repository, ModifyWorker.this.request.getBranch())); - Optional revCommit = doCommit(request.getCommitMessage(), request.getAuthor(), request.isSign()); - - if (initialCommit) { - handleBranchForInitialCommit(); - } - - push(); - return revCommit.orElseThrow(() -> new NoChangesMadeException(repository, ModifyWorker.this.request.getBranch())).name(); - } - - private void handleBranchForInitialCommit() { - String branch = StringUtils.isNotBlank(request.getBranch()) ? request.getBranch() : context.getGlobalConfig().getDefaultBranch(); - if (StringUtils.isNotBlank(branch)) { - try { - createBranchIfNotThere(branch); - } catch (GitAPIException | IOException e) { - throw new InternalRepositoryException(repository, "could not create default branch for initial commit", e); + while (treeWalk.next()) { + String path = treeWalk.getPathString(); + if (changes.stream().allMatch(c -> c.keepOriginalEntry(path, treeWalk.getObjectId(0)))) { + DirCacheEntry entry = new DirCacheEntry(path); + entry.setObjectId(treeWalk.getObjectId(0)); + entry.setFileMode(treeWalk.getFileMode(0)); + builder.add(entry); + } } } } - private void createBranchIfNotThere(String branch) throws IOException, GitAPIException { - if (!branch.equals(getClone().getRepository().getBranch())) { - getClone().checkout().setName(branch).setCreateBranch(true).call(); - setBranchInConfig(branch); + ObjectId getTreeId(ObjectId commitId) throws IOException { + try (RevWalk revWalk = new RevWalk(repository)) { + RevCommit commit = revWalk.parseCommit(commitId); + return commit.getTree().getId(); } } - private void setBranchInConfig(String branch) { - gitRepositoryConfigStoreProvider.setDefaultBranch(repository, branch); - } - - @Override - public void addFileToScm(String name, Path file) { - addToGitWithLfsSupport(name, file); - } - - private void addToGitWithLfsSupport(String path, Path targetFile) { - REGISTER_LOCKS.get(targetFile).lock(); + void updateTreeWithNewFile(String filePath, ObjectId blobId) { + if (filePath.startsWith("/")) { + filePath = filePath.substring(1); + } try { - LfsBlobStoreCleanFilterFactory cleanFilterFactory = new LfsBlobStoreCleanFilterFactory(lfsBlobStoreFactory, repository, targetFile); - - String registerKey = "git-lfs clean -- '" + path + "'"; - LOG.debug("register lfs filter command factory for command '{}'", registerKey); - FilterCommandRegistry.register(registerKey, cleanFilterFactory::createFilter); - try { - addFileToGit(path); - } catch (GitAPIException e) { - throwInternalRepositoryException("could not add file to index", e); - } finally { - LOG.debug("unregister lfs filter command factory for command \"{}\"", registerKey); - FilterCommandRegistry.unregister(registerKey); - } - } finally { - REGISTER_LOCKS.get(targetFile).unlock(); + DirCacheEntry newEntry = new DirCacheEntry(filePath); + newEntry.setObjectId(blobId); + newEntry.setFileMode(FileMode.REGULAR_FILE); + builder.add(newEntry); + } catch (InvalidPathException e) { + doThrow().violation("Path", filePath).when(true); } } - @Override - public void addMovedFileToScm(String path, Path targetPath) { + ObjectId flush() throws IOException { try { - addFileToGit(path); - } catch (GitAPIException e) { - throwInternalRepositoryException("could not add file to index", e); + builder.finish(); + } catch (DirCacheNameConflictException e) { + throw alreadyExists(entity("File", e.getPath1()).in(GitModifyCommand.this.repository)); } - } - - private void addFileToGit(String toBeCreated) throws GitAPIException { - String toBeCreatedWithoutLeadingSlash = removeStartingPathSeparators(toBeCreated); - DirCache addResult = getClone().add().addFilepattern(toBeCreatedWithoutLeadingSlash).call(); - if (addResult.findEntry(toBeCreatedWithoutLeadingSlash) < 0) { - throw new ModificationFailedException(ContextEntry.ContextBuilder.entity("File", toBeCreated).in(repository).build(), "Could not add file to repository"); - } - } - - @Override - public void doScmDelete(String toBeDeleted) { - try { - String toBeDeletedWithoutLeadingSlash = removeStartingPathSeparators(toBeDeleted); - DirCache deleteResult = getClone().rm().addFilepattern(toBeDeletedWithoutLeadingSlash).call(); - if (deleteResult.findEntry(toBeDeletedWithoutLeadingSlash) >= 0) { - throw new ModificationFailedException(ContextEntry.ContextBuilder.entity("File", toBeDeleted).in(repository).build(), "Could not delete file from repository"); - } - } catch (GitAPIException e) { - throwInternalRepositoryException("could not remove file from index", e); - } - } - - @Override - public boolean isProtectedPath(Path path) { - return path.startsWith(getClone().getRepository().getDirectory().toPath().normalize()); - } - - @Override - public File getWorkDir() { - return workDir; - } - - @Override - public Repository getRepository() { - return repository; - } - - @Override - public String getBranch() { - return request.getBranch(); - } - - private String removeStartingPathSeparators(String path) { - if (path.startsWith("/")) { - return path.substring(1); - } - return path; - } - - private String throwInternalRepositoryException(String message, Exception e) { - throw new InternalRepositoryException(context.getRepository(), message, e); + ObjectId newTreeId = dirCache.writeTree(inserter); + inserter.flush(); + return newTreeId; } } @@ -215,4 +211,245 @@ public class GitModifyCommand extends AbstractGitCommand implements ModifyComman ModifyCommand create(GitContext context); } + private class InPlaceWorker implements Worker { + private final Collection changes = new ArrayList<>(); + private final Repository repository; + private final Map attributesCache = new HashMap<>(); + + public InPlaceWorker(Repository repository) { + this.repository = repository; + } + + @Override + public void delete(String toBeDeleted, boolean recursive) { + changes.add(new DeleteChange(toBeDeleted, recursive)); + } + + @Override + public void create(String toBeCreated, File file, boolean overwrite) throws IOException { + changes.add(new CreateChange(overwrite, toBeCreated, createBlob(toBeCreated, file))); + } + + @Override + public void modify(String toBeModified, File file) throws IOException { + ObjectId blobId = createBlob(toBeModified, file); + changes.add(new ModifyChange(toBeModified, blobId)); + } + + @Override + public void move(String oldPath, String newPath, boolean overwrite) { + changes.add(new MoveChange(oldPath, newPath)); + } + + public void finish(TreeHelper treeHelper) throws IOException { + for (TreeChange c : changes) { + c.finish(treeHelper); + } + } + + private ObjectId createBlob(String path, File file) throws IOException { + + try (ObjectInserter inserter = repository.newObjectInserter()) { + + if (isLfsFile(path)) { + return writeWithLfs(file, inserter); + } else { + ObjectId blobId = inserter.insert(Constants.OBJ_BLOB, file.length(), new FileInputStream(file)); + inserter.flush(); + return blobId; + } + } + } + + private boolean isLfsFile(String path) { + if (parentCommit == null) { + return false; + } + String[] pathParts = path.split("/"); + + for (int i = pathParts.length; i > 0; --i) { + String directory = i == 1 ? "" : String.join("/", Arrays.copyOf(pathParts, i - 1)) + "/"; + String relativeFileName = path.substring(directory.length()); + if (isLfsFile(directory, relativeFileName)) { + return true; + } + } + return false; + } + + private boolean isLfsFile(String directory, String relativeFileName) { + String attributesPath = directory + ".gitattributes"; + + ObjectId treeId = parentCommit.getTree().getId(); + + return attributesCache + .computeIfAbsent(directory, dir -> loadAttributes(treeId, attributesPath)) + .getRules() + .stream() + .anyMatch(attributes -> hasLfsFilterAttribute(relativeFileName, attributes)); + } + + private boolean hasLfsFilterAttribute(String relativeFileName, AttributesRule attributes) { + if (attributes.isMatch(relativeFileName, false)) { + return attributes.getAttributes().stream().anyMatch(attribute -> attribute.getKey().equals("filter") && attribute.getValue().equals("lfs")); + } + return false; + } + + private AttributesNode loadAttributes(ObjectId treeId, String attributesPath) { + try (TreeWalk treeWalk = new TreeWalk(repository)) { + treeWalk.addTree(treeId); + treeWalk.setRecursive(true); + treeWalk.setFilter(PathFilter.create(attributesPath)); + + AttributesNode attributesNode = new AttributesNode(); + if (treeWalk.next()) { + ObjectId objectId = treeWalk.getObjectId(0); + ObjectLoader loader = repository.open(objectId); + attributesNode.parse(loader.openStream()); + } + return attributesNode; + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + private ObjectId writeWithLfs(File file, ObjectInserter inserter) throws IOException { + LfsBlobStoreCleanFilterFactory cleanFilterFactory = new LfsBlobStoreCleanFilterFactory(lfsBlobStoreFactory, GitModifyCommand.this.repository, file.toPath()); + ByteArrayOutputStream pointer = new ByteArrayOutputStream(); + cleanFilterFactory.createFilter(repository, new FileInputStream(file), pointer).run(); + ObjectId blobId = inserter.insert(Constants.OBJ_BLOB, pointer.toByteArray()); + inserter.flush(); + return blobId; + } + + private class DeleteChange implements TreeChange { + private final String toBeDeleted; + private final boolean recursive; + private final String toBeDeletedAsDirectory; + private boolean foundOriginal; + + public DeleteChange(String toBeDeleted, boolean recursive) { + this.toBeDeleted = removeStartingSlash(toBeDeleted); + this.recursive = recursive; + this.toBeDeletedAsDirectory = this.toBeDeleted + "/"; + } + + @Override + public boolean keepOriginalEntry(String path, ObjectId blob) { + if (path.equals(toBeDeleted) || recursive && path.startsWith(toBeDeletedAsDirectory)) { + foundOriginal = true; + return false; + } + return true; + } + + @Override + public void finish(TreeHelper treeHelper) { + if (!foundOriginal) { + throw notFound(entity("File", toBeDeleted).in(GitModifyCommand.this.repository)); + } + } + } + + private class CreateChange implements TreeChange { + private final String toBeCreated; + private final boolean overwrite; + private final ObjectId blobId; + + public CreateChange(boolean overwrite, String toBeCreated, ObjectId blobId) { + this.toBeCreated = removeStartingSlash(toBeCreated); + this.overwrite = overwrite; + this.blobId = blobId; + } + + @Override + public boolean keepOriginalEntry(String path, ObjectId blob) { + if (path.equals(toBeCreated)) { + if (!overwrite) { + throw alreadyExists(entity("File", toBeCreated).in(GitModifyCommand.this.repository)); + } + return false; + } + return true; + } + + @Override + public void finish(TreeHelper treeHelper) { + treeHelper.updateTreeWithNewFile(toBeCreated, blobId); + } + } + + private class ModifyChange implements TreeChange { + private final String toBeModified; + private final ObjectId blobId; + private boolean foundOriginal; + + public ModifyChange(String toBeModified, ObjectId blobId) { + this.toBeModified = removeStartingSlash(toBeModified); + this.blobId = blobId; + } + + @Override + public boolean keepOriginalEntry(String path, ObjectId blob) { + if (path.equals(toBeModified)) { + foundOriginal = true; + return false; + } + return true; + } + + @Override + public void finish(TreeHelper treeHelper) { + if (!foundOriginal) { + throw notFound(entity("File", toBeModified).in(GitModifyCommand.this.repository)); + } + treeHelper.updateTreeWithNewFile(toBeModified, blobId); + } + } + + private class MoveChange implements TreeChange { + private final String oldPath; + private final String oldPathAsDirectory; + private final String newPath; + private final Collection moves = new ArrayList<>(); + + public MoveChange(String oldPath, String newPath) { + this.oldPath = removeStartingSlash(oldPath); + this.newPath = removeStartingSlash(newPath); + this.oldPathAsDirectory = this.oldPath + "/"; + } + + @Override + public boolean keepOriginalEntry(String path, ObjectId blob) { + if (path.equals(oldPath) || path.startsWith(oldPathAsDirectory)) { + moves.add(new Move(path, blob)); + return false; + } + return !path.equals(newPath); + } + + @Override + public void finish(TreeHelper treeHelper) { + if (moves.isEmpty()) { + throw notFound(entity("File", oldPath).in(GitModifyCommand.this.repository)); + } + moves.forEach(move -> move.move(treeHelper)); + } + + private class Move { + private final String to; + private final ObjectId blobId; + + private Move(String from, ObjectId blobId) { + this.to = from.replace(oldPath, newPath); + this.blobId = blobId; + } + + private void move(TreeHelper treeHelper) { + treeHelper.updateTreeWithNewFile(to, blobId); + } + } + } + } } diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitRepositoryHookEventFactory.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitRepositoryHookEventFactory.java index 605bfcde6e..f729ba1f6d 100644 --- a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitRepositoryHookEventFactory.java +++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitRepositoryHookEventFactory.java @@ -17,6 +17,7 @@ package sonia.scm.repository.spi; import jakarta.inject.Inject; +import org.eclipse.jgit.revwalk.RevCommit; import sonia.scm.repository.GitChangesetConverter; import sonia.scm.repository.GitChangesetConverterFactory; import sonia.scm.repository.RepositoryHookEvent; @@ -24,10 +25,11 @@ import sonia.scm.repository.Tag; import sonia.scm.repository.api.HookContext; import sonia.scm.repository.api.HookContextFactory; -import java.io.IOException; import java.util.List; +import java.util.function.Supplier; import static sonia.scm.repository.RepositoryHookType.POST_RECEIVE; +import static sonia.scm.repository.RepositoryHookType.PRE_RECEIVE; class GitRepositoryHookEventFactory { @@ -40,14 +42,23 @@ class GitRepositoryHookEventFactory { this.changesetConverterFactory = changesetConverterFactory; } - RepositoryHookEvent createEvent(GitContext gitContext, + RepositoryHookEvent createPostReceiveEvent(GitContext gitContext, List branches, List tags, - GitLazyChangesetResolver changesetResolver - ) throws IOException { + Supplier> changesetResolver) { GitChangesetConverter converter = changesetConverterFactory.create(gitContext.open()); GitImportHookContextProvider contextProvider = new GitImportHookContextProvider(converter, branches, tags, changesetResolver); HookContext context = hookContextFactory.createContext(contextProvider, gitContext.getRepository()); return new RepositoryHookEvent(context, gitContext.getRepository(), POST_RECEIVE); } + + RepositoryHookEvent createPreReceiveEvent(GitContext gitContext, + List branches, + List tags, + Supplier> changesetResolver) { + GitChangesetConverter converter = changesetConverterFactory.create(gitContext.open()); + GitImportHookContextProvider contextProvider = new GitImportHookContextProvider(converter, branches, tags, changesetResolver); + HookContext context = hookContextFactory.createContext(contextProvider, gitContext.getRepository()); + return new RepositoryHookEvent(context, gitContext.getRepository(), PRE_RECEIVE); + } } 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/GitTagCommand.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitTagCommand.java index ab4f219c61..a5507c7073 100644 --- a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitTagCommand.java +++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitTagCommand.java @@ -41,6 +41,7 @@ import sonia.scm.repository.PreReceiveRepositoryHookEvent; import sonia.scm.repository.RepositoryHookEvent; import sonia.scm.repository.RepositoryHookType; import sonia.scm.repository.Tag; +import sonia.scm.repository.api.HookBranchProvider; import sonia.scm.repository.api.HookChangesetProvider; import sonia.scm.repository.api.HookContext; import sonia.scm.repository.api.HookContextFactory; @@ -220,6 +221,21 @@ public class GitTagCommand extends AbstractGitCommand implements TagCommand { }; } + @Override + public HookBranchProvider getBranchProvider() { + return new HookBranchProvider() { + @Override + public List getCreatedOrModified() { + return List.of(); + } + + @Override + public List getDeletedOrClosed() { + return List.of(); + } + }; + } + @Override public HookChangesetProvider getChangesetProvider() { Collection receiveCommands = new ArrayList<>(); @@ -230,12 +246,7 @@ public class GitTagCommand extends AbstractGitCommand implements TagCommand { .map(tag -> new ReceiveCommand(fromString(tag.getRevision()), zeroId(), REFS_TAGS_PREFIX + tag.getName())) .forEach(receiveCommands::add); return x -> { - Repository gitRepo; - try { - gitRepo = context.open(); - } catch (IOException e) { - throw new InternalRepositoryException(repository, "failed to open repository for post receive hook after internal change", e); - } + Repository gitRepo = context.open(); GitHookChangesetCollector collector = GitHookChangesetCollector.collectChangesets( converterFactory, diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitUnbundleCommand.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitUnbundleCommand.java index 45bd5b6c20..fa81c3ab3f 100644 --- a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitUnbundleCommand.java +++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitUnbundleCommand.java @@ -73,11 +73,11 @@ public class GitUnbundleCommand extends AbstractGitCommand implements UnbundleCo List branches = extractBranches(git); List tags = extractTags(git); GitLazyChangesetResolver changesetResolver = new GitLazyChangesetResolver(context.getRepository(), git); - RepositoryHookEvent event = eventFactory.createEvent(context, branches, tags, changesetResolver); + RepositoryHookEvent event = eventFactory.createPostReceiveEvent(context, branches, tags, changesetResolver); if (event != null) { request.getPostEventSink().accept(event); } - } catch (IOException | GitAPIException e) { + } catch (GitAPIException e) { throw new ImportFailedException( ContextEntry.ContextBuilder.entity(context.getRepository()).build(), "Could not fire post receive repository hook event after unbundle", 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 new file mode 100644 index 0000000000..ac574374f0 --- /dev/null +++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/MergeHelper.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.common.base.Strings; +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.RecursiveMerger; +import org.eclipse.jgit.merge.ResolveMerger; +import org.eclipse.jgit.revwalk.RevCommit; +import org.eclipse.jgit.revwalk.RevWalk; +import sonia.scm.NoChangesMadeException; +import sonia.scm.repository.InternalRepositoryException; +import sonia.scm.repository.RepositoryManager; +import sonia.scm.repository.api.MergeCommandResult; + +import java.io.IOException; +import java.text.MessageFormat; +import java.util.Collection; +import java.util.Map; +import java.util.function.BiFunction; + +import static org.eclipse.jgit.merge.MergeStrategy.RECURSIVE; +import static sonia.scm.ContextEntry.ContextBuilder.entity; +import static sonia.scm.NotFoundException.notFound; + +@Slf4j +class MergeHelper { + + private static final String MERGE_COMMIT_MESSAGE_TEMPLATE = String.join("\n", + "Merge of branch {0} into {1}", + "", + "Automatic merge by SCM-Manager."); + + private final GitContext context; + private final RepositoryManager repositoryManager; + private final GitRepositoryHookEventFactory eventFactory; + private final Repository repository; + + private final ObjectId targetRevision; + private final ObjectId revisionToMerge; + private final String targetBranch; + private final String branchToMerge; + private final String messageTemplate; + private final String message; + + MergeHelper(GitContext context, + MergeCommandRequest request, + RepositoryManager repositoryManager, + GitRepositoryHookEventFactory eventFactory) { + this.context = context; + this.repositoryManager = repositoryManager; + this.eventFactory = eventFactory; + try { + this.repository = context.open(); + this.targetRevision = resolveRevision(request.getTargetBranch()); + this.revisionToMerge = resolveRevision(request.getBranchToMerge()); + } catch (IOException e) { + throw new InternalRepositoryException(context.getRepository(), "Could not resolve revisions of target branch or branch to merge", e); + } + this.targetBranch = request.getTargetBranch(); + this.branchToMerge = request.getBranchToMerge(); + this.messageTemplate = request.getMessageTemplate(); + 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; + } + + ObjectId getRevisionToMerge() { + return revisionToMerge; + } + + ObjectId resolveRevision(String revision) throws IOException { + ObjectId resolved = repository.resolve(revision); + if (resolved == null) { + throw notFound(entity("Revision", revision).in(context.getRepository())); + } else { + return resolved; + } + } + + String determineMessage() { + if (!Strings.isNullOrEmpty(message)) { + return message; + } else if (!Strings.isNullOrEmpty(messageTemplate)) { + return MessageFormat.format(messageTemplate, branchToMerge, targetBranch); + } else { + return MessageFormat.format(MERGE_COMMIT_MESSAGE_TEMPLATE, branchToMerge, targetBranch); + } + } + + boolean isMergedInto(ObjectId baseRevision, ObjectId revisionToCheck) { + try (RevWalk revWalk = new RevWalk(context.open())) { + RevCommit baseCommit = revWalk.parseCommit(baseRevision); + RevCommit commitToCheck = revWalk.parseCommit(revisionToCheck); + return revWalk.isMergedInto(baseCommit, commitToCheck); + } catch (IOException e) { + throw new InternalRepositoryException(context.getRepository(), "failed to check whether revision " + revisionToCheck + " is merged into " + baseRevision, e); + } + } + + MergeCommandResult doRecursiveMerge(MergeCommandRequest request, BiFunction parents) { + log.trace("merge branch {} into {}", branchToMerge, targetBranch); + try { + org.eclipse.jgit.lib.Repository repository = context.open(); + ObjectId sourceRevision = getRevisionToMerge(); + ObjectId targetRevision = getTargetRevision(); + + assertBranchesNotMerged(request, sourceRevision, targetRevision); + + ResolveMerger merger = (ResolveMerger) RECURSIVE.newMerger(repository, true); // The recursive merger is always a RecursiveMerge + boolean mergeSucceeded = merger.merge(sourceRevision, targetRevision); + if (!mergeSucceeded) { + log.trace("could not merge branch {} into {}", branchToMerge, targetBranch); + return MergeCommandResult.failure(targetRevision.name(), sourceRevision.name(), getFailingPaths(merger)); + } + ObjectId newTreeId = merger.getResultTreeId(); + log.trace("create commit for new tree {}", newTreeId); + + CommitHelper commitHelper = new CommitHelper(context, repositoryManager, eventFactory); + ObjectId commitId = commitHelper.createCommit( + newTreeId, + request.getAuthor(), + request.getAuthor(), + determineMessage(), + request.isSign(), + parents.apply(sourceRevision, targetRevision) + ); + log.trace("created commit {}", commitId); + + commitHelper.updateBranch(request.getTargetBranch(), commitId, targetRevision); + + return MergeCommandResult.success(targetRevision.name(), sourceRevision.name(), commitId.name()); + } catch (IOException | CanceledException | UnsupportedSigningFormatException e) { + throw new InternalRepositoryException(context.getRepository(), "Error during merge", e); + } + } + + private void assertBranchesNotMerged(MergeCommandRequest request, ObjectId sourceRevision, ObjectId targetRevision) throws IOException { + if (isMergedInto(sourceRevision, targetRevision)) { + throw new NoChangesMadeException(context.getRepository(), request.getTargetBranch()); + } + } +} diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/PostReceiveRepositoryHookEventFactory.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/PostReceiveRepositoryHookEventFactory.java index 90af25f255..19c63b6cee 100644 --- a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/PostReceiveRepositoryHookEventFactory.java +++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/PostReceiveRepositoryHookEventFactory.java @@ -20,14 +20,11 @@ import com.google.inject.assistedinject.Assisted; import jakarta.inject.Inject; import org.eclipse.jgit.api.Git; import org.eclipse.jgit.transport.FetchResult; -import sonia.scm.ContextEntry; import sonia.scm.event.ScmEventBus; import sonia.scm.repository.PostReceiveRepositoryHookEvent; import sonia.scm.repository.Tag; import sonia.scm.repository.WrappedRepositoryHookEvent; -import sonia.scm.repository.api.ImportFailedException; -import java.io.IOException; import java.util.List; import java.util.stream.Collectors; @@ -46,18 +43,10 @@ public class PostReceiveRepositoryHookEventFactory { void fireForFetch(Git git, FetchResult result) { PostReceiveRepositoryHookEvent event; - try { - List branches = getBranchesFromFetchResult(result); - List tags = getTagsFromFetchResult(result); - GitLazyChangesetResolver changesetResolver = new GitLazyChangesetResolver(context.getRepository(), git); - event = new PostReceiveRepositoryHookEvent(WrappedRepositoryHookEvent.wrap(eventFactory.createEvent(context, branches, tags, changesetResolver))); - } catch (IOException e) { - throw new ImportFailedException( - ContextEntry.ContextBuilder.entity(context.getRepository()).build(), - "Could not fire post receive repository hook event after fetch", - e - ); - } + List branches = getBranchesFromFetchResult(result); + List tags = getTagsFromFetchResult(result); + GitLazyChangesetResolver changesetResolver = new GitLazyChangesetResolver(context.getRepository(), git); + event = new PostReceiveRepositoryHookEvent(WrappedRepositoryHookEvent.wrap(eventFactory.createPostReceiveEvent(context, branches, tags, changesetResolver))); eventBus.post(event); } diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/update/GitUpdateStepHelper.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/update/GitUpdateStepHelper.java new file mode 100644 index 0000000000..84c850877b --- /dev/null +++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/update/GitUpdateStepHelper.java @@ -0,0 +1,52 @@ +/* + * 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.update; + +import org.eclipse.jgit.storage.file.FileRepositoryBuilder; +import sonia.scm.repository.GitRepositoryHandler; +import sonia.scm.repository.Repository; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +class GitUpdateStepHelper { + + private GitUpdateStepHelper() {} + + static Path determineEffectiveGitFolder(Path path) { + Path bareGitFolder = path.resolve("data"); + Path nonBareGitFolder = bareGitFolder.resolve(".git"); + final Path effectiveGitPath; + if (Files.exists(nonBareGitFolder)) { + effectiveGitPath = nonBareGitFolder; + } else { + effectiveGitPath = bareGitFolder; + } + return effectiveGitPath; + } + + static org.eclipse.jgit.lib.Repository build(File directory) throws IOException { + return new FileRepositoryBuilder().setGitDir(directory).readEnvironment().findGitDir().build(); + } + + static boolean isGitDirectory(Repository repository) { + return GitRepositoryHandler.TYPE_NAME.equals(repository.getType()); + } + +} diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/update/GitV2UpdateStep.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/update/GitV2UpdateStep.java index 7546cd43d5..7f1f4228d3 100644 --- a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/update/GitV2UpdateStep.java +++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/update/GitV2UpdateStep.java @@ -17,22 +17,21 @@ package sonia.scm.repository.update; import jakarta.inject.Inject; -import org.eclipse.jgit.storage.file.FileRepositoryBuilder; import sonia.scm.migration.UpdateException; import sonia.scm.migration.UpdateStep; import sonia.scm.plugin.Extension; import sonia.scm.repository.GitConfigHelper; -import sonia.scm.repository.GitRepositoryHandler; import sonia.scm.repository.Repository; import sonia.scm.repository.RepositoryLocationResolver; import sonia.scm.update.UpdateStepRepositoryMetadataAccess; import sonia.scm.version.Version; -import java.io.File; import java.io.IOException; -import java.nio.file.Files; import java.nio.file.Path; +import static sonia.scm.repository.update.GitUpdateStepHelper.build; +import static sonia.scm.repository.update.GitUpdateStepHelper.determineEffectiveGitFolder; +import static sonia.scm.repository.update.GitUpdateStepHelper.isGitDirectory; import static sonia.scm.version.Version.parse; @Extension @@ -64,30 +63,6 @@ public class GitV2UpdateStep implements UpdateStep { ); } - public Path determineEffectiveGitFolder(Path path) { - Path bareGitFolder = path.resolve("data"); - Path nonBareGitFolder = bareGitFolder.resolve(".git"); - final Path effectiveGitPath; - if (Files.exists(nonBareGitFolder)) { - effectiveGitPath = nonBareGitFolder; - } else { - effectiveGitPath = bareGitFolder; - } - return effectiveGitPath; - } - - private org.eclipse.jgit.lib.Repository build(File directory) throws IOException { - return new FileRepositoryBuilder() - .setGitDir(directory) - .readEnvironment() - .findGitDir() - .build(); - } - - private boolean isGitDirectory(Repository repository) { - return GitRepositoryHandler.TYPE_NAME.equals(repository.getType()); - } - @Override public Version getTargetVersion() { return parse("2.0.0"); diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/update/UpdatePackFilterUpdateStep.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/update/UpdatePackFilterUpdateStep.java new file mode 100644 index 0000000000..181de2e30b --- /dev/null +++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/update/UpdatePackFilterUpdateStep.java @@ -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/. + */ + +package sonia.scm.repository.update; + +import jakarta.inject.Inject; +import sonia.scm.migration.RepositoryUpdateContext; +import sonia.scm.migration.RepositoryUpdateStep; +import sonia.scm.plugin.Extension; +import sonia.scm.repository.GitConfigHelper; +import sonia.scm.repository.Repository; +import sonia.scm.repository.RepositoryLocationResolver; +import sonia.scm.update.UpdateStepRepositoryMetadataAccess; +import sonia.scm.version.Version; + +import java.io.File; +import java.nio.file.Path; + +import static sonia.scm.repository.update.GitUpdateStepHelper.build; +import static sonia.scm.repository.update.GitUpdateStepHelper.determineEffectiveGitFolder; +import static sonia.scm.repository.update.GitUpdateStepHelper.isGitDirectory; +import static sonia.scm.version.Version.parse; + +@Extension +public class UpdatePackFilterUpdateStep implements RepositoryUpdateStep { + + private final RepositoryLocationResolver locationResolver; + private final UpdateStepRepositoryMetadataAccess repositoryMetadataAccess; + + @Inject + public UpdatePackFilterUpdateStep(RepositoryLocationResolver locationResolver, UpdateStepRepositoryMetadataAccess repositoryMetadataAccess) { + this.locationResolver = locationResolver; + this.repositoryMetadataAccess = repositoryMetadataAccess; + } + + @Override + public void doUpdate(RepositoryUpdateContext repositoryUpdateContext) throws Exception { + Path scmmRepositoryLocation = locationResolver.forClass(Path.class).getLocation(repositoryUpdateContext.getRepositoryId()); + Repository scmmRepository = repositoryMetadataAccess.read(scmmRepositoryLocation); + + if (isGitDirectory(scmmRepository)) { + File gitFile = determineEffectiveGitFolder(scmmRepositoryLocation).toFile(); + org.eclipse.jgit.lib.Repository gitRepository = build(gitFile); + GitConfigHelper gitConfigHelper = new GitConfigHelper(); + gitConfigHelper.createScmmConfig(scmmRepository, gitRepository); + } + } + + @Override + public Version getTargetVersion() { + return parse("3.7.0"); + } + + @Override + public String getAffectedDataType() { + return "sonia.scm.plugin.git"; + } + +} 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/main/js/GitGlobalConfiguration.tsx b/scm-plugins/scm-git-plugin/src/main/js/GitGlobalConfiguration.tsx index 9bff653d04..1395b1e198 100644 --- a/scm-plugins/scm-git-plugin/src/main/js/GitGlobalConfiguration.tsx +++ b/scm-plugins/scm-git-plugin/src/main/js/GitGlobalConfiguration.tsx @@ -14,13 +14,11 @@ * along with this program. If not, see https://www.gnu.org/licenses/. */ -import React, { FC, useEffect } from "react"; -import { useForm } from "react-hook-form"; +import React, { FC } from "react"; import { useTranslation } from "react-i18next"; -import { useConfigLink } from "@scm-manager/ui-api"; -import { ConfigurationForm, InputField, Checkbox, validation } from "@scm-manager/ui-components"; -import { Title, useDocumentTitle } from "@scm-manager/ui-core"; +import { ConfigurationForm, Form, Title, useDocumentTitle } from "@scm-manager/ui-core"; import { HalRepresentation } from "@scm-manager/ui-types"; +import { validation } from "@scm-manager/ui-components"; type Props = { link: string; @@ -40,62 +38,41 @@ const GitGlobalConfiguration: FC = ({ link }) => { const [t] = useTranslation("plugins"); useDocumentTitle(t("scm-git-plugin.config.title")); - const { initialConfiguration, isReadOnly, update, ...formProps } = useConfigLink(link); - const { formState, handleSubmit, register, reset } = useForm({ mode: "onChange" }); - - useEffect(() => { - if (initialConfiguration) { - reset(initialConfiguration); - } - }, [initialConfiguration, reset]); - - const isValidDefaultBranch = (value: string) => { - return validation.isBranchValid(value); + const validateLfsWriteAuthorization = (value: string) => { + const authorizationTime = parseInt(value); + return Number.isInteger(authorizationTime) && authorizationTime > 0; }; return ( - - {t("scm-git-plugin.config.title")} - - - - - + link={link} translationPath={["plugins", "scm-git-plugin.config"]}> + {({ watch }) => ( + <> + {t("scm-git-plugin.config.title")} + + + + {!watch("disabled") ? ( + <> + + + + + + + + + + + + + + ) : null} + + )} ); }; diff --git a/scm-plugins/scm-git-plugin/src/main/resources/locales/de/plugins.json b/scm-plugins/scm-git-plugin/src/main/resources/locales/de/plugins.json index 865e1f3aec..dd59ca4053 100644 --- a/scm-plugins/scm-git-plugin/src/main/resources/locales/de/plugins.json +++ b/scm-plugins/scm-git-plugin/src/main/resources/locales/de/plugins.json @@ -21,18 +21,24 @@ "config": { "link": "Git", "title": "Git Konfiguration", - "gcExpression": "GC Cron Ausdruck", - "gcExpressionHelpText": "Benutze Quartz Cron Ausdrücke (SECOND MINUTE HOUR DAYOFMONTH MONTH DAYOFWEEK), um git GC regelmäßig auszuführen.", - "nonFastForwardDisallowed": "Deaktiviere \"Non Fast-Forward\"", - "nonFastForwardDisallowedHelpText": "Git Pushes ablehnen, die nicht \"fast-forward\" sind, wie \"--force\".", - "defaultBranch": "Default Branch", - "defaultBranchHelpText": "Dieser Name wird bei der Initialisierung neuer Git Repositories genutzt. Er hat keine weiteren Auswirkungen (insbesondere hat er keinen Einfluss auf den Branchnamen bei leeren Repositories).", - "defaultBranchValidationError": "Dies ist kein valider Branchname", - "lfsWriteAuthorizationExpirationInMinutes": "Ablaufzeit für LFS Autorisierung", - "lfsWriteAuthorizationExpirationInMinutesHelpText": "Ablaufzeit für den Autorisierungstoken in Minuten, der für LFS Speicheranfragen ausgestellt wird. Wenn der SCM-Manager hinter einem Reverse-Proxy mit Zwischenspeicherung (z. B. Nginx) betrieben wird, sollte dieser Wert auf die Zeit gesetzt werden, die ein LFS-Upload maximal benötigen kann.", - "lfsWriteAuthorizationExpirationInMinutesValidationError": "Has to be at least 1 minute", - "disabled": "Deaktiviert", - "disabledHelpText": "Aktiviere oder deaktiviere das Git Plugin. Nur erlaubt, wenn keine Git Repositories existieren.", + "gcExpression": { + "label": "GC Cron Ausdruck", + "descriptionText": "Benutze Quartz Cron Ausdrücke (SECOND MINUTE HOUR DAYOFMONTH MONTH DAYOFWEEK), um git GC regelmäßig auszuführen." + }, + "nonFastForwardDisallowed": { + "label": "Deaktiviere \"Non Fast-Forward\"", + "descriptionText": "Git Pushes ablehnen, die nicht \"fast-forward\" sind, wie \"--force\"." + }, + "defaultBranch": { + "label": "Default Branch für neu initialisierte Repositorys" + }, + "lfsWriteAuthorizationExpirationInMinutes": { + "label": "Ablaufzeit für LFS Autorisierung", + "descriptionText": "Ablaufzeit für den Autorisierungstoken in Minuten, der für LFS Speicheranfragen ausgestellt wird. Wenn der SCM-Manager hinter einem Reverse-Proxy mit Zwischenspeicherung (z. B. Nginx) betrieben wird, sollte dieser Wert auf die Zeit gesetzt werden, die ein LFS-Upload maximal benötigen kann." + }, + "disabled": { + "label": "Deaktiviere das Git Plugin. Nur erlaubt, wenn keine Git Repositories existieren." + }, "submit": "Speichern" }, "repoConfig": { diff --git a/scm-plugins/scm-git-plugin/src/main/resources/locales/en/plugins.json b/scm-plugins/scm-git-plugin/src/main/resources/locales/en/plugins.json index 4133189e75..4f24e0b172 100644 --- a/scm-plugins/scm-git-plugin/src/main/resources/locales/en/plugins.json +++ b/scm-plugins/scm-git-plugin/src/main/resources/locales/en/plugins.json @@ -21,18 +21,24 @@ "config": { "link": "Git", "title": "Git Configuration", - "gcExpression": "GC Cron Expression", - "gcExpressionHelpText": "Use Quartz Cron Expressions (SECOND MINUTE HOUR DAYOFMONTH MONTH DAYOFWEEK) to run git gc in intervals.", - "nonFastForwardDisallowed": "Disallow Non Fast-Forward", - "nonFastForwardDisallowedHelpText": "Reject git pushes which are non fast-forward such as --force.", - "defaultBranch": "Default Branch", - "defaultBranchHelpText": "This name will be used for the initialization of new git repositories. It has no effect otherwise (especially this cannot change the initial branch name for empty repositories).", - "defaultBranchValidationError": "This is not a valid branch name", - "lfsWriteAuthorizationExpirationInMinutes": "LFS authorization expiration", - "lfsWriteAuthorizationExpirationInMinutesHelpText": "Expiration time of the authorization token generated for LFS put requests in minutes. If SCM-Manager is run behind a reverse proxy that buffers http requests (eg. Nginx), this should set up to the time, an LFS upload may take at maximum.", - "lfsWriteAuthorizationExpirationInMinutesValidationError": "Has to be at least 1 minute", - "disabled": "Disabled", - "disabledHelpText": "Enable or disable the Git plugin. Only allowed if no Git Repositories exist.", + "gcExpression": { + "label": "GC Cron Expression", + "descriptionText": "Use Quartz Cron Expressions (SECOND MINUTE HOUR DAYOFMONTH MONTH DAYOFWEEK) to run git gc in intervals." + }, + "nonFastForwardDisallowed": { + "label": "Disallow Non Fast-Forward", + "descriptionText": "Reject git pushes which are non fast-forward such as --force." + }, + "defaultBranch": { + "label": "Default branch for newly initialized repositories" + }, + "lfsWriteAuthorizationExpirationInMinutes": { + "label": "LFS authorization expiration", + "descriptionText": "Expiration time of the authorization token generated for LFS put requests in minutes. If SCM-Manager is run behind a reverse proxy that buffers http requests (eg. Nginx), this should set up to the time, an LFS upload may take at maximum." + }, + "disabled": { + "label": "Disable the Git plugin. Only allowed if no Git Repositories exist." + }, "submit": "Submit" }, "repoConfig": { diff --git a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/GitChangesetConverterTest.java b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/GitChangesetConverterTest.java index 5f1403fa54..29887052b1 100644 --- a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/GitChangesetConverterTest.java +++ b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/GitChangesetConverterTest.java @@ -29,13 +29,17 @@ import org.bouncycastle.openpgp.PGPSignatureGenerator; import org.bouncycastle.openpgp.operator.jcajce.JcaPGPContentSignerBuilder; import org.bouncycastle.openpgp.operator.jcajce.JcaPGPKeyPair; import org.eclipse.jgit.api.Git; +import org.eclipse.jgit.api.errors.CanceledException; import org.eclipse.jgit.api.errors.GitAPIException; import org.eclipse.jgit.api.errors.JGitInternalException; +import org.eclipse.jgit.api.errors.UnsupportedSigningFormatException; import org.eclipse.jgit.internal.JGitText; -import org.eclipse.jgit.lib.CommitBuilder; +import org.eclipse.jgit.lib.GpgConfig; import org.eclipse.jgit.lib.GpgSignature; -import org.eclipse.jgit.lib.GpgSigner; import org.eclipse.jgit.lib.PersonIdent; +import org.eclipse.jgit.lib.Repository; +import org.eclipse.jgit.lib.Signer; +import org.eclipse.jgit.lib.Signers; import org.eclipse.jgit.revwalk.RevCommit; import org.eclipse.jgit.transport.CredentialsProvider; import org.junit.jupiter.api.AfterAll; @@ -134,21 +138,21 @@ class GitChangesetConverterTest { private PublicKey publicKey; private PGPKeyPair keyPair; - private GpgSigner defaultSigner; + private Signer defaultSigner; @BeforeEach void setUpTestingSignerAndCaptureDefault() throws Exception { - defaultSigner = GpgSigner.getDefault(); + defaultSigner = Signers.get(GpgConfig.GpgFormat.OPENPGP); // we use the same keypair for all tests to speed things up a little bit if (keyPair == null) { keyPair = createKeyPair(); - GpgSigner.setDefault(new TestingGpgSigner(keyPair)); + Signers.set(GpgConfig.GpgFormat.OPENPGP, new TestingGpgSigner(keyPair)); } } @AfterEach void restoreDefaultSigner() { - GpgSigner.setDefault(defaultSigner); + Signers.set(GpgConfig.GpgFormat.OPENPGP, defaultSigner); } @Test @@ -242,7 +246,7 @@ class GitChangesetConverterTest { return new JcaPGPKeyPair(PGPPublicKey.RSA_GENERAL, pair, new Date()); } - private static class TestingGpgSigner extends GpgSigner { + private static class TestingGpgSigner implements Signer { private final PGPKeyPair keyPair; @@ -251,13 +255,7 @@ class GitChangesetConverterTest { } @Override - public boolean canLocateSigningKey(String gpgSigningKey, PersonIdent committer, CredentialsProvider credentialsProvider) { - return true; - } - - @Override - public void sign(CommitBuilder commit, String gpgSigningKey, - PersonIdent committer, CredentialsProvider credentialsProvider) { + public GpgSignature sign(Repository repository, GpgConfig gpgConfig, byte[] bytes, PersonIdent personIdent, String s, CredentialsProvider credentialsProvider) throws CanceledException, IOException, UnsupportedSigningFormatException { try { if (keyPair == null) { throw new JGitInternalException(JGitText.get().unableToSignCommitNoSecretKey); @@ -274,15 +272,18 @@ class GitChangesetConverterTest { ByteArrayOutputStream buffer = new ByteArrayOutputStream(); try (BCPGOutputStream out = new BCPGOutputStream(new ArmoredOutputStream(buffer))) { - signatureGenerator.update(commit.build()); signatureGenerator.generate().encode(out); } - commit.setGpgSignature(new GpgSignature(buffer.toByteArray())); + return new GpgSignature(buffer.toByteArray()); } catch (PGPException | IOException e) { throw new JGitInternalException(e.getMessage(), e); } } + @Override + public boolean canLocateSigningKey(Repository repository, GpgConfig gpgConfig, PersonIdent personIdent, String s, CredentialsProvider credentialsProvider) throws CanceledException { + return true; + } } // register bouncy castle provider on load diff --git a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/GitConfigHelperTest.java b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/GitConfigHelperTest.java new file mode 100644 index 0000000000..ca0ef486fd --- /dev/null +++ b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/GitConfigHelperTest.java @@ -0,0 +1,76 @@ +/* + * 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 org.eclipse.jgit.lib.Repository; +import org.eclipse.jgit.lib.StoredConfig; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.io.IOException; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.doReturn; + +@ExtendWith(MockitoExtension.class) +class GitConfigHelperTest { + @Mock + Repository gitRepository; + + StoredConfig gitConfig; + + sonia.scm.repository.Repository scmmRepository; + + @BeforeEach + void setUp() { + gitConfig = new StoredConfig() { + @Override + public void load() { + // not needed + } + + @Override + public void save() { + // not needed + } + }; + doReturn(gitConfig).when(gitRepository).getConfig(); + + scmmRepository = RepositoryTestData.createHeartOfGold(); + } + + @Test + void shouldSetCorrectScmmRepositoryId() throws IOException { + GitConfigHelper target = new GitConfigHelper(); + + target.createScmmConfig(scmmRepository, gitRepository); + + assertThat(gitConfig.getString("scmm", null, "repositoryid")).isEqualTo(scmmRepository.getId()); + } + + @Test + void shouldAllowUploadpackFilter() throws IOException { + GitConfigHelper target = new GitConfigHelper(); + + target.createScmmConfig(scmmRepository, gitRepository); + + assertThat(gitConfig.getBoolean("uploadpack", "allowFilter", false)).isTrue(); + } +} diff --git a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/GitRepositoryHandlerTest.java b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/GitRepositoryHandlerTest.java index 9c0ed22916..5ef2757fd1 100644 --- a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/GitRepositoryHandlerTest.java +++ b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/GitRepositoryHandlerTest.java @@ -23,8 +23,12 @@ import org.mockito.Mock; import org.mockito.junit.MockitoJUnitRunner; import sonia.scm.schedule.Scheduler; import sonia.scm.store.ConfigurationStoreFactory; +import sonia.scm.store.InMemoryByteConfigurationStoreFactory; +import java.io.BufferedReader; import java.io.File; +import java.io.FileReader; +import java.nio.file.Path; import static org.assertj.core.api.Assertions.assertThat; import static org.junit.Assert.assertEquals; @@ -79,7 +83,6 @@ public class GitRepositoryHandlerTest extends SimpleRepositoryHandlerTestBase { GitConfig config = new GitConfig(); - // TODO fix event bus exception repositoryHandler.setConfig(config); return repositoryHandler; @@ -116,4 +119,28 @@ public class GitRepositoryHandlerTest extends SimpleRepositoryHandlerTestBase { assertThat(new File(nativeRepoDirectory, "HEAD")).hasContent("ref: refs/heads/other"); } + + @Test + public void shouldSetAllowFilterConfigByDefault() throws Exception{ + ConfigurationStoreFactory configurationStoreFactory = new InMemoryByteConfigurationStoreFactory(); + + GitRepositoryHandler repositoryHandler = new GitRepositoryHandler(configurationStoreFactory, + scheduler, repositoryLocationResolver, gitWorkingCopyFactory,null); + GitConfig config = new GitConfig(); + + repositoryHandler.setConfig(config); + repositoryHandler.create(RepositoryTestData.createHeartOfGold("git")); + + Path repositoryPath = repositoryLocationResolver.forClass(Path.class).getLocation(""); + File configFile = repositoryPath.resolve("data/config").toFile(); + + boolean containsAllowFilter = false; + try (BufferedReader br = new BufferedReader(new FileReader(configFile.getAbsolutePath()))) { + do { + String line = br.readLine(); + containsAllowFilter |= line.contains("allowFilter") && line.contains("true"); + } while (br.readLine() != null); + } + assertTrue(containsAllowFilter); + } } diff --git a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/GitTestHelper.java b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/GitTestHelper.java index e13fe90afe..316636dc78 100644 --- a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/GitTestHelper.java +++ b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/GitTestHelper.java @@ -16,11 +16,11 @@ package sonia.scm.repository; -import org.eclipse.jgit.api.errors.CanceledException; -import org.eclipse.jgit.lib.CommitBuilder; +import org.eclipse.jgit.lib.GpgConfig; import org.eclipse.jgit.lib.GpgSignature; -import org.eclipse.jgit.lib.GpgSigner; import org.eclipse.jgit.lib.PersonIdent; +import org.eclipse.jgit.lib.Repository; +import org.eclipse.jgit.lib.Signer; import org.eclipse.jgit.transport.CredentialsProvider; import sonia.scm.security.GPG; import sonia.scm.security.PrivateKey; @@ -38,20 +38,19 @@ public final class GitTestHelper { return new GitChangesetConverterFactory(new NoopGPG()); } - public static class SimpleGpgSigner extends GpgSigner { + public static class SimpleGpgSigner implements Signer { public static byte[] getSignature() { return "SIGNATURE".getBytes(); } @Override - public void sign(CommitBuilder commitBuilder, String s, PersonIdent personIdent, CredentialsProvider - credentialsProvider) throws CanceledException { - commitBuilder.setGpgSignature(new GpgSignature(SimpleGpgSigner.getSignature())); + public GpgSignature sign(Repository repository, GpgConfig gpgConfig, byte[] bytes, PersonIdent personIdent, String s, CredentialsProvider credentialsProvider) { + return new GpgSignature(SimpleGpgSigner.getSignature()); } @Override - public boolean canLocateSigningKey(String s, PersonIdent personIdent, CredentialsProvider credentialsProvider) throws CanceledException { + public boolean canLocateSigningKey(Repository repository, GpgConfig gpgConfig, PersonIdent personIdent, String s, CredentialsProvider credentialsProvider) { return true; } diff --git a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/ScmGpgSignerTest.java b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/ScmGpgSignerTest.java index 47caba200d..ea9378b07a 100644 --- a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/ScmGpgSignerTest.java +++ b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/ScmGpgSignerTest.java @@ -18,12 +18,9 @@ package sonia.scm.repository; import org.eclipse.jgit.api.Git; import org.eclipse.jgit.api.errors.CanceledException; -import org.eclipse.jgit.internal.storage.dfs.DfsRepositoryDescription; -import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository; -import org.eclipse.jgit.lib.CommitBuilder; -import org.eclipse.jgit.lib.GpgSigner; +import org.eclipse.jgit.lib.GpgConfig; 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.transport.CredentialsProvider; import org.junit.jupiter.api.BeforeEach; @@ -79,7 +76,7 @@ class ScmGpgSignerTest { when(gpg.getPrivateKey()).thenReturn(privateKey); - GpgSigner.setDefault(signer); + Signers.set(GpgConfig.GpgFormat.OPENPGP, signer); Path repositoryPath = workdir.resolve("repository"); Git git = Git.init().setDirectory(repositoryPath.toFile()).call(); @@ -103,6 +100,6 @@ class ScmGpgSignerTest { @Test void canLocateSigningKey() throws CanceledException { - assertThat(signer.canLocateSigningKey("foo", personIdent, credentialsProvider)).isTrue(); + assertThat(signer.canLocateSigningKey(null, null, personIdent, "foo", credentialsProvider)).isTrue(); } } 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 f11b7b653b..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,51 +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) - { - context = new GitContext(repositoryDirectory, repository, new GitRepositoryConfigStoreProvider(InMemoryConfigurationStoreFactory.create()), new GitConfig()); + protected GitContext createContext() { + return createContext("main"); + } + + protected GitContext createContext(String defaultBranch) { + if (context == null) { + GitConfig config = new GitConfig(); + 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/GitLazyChangesetResolverTest.java b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitLazyChangesetResolverTest.java index 874014544f..3884b54a1e 100644 --- a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitLazyChangesetResolverTest.java +++ b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitLazyChangesetResolverTest.java @@ -34,7 +34,7 @@ public class GitLazyChangesetResolverTest extends AbstractGitCommandTestBase { @Test public void shouldResolveChangesets() throws IOException { GitLazyChangesetResolver changesetResolver = new GitLazyChangesetResolver(repository, Git.wrap(createContext().open())); - Iterable commits = changesetResolver.call(); + Iterable commits = changesetResolver.get(); RevCommit firstCommit = commits.iterator().next(); assertThat(firstCommit.getId().toString()).isEqualTo("commit a8495c0335a13e6e432df90b3727fa91943189a7 1602078219 -----sp"); @@ -46,7 +46,7 @@ public class GitLazyChangesetResolverTest extends AbstractGitCommandTestBase { public void shouldResolveAllChangesets() throws IOException, GitAPIException { Git git = Git.wrap(createContext().open()); GitLazyChangesetResolver changesetResolver = new GitLazyChangesetResolver(repository, git); - Iterable allCommits = changesetResolver.call(); + Iterable allCommits = changesetResolver.get(); int allCommitsCounter = Iterables.size(allCommits); int singleBranchCommitsCounter = Iterables.size(git.log().call()); @@ -57,7 +57,7 @@ public class GitLazyChangesetResolverTest extends AbstractGitCommandTestBase { public void shouldThrowImportFailedException() { Git git = mock(Git.class); doThrow(ImportFailedException.class).when(git).log(); - new GitLazyChangesetResolver(repository, git).call(); + new GitLazyChangesetResolver(repository, git).get(); } } 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/GitMergeCommandConflictTest.java b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitMergeCommandConflictTest.java index 5d8d8be25e..0246d95449 100644 --- a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitMergeCommandConflictTest.java +++ b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitMergeCommandConflictTest.java @@ -90,7 +90,7 @@ public class GitMergeCommandConflictTest extends AbstractGitCommandTestBase { private MergeConflictResult computeMergeConflictResult(String branchToMerge, String targetBranch) { AttributeAnalyzer attributeAnalyzer = mock(AttributeAnalyzer.class); when(attributeAnalyzer.hasExternalMergeToolConflicts(any(), any())).thenReturn(false); - GitMergeCommand gitMergeCommand = new GitMergeCommand(createContext(), new SimpleGitWorkingCopyFactory(new NoneCachingWorkingCopyPool(new WorkdirProvider(null, repositoryLocationResolver)), new SimpleMeterRegistry()), attributeAnalyzer); + GitMergeCommand gitMergeCommand = new GitMergeCommand(createContext(), new SimpleGitWorkingCopyFactory(new NoneCachingWorkingCopyPool(new WorkdirProvider(null, repositoryLocationResolver)), new SimpleMeterRegistry()), attributeAnalyzer, null, null); MergeCommandRequest mergeCommandRequest = new MergeCommandRequest(); mergeCommandRequest.setBranchToMerge(branchToMerge); mergeCommandRequest.setTargetBranch(targetBranch); diff --git a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitMergeCommandTest.java b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitMergeCommandTest.java index 487db356b0..2b8d9439fd 100644 --- a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitMergeCommandTest.java +++ b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitMergeCommandTest.java @@ -23,24 +23,28 @@ import org.apache.shiro.subject.SimplePrincipalCollection; import org.apache.shiro.subject.Subject; import org.eclipse.jgit.api.Git; import org.eclipse.jgit.api.errors.GitAPIException; -import org.eclipse.jgit.lib.GpgSigner; +import org.eclipse.jgit.lib.GpgConfig; import org.eclipse.jgit.lib.ObjectId; import org.eclipse.jgit.lib.PersonIdent; +import org.eclipse.jgit.lib.RefUpdate; import org.eclipse.jgit.lib.Repository; +import org.eclipse.jgit.lib.Signers; import org.eclipse.jgit.revwalk.RevCommit; import org.junit.BeforeClass; import org.junit.Rule; import org.junit.Test; -import org.junit.jupiter.api.Assertions; import org.junit.runner.RunWith; import org.mockito.Mock; import org.mockito.junit.MockitoJUnitRunner; +import sonia.scm.ConcurrentModificationException; import sonia.scm.NoChangesMadeException; import sonia.scm.NotFoundException; import sonia.scm.repository.Added; import sonia.scm.repository.GitTestHelper; import sonia.scm.repository.GitWorkingCopyFactory; import sonia.scm.repository.Person; +import sonia.scm.repository.RepositoryHookEvent; +import sonia.scm.repository.RepositoryManager; import sonia.scm.repository.api.MergeCommandResult; import sonia.scm.repository.api.MergeDryRunCommandResult; import sonia.scm.repository.api.MergePreventReason; @@ -50,15 +54,17 @@ import sonia.scm.repository.work.NoneCachingWorkingCopyPool; import sonia.scm.repository.work.WorkdirProvider; import sonia.scm.user.User; -import java.io.BufferedWriter; -import java.io.File; -import java.io.FileWriter; import java.io.IOException; import java.util.List; import java.util.function.Consumer; import java.util.function.Function; import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @RunWith(MockitoJUnitRunner.class) @@ -69,14 +75,16 @@ public class GitMergeCommandTest extends AbstractGitCommandTestBase { @Rule public ShiroRule shiro = new ShiroRule(); - @Rule - public BindTransportProtocolRule transportProtocolRule = new BindTransportProtocolRule(); @Mock private AttributeAnalyzer attributeAnalyzer; + @Mock + private RepositoryManager repositoryManager; + @Mock + private GitRepositoryHookEventFactory eventFactory; @BeforeClass public static void setSigner() { - GpgSigner.setDefault(new GitTestHelper.SimpleGpgSigner()); + Signers.set(GpgConfig.GpgFormat.OPENPGP, new GitTestHelper.SimpleGpgSigner()); } @Test @@ -248,27 +256,26 @@ public class GitMergeCommandTest extends AbstractGitCommandTestBase { assertThat(mergeCommandResult.getFilesWithConflict()).containsExactly("a.txt"); } - @Test - public void shouldHandleUnexpectedMergeResults() { - GitMergeCommand command = createCommand(git -> { - try { - FileWriter fw = new FileWriter(new File(git.getRepository().getWorkTree(), "b.txt"), true); - BufferedWriter bw = new BufferedWriter(fw); - bw.write("change"); - bw.newLine(); - bw.close(); - } catch (IOException e) { - e.printStackTrace(); - } - }); + @Test(expected = ConcurrentModificationException.class) + public void shouldHandleConcurrentBranchModification() { + GitMergeCommand command = createCommand(); MergeCommandRequest request = new MergeCommandRequest(); - request.setBranchToMerge("mergeable"); request.setTargetBranch("master"); + request.setBranchToMerge("mergeable"); request.setMergeStrategy(MergeStrategy.MERGE_COMMIT); request.setAuthor(new Person("Dirk Gently", "dirk@holistic.det")); - request.setMessageTemplate("simple"); - Assertions.assertThrows(UnexpectedMergeResultException.class, () -> command.merge(request)); + // create concurrent modification after the pre commit hook was fired + doAnswer(invocation -> { + RefUpdate refUpdate = createCommand() + .open() + .updateRef("refs/heads/master"); + refUpdate.setNewObjectId(ObjectId.fromString("2f95f02d9c568594d31e78464bd11a96c62e3f91")); + refUpdate.update(); + return null; + }).when(repositoryManager).fireHookEvent(any()); + + command.merge(request); } @Test @@ -344,6 +351,7 @@ public class GitMergeCommandTest extends AbstractGitCommandTestBase { Iterable commits = new Git(repository).log().add(repository.resolve("master")).setMaxCount(1).call(); RevCommit mergeCommit = commits.iterator().next(); + assertThat(mergeCommit.getParentCount()).isEqualTo(1); PersonIdent mergeAuthor = mergeCommit.getAuthorIdent(); String message = mergeCommit.getFullMessage(); assertThat(mergeAuthor.getName()).isEqualTo("Dirk Gently"); @@ -370,6 +378,8 @@ public class GitMergeCommandTest extends AbstractGitCommandTestBase { String message = mergeCommit.getFullMessage(); assertThat(mergeAuthor.getName()).isEqualTo("Dirk Gently"); assertThat(message).isEqualTo("squash three commits"); + assertThat(mergeCommit.getParentCount()).isEqualTo(1); + assertThat(mergeCommit.getParent(0).name()).isEqualTo("fcd0ef1831e4002ac43ea539f4094334c79ea9ec"); GitModificationsCommand modificationsCommand = new GitModificationsCommand(createContext()); List changes = modificationsCommand.getModifications("master").getAdded(); @@ -533,6 +543,33 @@ public class GitMergeCommandTest extends AbstractGitCommandTestBase { assertThat(mergeCommit.getParent(0).name()).isEqualTo("fcd0ef1831e4002ac43ea539f4094334c79ea9ec"); assertThat(mergeCommit.getName()).isEqualTo(mergeCommandResult.getNewHeadRevision()); assertThat(mergeCommit.getName()).doesNotStartWith("91b99de908fcd04772798a31c308a64aea1a5523"); + assertThat(mergeCommit.getAuthorIdent().getWhenAsInstant()).isEqualTo("2018-11-07T10:20:52Z"); // the timestamp of the original commit + } + + @Test + public void shouldRebaseMultipleCommits() throws IOException, GitAPIException { + GitMergeCommand command = createCommand(); + MergeCommandRequest request = new MergeCommandRequest(); + request.setTargetBranch("master"); + request.setBranchToMerge("squash"); + request.setMergeStrategy(MergeStrategy.REBASE); + request.setAuthor(new Person("Dirk Gently", "dirk@holistic.det")); + + command.merge(request); + + Repository repository = createContext().open(); + Iterable commits = new Git(repository).log().add(repository.resolve("master")).setMaxCount(6).call(); + + assertThat(commits) + .extracting("shortMessage") + .containsExactly( + "third", + "second commit", + "first commit", + "added new line for blame", + "added file f", + "added file d and e in folder c" + ); } @Test @@ -547,11 +584,31 @@ public class GitMergeCommandTest extends AbstractGitCommandTestBase { MergeCommandResult mergeCommandResult = command.merge(request); assertThat(mergeCommandResult.isSuccess()).isFalse(); + assertThat(mergeCommandResult.getFilesWithConflict()).containsExactly("a.txt"); Repository repository = createContext().open(); Iterable commits = new Git(repository).log().add(repository.resolve("master")).setMaxCount(1).call(); RevCommit headCommit = commits.iterator().next(); assertThat(headCommit.getName()).isEqualTo("fcd0ef1831e4002ac43ea539f4094334c79ea9ec"); + } + @Test + public void shouldFireEvents() { + RepositoryHookEvent preReceive = mock(RepositoryHookEvent.class); + RepositoryHookEvent postReceive = mock(RepositoryHookEvent.class); + when(eventFactory.createPreReceiveEvent(any(), eq(List.of("master")), any(), any())).thenReturn(preReceive); + when(eventFactory.createPostReceiveEvent(any(), eq(List.of("master")), any(), any())).thenReturn(postReceive); + + GitMergeCommand command = createCommand(); + MergeCommandRequest request = new MergeCommandRequest(); + request.setTargetBranch("master"); + request.setBranchToMerge("mergeable"); + request.setMergeStrategy(MergeStrategy.MERGE_COMMIT); + request.setAuthor(new Person("Dirk Gently", "dirk@holistic.det")); + + command.merge(request); + + verify(repositoryManager).fireHookEvent(preReceive); + verify(repositoryManager).fireHookEvent(postReceive); } private GitMergeCommand createCommand() { @@ -560,7 +617,7 @@ public class GitMergeCommandTest extends AbstractGitCommandTestBase { } private GitMergeCommand createCommand(Consumer interceptor) { - return new GitMergeCommand(createContext(), new SimpleGitWorkingCopyFactory(new NoneCachingWorkingCopyPool(new WorkdirProvider(null, repositoryLocationResolver)), new SimpleMeterRegistry()), attributeAnalyzer) { + return new GitMergeCommand(createContext(), new SimpleGitWorkingCopyFactory(new NoneCachingWorkingCopyPool(new WorkdirProvider(null, repositoryLocationResolver)), new SimpleMeterRegistry()), attributeAnalyzer, repositoryManager, eventFactory) { @Override > R inClone(Function workerSupplier, GitWorkingCopyFactory workingCopyFactory, String initialBranch) { Function interceptedWorkerSupplier = git -> { diff --git a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitModifyCommandTest.java b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitModifyCommandTest.java index 616c4a15e6..ad6d2bc395 100644 --- a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitModifyCommandTest.java +++ b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitModifyCommandTest.java @@ -16,12 +16,12 @@ package sonia.scm.repository.spi; -import com.github.sdorra.shiro.SubjectAware; -import org.apache.shiro.subject.SimplePrincipalCollection; -import org.apache.shiro.subject.Subject; import org.eclipse.jgit.api.Git; +import org.eclipse.jgit.api.ResetCommand; import org.eclipse.jgit.api.errors.GitAPIException; import org.eclipse.jgit.lib.ObjectId; +import org.eclipse.jgit.lib.RefUpdate; +import org.eclipse.jgit.lib.Repository; import org.eclipse.jgit.revwalk.RevCommit; import org.eclipse.jgit.revwalk.RevWalk; import org.eclipse.jgit.treewalk.TreeWalk; @@ -29,12 +29,12 @@ import org.junit.Test; import sonia.scm.AlreadyExistsException; import sonia.scm.BadRequestException; import sonia.scm.ConcurrentModificationException; +import sonia.scm.NoChangesMadeException; import sonia.scm.NotFoundException; import sonia.scm.ScmConstraintViolationException; import sonia.scm.repository.GitTestHelper; import sonia.scm.repository.Person; import sonia.scm.repository.RepositoryHookType; -import sonia.scm.user.User; import java.io.File; import java.io.IOException; @@ -45,14 +45,14 @@ import static java.util.concurrent.TimeUnit.SECONDS; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.fail; import static org.awaitility.Awaitility.await; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.argThat; import static org.mockito.Mockito.description; +import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.verify; public class GitModifyCommandTest extends GitModifyCommandTestBase { - private static final String REALM = "AdminRealm"; - @Override protected String getZippedRepositoryResource() { return "sonia/scm/repository/spi/scm-git-spi-move-test.zip"; @@ -263,6 +263,38 @@ public class GitModifyCommandTest extends GitModifyCommandTestBase { } } + @Test + public void shouldDeleteDirectoryButNotFileWithSamePrefix() throws IOException { + GitModifyCommand command = createCommand(); + + ModifyCommandRequest request = prepareModifyCommandRequest(); + request.setBranch("similar-paths"); + request.addRequest(new ModifyCommandRequest.DeleteFileRequest("c", true)); + + command.execute(request); + + boolean foundCTxt = false; + + Repository repository = createContext().open(); + ObjectId lastCommit = repository.resolve("refs/heads/similar-paths"); + try (RevWalk walk = new RevWalk(repository)) { + RevCommit commit = walk.parseCommit(lastCommit); + ObjectId treeId = commit.getTree().getId(); + TreeWalk treeWalk = new TreeWalk(repository); + treeWalk.setRecursive(true); + treeWalk.addTree(treeId); + while (treeWalk.next()) { + if (treeWalk.getPathString().startsWith("c/")) { + fail("directory should be deleted"); + } + if (treeWalk.getPathString().equals("c.txt")) { + foundCTxt = true; + } + } + } + assertThat(foundCTxt).isTrue(); + } + @Test(expected = NotFoundException.class) public void shouldThrowNotFoundExceptionWhenFileToDeleteDoesNotExist() { GitModifyCommand command = createCommand(); @@ -346,10 +378,10 @@ public class GitModifyCommandTest extends GitModifyCommandTestBase { command.execute(request); - verify(transportProtocolRule.repositoryManager, description("pre receive hook event expected")) + verify(repositoryManager, description("pre receive hook event expected")) .fireHookEvent(argThat(argument -> argument.getType() == RepositoryHookType.PRE_RECEIVE)); await().pollInterval(50, MILLISECONDS).atMost(1, SECONDS).untilAsserted(() -> - verify(transportProtocolRule.repositoryManager, description("post receive hook event expected")) + verify(repositoryManager, description("post receive hook event expected")) .fireHookEvent(argThat(argument -> argument.getType() == RepositoryHookType.POST_RECEIVE)) ); } @@ -511,7 +543,7 @@ public class GitModifyCommandTest extends GitModifyCommandTestBase { assertInTree(assertions); } - @Test(expected = AlreadyExistsException.class) + @Test(expected = NoChangesMadeException.class) public void shouldFailMoveAndKeepFilesWhenSourceAndTargetAreTheSame() { GitModifyCommand command = createCommand(); @@ -521,18 +553,31 @@ public class GitModifyCommandTest extends GitModifyCommandTestBase { command.execute(request); } + @Test(expected = ConcurrentModificationException.class) + public void shouldFailOnConcurrent() throws IOException, GitAPIException { + File newFile = Files.write(tempFolder.newFile().toPath(), "new content".getBytes()).toFile(); + GitModifyCommand command = createCommand(); + ModifyCommandRequest request = prepareModifyCommandRequest(); + request.setBranch("master"); + request.addRequest(new ModifyCommandRequest.CreateFileRequest("new_file", newFile, false)); + + // create concurrent modification after the pre commit hook was fired + doAnswer(invocation -> { + RefUpdate refUpdate = createCommand() + .open() + .updateRef("refs/heads/master"); + refUpdate.setNewObjectId(ObjectId.fromString("a7d622087b6847725670ae84fa37bdf451123008")); + refUpdate.update(); + return null; + }).when(repositoryManager).fireHookEvent(any()); + + command.execute(request); + } + private ModifyCommandRequest prepareModifyCommandRequest() { ModifyCommandRequest request = new ModifyCommandRequest(); request.setCommitMessage("Make some change"); request.setAuthor(new Person("Dirk Gently", "dirk@holistic.det")); return request; } - - private ModifyCommandRequest prepareModifyCommandRequestWithoutAuthorEmail() { - ModifyCommandRequest request = new ModifyCommandRequest(); - request.setAuthor(new Person("Dirk Gently", "")); - request.setCommitMessage("Make some change"); - return request; - } - } 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 3dba080ba3..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 @@ -18,39 +18,42 @@ package sonia.scm.repository.spi; import com.github.sdorra.shiro.ShiroRule; import com.github.sdorra.shiro.SubjectAware; -import io.micrometer.core.instrument.simple.SimpleMeterRegistry; import org.eclipse.jgit.api.Git; import org.eclipse.jgit.api.errors.GitAPIException; -import org.eclipse.jgit.lib.GpgSigner; +import org.eclipse.jgit.lib.GpgConfig; import org.eclipse.jgit.lib.ObjectId; +import org.eclipse.jgit.lib.Signers; import org.eclipse.jgit.revwalk.RevCommit; import org.eclipse.jgit.revwalk.RevWalk; import org.eclipse.jgit.treewalk.TreeWalk; import org.junit.BeforeClass; import org.junit.Rule; import sonia.scm.repository.GitTestHelper; -import sonia.scm.repository.work.NoneCachingWorkingCopyPool; -import sonia.scm.repository.work.WorkdirProvider; +import sonia.scm.repository.RepositoryHookEvent; +import sonia.scm.repository.RepositoryHookType; +import sonia.scm.repository.RepositoryManager; import sonia.scm.web.lfs.LfsBlobStoreFactory; import java.io.IOException; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.mock; -import static sonia.scm.repository.spi.GitRepositoryConfigStoreProviderTestUtil.createGitRepositoryConfigStoreProvider; +import static org.mockito.Mockito.when; +import static sonia.scm.repository.RepositoryHookType.POST_RECEIVE; +import static sonia.scm.repository.RepositoryHookType.PRE_RECEIVE; @SubjectAware(configuration = "classpath:sonia/scm/configuration/shiro.ini", username = "admin", password = "secret") class GitModifyCommandTestBase extends AbstractGitCommandTestBase { - @Rule - public BindTransportProtocolRule transportProtocolRule = new BindTransportProtocolRule(); @Rule public ShiroRule shiro = new ShiroRule(); final LfsBlobStoreFactory lfsBlobStoreFactory = mock(LfsBlobStoreFactory.class); + final RepositoryManager repositoryManager = mock(RepositoryManager.class); @BeforeClass public static void setSigner() { - GpgSigner.setDefault(new GitTestHelper.SimpleGpgSigner()); + Signers.set(GpgConfig.GpgFormat.OPENPGP, new GitTestHelper.SimpleGpgSigner()); } RevCommit getLastCommit(Git git) throws GitAPIException, IOException { @@ -58,11 +61,23 @@ class GitModifyCommandTestBase extends AbstractGitCommandTestBase { } GitModifyCommand createCommand() { + GitRepositoryHookEventFactory eventFactory = mock(GitRepositoryHookEventFactory.class); + RepositoryHookEvent preReceiveEvent = mockEvent(PRE_RECEIVE); + when(eventFactory.createPreReceiveEvent(any(), any(), any(), any())).thenReturn(preReceiveEvent); + RepositoryHookEvent postReceiveEvent = mockEvent(POST_RECEIVE); + when(eventFactory.createPostReceiveEvent(any(), any(), any(), any())).thenReturn(postReceiveEvent); return new GitModifyCommand( - createContext(), - new SimpleGitWorkingCopyFactory(new NoneCachingWorkingCopyPool(new WorkdirProvider(null, repositoryLocationResolver)), new SimpleMeterRegistry()), + createContext("master"), lfsBlobStoreFactory, - createGitRepositoryConfigStoreProvider()); + repositoryManager, + eventFactory + ); + } + + private static RepositoryHookEvent mockEvent(RepositoryHookType type) { + RepositoryHookEvent mock = mock(RepositoryHookEvent.class); + when(mock.getType()).thenReturn(type); + return mock; } void assertInTree(TreeAssertions assertions) throws IOException, GitAPIException { diff --git a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitModifyCommand_LFSTest.java b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitModifyCommand_LFSTest.java index 6f662f1503..1ecbc0d681 100644 --- a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitModifyCommand_LFSTest.java +++ b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitModifyCommand_LFSTest.java @@ -68,6 +68,36 @@ public class GitModifyCommand_LFSTest extends GitModifyCommandTestBase { assertThat(outputStream).hasToString("new content"); } + @Test + public void shouldCreateCommitInSubdirectoryWithAttributesInSamePath() throws IOException, GitAPIException { + ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); + String newRef = createCommit("jpegs/new_lfs.jpg", "new content", "fe32608c9ef5b6cf7e3f946480253ff76f24f4ec0678f3d0f07f9844cbff9601", outputStream); + + try (Git git = new Git(createContext().open())) { + RevCommit lastCommit = getLastCommit(git); + assertThat(lastCommit.getFullMessage()).isEqualTo("test commit"); + assertThat(lastCommit.getAuthorIdent().getName()).isEqualTo("Dirk Gently"); + assertThat(newRef).isEqualTo(lastCommit.toObjectId().name()); + } + + assertThat(outputStream).hasToString("new content"); + } + + @Test + public void shouldCreateCommitInSubdirectoryWithAttributesInParentPath() throws IOException, GitAPIException { + ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); + String newRef = createCommit("jpegs/new_lfs.png", "new content", "fe32608c9ef5b6cf7e3f946480253ff76f24f4ec0678f3d0f07f9844cbff9601", outputStream); + + try (Git git = new Git(createContext().open())) { + RevCommit lastCommit = getLastCommit(git); + assertThat(lastCommit.getFullMessage()).isEqualTo("test commit"); + assertThat(lastCommit.getAuthorIdent().getName()).isEqualTo("Dirk Gently"); + assertThat(newRef).isEqualTo(lastCommit.toObjectId().name()); + } + + assertThat(outputStream).hasToString("new content"); + } + @Test public void shouldCreateSecondCommits() throws IOException, GitAPIException { createCommit("new_lfs.png", "new content", "fe32608c9ef5b6cf7e3f946480253ff76f24f4ec0678f3d0f07f9844cbff9601", new ByteArrayOutputStream()); diff --git a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitPostReceiveRepositoryHookEventFactoryTest.java b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitPostReceiveRepositoryHookEventFactoryTest.java index 3c6b6922af..0e3b381a10 100644 --- a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitPostReceiveRepositoryHookEventFactoryTest.java +++ b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitPostReceiveRepositoryHookEventFactoryTest.java @@ -60,7 +60,7 @@ public class GitPostReceiveRepositoryHookEventFactoryTest extends AbstractGitCom when(hookContext.getBranchProvider().getCreatedOrModified()).thenReturn(branches); when(hookContext.getTagProvider().getCreatedTags()).thenReturn(tags); - RepositoryHookEvent event = eventFactory.createEvent( + RepositoryHookEvent event = eventFactory.createPostReceiveEvent( createContext(), branches, tags, 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/GitTagCommandTest.java b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitTagCommandTest.java index 4a6a2df4f2..35d5cf2bde 100644 --- a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitTagCommandTest.java +++ b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitTagCommandTest.java @@ -22,8 +22,9 @@ import org.apache.shiro.subject.Subject; import org.apache.shiro.util.ThreadContext; import org.eclipse.jgit.api.Git; import org.eclipse.jgit.api.errors.GitAPIException; -import org.eclipse.jgit.lib.GpgSigner; +import org.eclipse.jgit.lib.GpgConfig; import org.eclipse.jgit.lib.ObjectId; +import org.eclipse.jgit.lib.Signers; import org.eclipse.jgit.revwalk.RevCommit; import org.eclipse.jgit.revwalk.RevWalk; import org.junit.After; @@ -81,7 +82,7 @@ public class GitTagCommandTest extends AbstractGitCommandTestBase { @Before public void setSigner() { - GpgSigner.setDefault(new GitTestHelper.SimpleGpgSigner()); + Signers.set(GpgConfig.GpgFormat.OPENPGP, new GitTestHelper.SimpleGpgSigner()); } @Before diff --git a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitUnbundleCommandTest.java b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitUnbundleCommandTest.java index d98268b0e6..d61b02204e 100644 --- a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitUnbundleCommandTest.java +++ b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitUnbundleCommandTest.java @@ -49,7 +49,7 @@ public class GitUnbundleCommandTest extends AbstractGitCommandTestBase { @Test public void shouldUnbundleRepositoryFiles() throws IOException { RepositoryHookEvent event = new RepositoryHookEvent(null, repository, RepositoryHookType.POST_RECEIVE); - when(eventFactory.createEvent(eq(createContext()), any(), any(), any())).thenReturn(event); + when(eventFactory.createPostReceiveEvent(eq(createContext()), any(), any(), any())).thenReturn(event); AtomicReference receivedEvent = new AtomicReference<>(); 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/java/sonia/scm/repository/update/UpdatePackFilterUpdateStepTest.java b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/update/UpdatePackFilterUpdateStepTest.java new file mode 100644 index 0000000000..3b3ddbe241 --- /dev/null +++ b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/update/UpdatePackFilterUpdateStepTest.java @@ -0,0 +1,109 @@ +/* + * 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.update; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.api.io.TempDir; +import org.mockito.junit.jupiter.MockitoExtension; +import sonia.scm.TempDirRepositoryLocationResolver; +import sonia.scm.migration.RepositoryUpdateContext; +import sonia.scm.repository.Repository; +import sonia.scm.repository.RepositoryLocationResolver; +import sonia.scm.repository.RepositoryTestData; +import sonia.scm.update.UpdateStepRepositoryMetadataAccess; + +import java.io.BufferedReader; +import java.io.File; +import java.io.FileReader; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.stream.Stream; + +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.fail; +import static org.junit.jupiter.api.io.CleanupMode.ALWAYS; + +@ExtendWith(MockitoExtension.class) +class UpdatePackFilterUpdateStepTest { + + RepositoryLocationResolver repositoryLocationResolver; + + UpdateStepRepositoryMetadataAccess updateStepRepositoryMetadataAccess; + + @Nested + class DoUpdate { + + @TempDir(cleanup = ALWAYS) + Path tempDir; + + UpdatePackFilterUpdateStep target; + + private static final String EXAMPLE_REPOSITORY_ID = "3ZUZMNJn3E"; + + @BeforeEach + void setUp() throws IOException { + String sourcePath = "src/test/resources/scm-home/repositories/exampleGitRepoWithoutFilterUpdate"; + loadFilesIntoTempDir(tempDir, sourcePath); + + repositoryLocationResolver = new TempDirRepositoryLocationResolver(tempDir.toFile()); + updateStepRepositoryMetadataAccess = location -> { + Repository repository = RepositoryTestData.createRestaurantAtTheEndOfTheUniverse("git"); + repository.setId(EXAMPLE_REPOSITORY_ID); + return repository; + }; + } + + @Test + void shouldWriteAllowFilterLineWithinConfig() throws Exception { + target = new UpdatePackFilterUpdateStep(repositoryLocationResolver, updateStepRepositoryMetadataAccess); + + target.doUpdate(new RepositoryUpdateContext(EXAMPLE_REPOSITORY_ID)); + + File configFile = tempDir.resolve("data/config").toFile(); + + boolean containsAllowFilter = false; + try (BufferedReader br = new BufferedReader(new FileReader(configFile.getAbsolutePath()))) { + do { + String line = br.readLine(); + containsAllowFilter |= line.contains("allowFilter") && line.contains("true"); + } while (br.readLine() != null); + } + assertTrue(containsAllowFilter); + } + } + + private void loadFilesIntoTempDir(Path tempDir, String sourcePath) throws IOException { + File repositorySourcePath = new File(sourcePath); + try (Stream sources = Files.walk(repositorySourcePath.toPath())) { + sources.forEach(source -> { + Path destination = Paths.get(tempDir.toString(), source.toString().substring(repositorySourcePath.toString().length())); + if (!destination.toFile().exists()) { + try { + Files.copy(source, destination); + } catch (IOException e) { + fail("An exception occurred during temporary repository file setup.", e); + } + } + }); + } + } +} diff --git a/scm-plugins/scm-git-plugin/src/test/resources/scm-home/repositories/exampleGitRepoWithoutFilterUpdate/data/HEAD b/scm-plugins/scm-git-plugin/src/test/resources/scm-home/repositories/exampleGitRepoWithoutFilterUpdate/data/HEAD new file mode 100644 index 0000000000..b870d82622 --- /dev/null +++ b/scm-plugins/scm-git-plugin/src/test/resources/scm-home/repositories/exampleGitRepoWithoutFilterUpdate/data/HEAD @@ -0,0 +1 @@ +ref: refs/heads/main diff --git a/scm-plugins/scm-git-plugin/src/test/resources/scm-home/repositories/exampleGitRepoWithoutFilterUpdate/data/config b/scm-plugins/scm-git-plugin/src/test/resources/scm-home/repositories/exampleGitRepoWithoutFilterUpdate/data/config new file mode 100644 index 0000000000..3a3200072a --- /dev/null +++ b/scm-plugins/scm-git-plugin/src/test/resources/scm-home/repositories/exampleGitRepoWithoutFilterUpdate/data/config @@ -0,0 +1,7 @@ +[core] + repositoryformatversion = 0 + filemode = true + bare = true + logallrefupdates = false +[scmm] + repositoryid = 3ZUZMNJn3E diff --git a/scm-plugins/scm-git-plugin/src/test/resources/scm-home/repositories/exampleGitRepoWithoutFilterUpdate/data/objects/pack/pack-242e6b7438d6969e52d7b7f75215a3e58151b4ec.idx b/scm-plugins/scm-git-plugin/src/test/resources/scm-home/repositories/exampleGitRepoWithoutFilterUpdate/data/objects/pack/pack-242e6b7438d6969e52d7b7f75215a3e58151b4ec.idx new file mode 100644 index 0000000000..42f2125f3d Binary files /dev/null and b/scm-plugins/scm-git-plugin/src/test/resources/scm-home/repositories/exampleGitRepoWithoutFilterUpdate/data/objects/pack/pack-242e6b7438d6969e52d7b7f75215a3e58151b4ec.idx differ diff --git a/scm-plugins/scm-git-plugin/src/test/resources/scm-home/repositories/exampleGitRepoWithoutFilterUpdate/data/objects/pack/pack-242e6b7438d6969e52d7b7f75215a3e58151b4ec.pack b/scm-plugins/scm-git-plugin/src/test/resources/scm-home/repositories/exampleGitRepoWithoutFilterUpdate/data/objects/pack/pack-242e6b7438d6969e52d7b7f75215a3e58151b4ec.pack new file mode 100644 index 0000000000..72d37156cb Binary files /dev/null and b/scm-plugins/scm-git-plugin/src/test/resources/scm-home/repositories/exampleGitRepoWithoutFilterUpdate/data/objects/pack/pack-242e6b7438d6969e52d7b7f75215a3e58151b4ec.pack differ diff --git a/scm-plugins/scm-git-plugin/src/test/resources/scm-home/repositories/exampleGitRepoWithoutFilterUpdate/data/refs/heads/main b/scm-plugins/scm-git-plugin/src/test/resources/scm-home/repositories/exampleGitRepoWithoutFilterUpdate/data/refs/heads/main new file mode 100644 index 0000000000..ff4d480a8d --- /dev/null +++ b/scm-plugins/scm-git-plugin/src/test/resources/scm-home/repositories/exampleGitRepoWithoutFilterUpdate/data/refs/heads/main @@ -0,0 +1 @@ +43932c31dda188da38d96682d50df14a9407579e diff --git a/scm-plugins/scm-git-plugin/src/test/resources/scm-home/repositories/exampleGitRepoWithoutFilterUpdate/metadata.xml b/scm-plugins/scm-git-plugin/src/test/resources/scm-home/repositories/exampleGitRepoWithoutFilterUpdate/metadata.xml new file mode 100644 index 0000000000..8a4d51d25f --- /dev/null +++ b/scm-plugins/scm-git-plugin/src/test/resources/scm-home/repositories/exampleGitRepoWithoutFilterUpdate/metadata.xml @@ -0,0 +1,34 @@ + + + + + + 3ZUZMNJn3E + test + exampleGitRepoWithoutFilterUpdate + git + + + 1736402353654 + 1736402353753 + + false + scmadmin + OWNER + + false + diff --git a/scm-plugins/scm-git-plugin/src/test/resources/scm-home/repositories/exampleGitRepoWithoutFilterUpdate/store/config/executedUpdates.xml b/scm-plugins/scm-git-plugin/src/test/resources/scm-home/repositories/exampleGitRepoWithoutFilterUpdate/store/config/executedUpdates.xml new file mode 100644 index 0000000000..0b1a49a777 --- /dev/null +++ b/scm-plugins/scm-git-plugin/src/test/resources/scm-home/repositories/exampleGitRepoWithoutFilterUpdate/store/config/executedUpdates.xml @@ -0,0 +1,25 @@ + + + + + + sonia.scm.dao.xml + + 2.0.0 + + + diff --git a/scm-plugins/scm-git-plugin/src/test/resources/scm-home/repositories/exampleGitRepoWithoutFilterUpdate/store/config/gitConfig.xml b/scm-plugins/scm-git-plugin/src/test/resources/scm-home/repositories/exampleGitRepoWithoutFilterUpdate/store/config/gitConfig.xml new file mode 100644 index 0000000000..9b3ac72cc5 --- /dev/null +++ b/scm-plugins/scm-git-plugin/src/test/resources/scm-home/repositories/exampleGitRepoWithoutFilterUpdate/store/config/gitConfig.xml @@ -0,0 +1,21 @@ + + + + + main + false + 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-lfs-test.zip b/scm-plugins/scm-git-plugin/src/test/resources/sonia/scm/repository/spi/scm-git-spi-lfs-test.zip index b97f519684..dc383f79d2 100644 Binary files a/scm-plugins/scm-git-plugin/src/test/resources/sonia/scm/repository/spi/scm-git-spi-lfs-test.zip and b/scm-plugins/scm-git-plugin/src/test/resources/sonia/scm/repository/spi/scm-git-spi-lfs-test.zip differ diff --git a/scm-plugins/scm-git-plugin/src/test/resources/sonia/scm/repository/spi/scm-git-spi-move-test.zip b/scm-plugins/scm-git-plugin/src/test/resources/sonia/scm/repository/spi/scm-git-spi-move-test.zip index 15ddfe1c13..ec020a6677 100644 Binary files a/scm-plugins/scm-git-plugin/src/test/resources/sonia/scm/repository/spi/scm-git-spi-move-test.zip and b/scm-plugins/scm-git-plugin/src/test/resources/sonia/scm/repository/spi/scm-git-spi-move-test.zip differ 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-plugins/scm-hg-plugin/package.json b/scm-plugins/scm-hg-plugin/package.json index caeb4ec0ae..f64fe240ef 100644 --- a/scm-plugins/scm-hg-plugin/package.json +++ b/scm-plugins/scm-hg-plugin/package.json @@ -1,7 +1,7 @@ { "name": "@scm-manager/scm-hg-plugin", "private": true, - "version": "3.6.1-SNAPSHOT", + "version": "3.7.5-SNAPSHOT", "license": "AGPL-3.0-only", "main": "./src/main/js/index.ts", "scripts": { @@ -10,7 +10,8 @@ "typecheck": "tsc" }, "dependencies": { - "@scm-manager/ui-plugins": "3.6.1-SNAPSHOT" + "@scm-manager/ui-plugins": "3.7.5-SNAPSHOT", + "react-query": "^3.25.1" }, "devDependencies": { "@scm-manager/babel-preset": "^2.13.1", @@ -32,4 +33,4 @@ "eslintConfig": { "extends": "@scm-manager/eslint-config" } -} \ No newline at end of file +} diff --git a/scm-plugins/scm-hg-plugin/src/main/java/sonia/scm/api/v2/resources/HgConfigResource.java b/scm-plugins/scm-hg-plugin/src/main/java/sonia/scm/api/v2/resources/HgConfigResource.java index 5d7f5d3b04..4a2c7bbbd3 100644 --- a/scm-plugins/scm-hg-plugin/src/main/java/sonia/scm/api/v2/resources/HgConfigResource.java +++ b/scm-plugins/scm-hg-plugin/src/main/java/sonia/scm/api/v2/resources/HgConfigResource.java @@ -152,7 +152,7 @@ public class HgConfigResource { HgGlobalConfig config = dtoToConfigMapper.map(configDto); ConfigurationPermissions.write(config).check(); - if (config.getHgBinary() != null) { + if (!config.isDisabled() && config.getHgBinary() != null) { HgVerifier.HgVerifyStatus verifyStatus = new HgVerifier().verify(config.getHgBinary()); doThrow() .violation(verifyStatus.getDescription()) diff --git a/scm-plugins/scm-hg-plugin/src/main/js/HgConfigurationForm.tsx b/scm-plugins/scm-hg-plugin/src/main/js/HgConfigurationForm.tsx deleted file mode 100644 index 7583f433ca..0000000000 --- a/scm-plugins/scm-hg-plugin/src/main/js/HgConfigurationForm.tsx +++ /dev/null @@ -1,138 +0,0 @@ -/* - * 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, useEffect, useState } from "react"; -import { useTranslation } from "react-i18next"; -import { Link, Links } from "@scm-manager/ui-types"; -import { apiClient, Button, Checkbox, InputField } from "@scm-manager/ui-components"; - -type Configuration = { - disabled: boolean; - allowDisable: boolean; - hgBinary: string; - encoding: string; - showRevisionInId: boolean; - enableHttpPostArgs: boolean; - _links: Links; -}; - -type Props = { - initialConfiguration: Configuration; - readOnly: boolean; - onConfigurationChange: (p1: Configuration, p2: boolean) => void; -}; - -const HgConfigurationForm: FC = ({ initialConfiguration, onConfigurationChange, readOnly }) => { - const [validationErrors, setValidationErrors] = useState([]); - const [configuration, setConfiguration] = useState(initialConfiguration); - const [t] = useTranslation("plugins"); - - useEffect(() => setConfiguration(initialConfiguration), [initialConfiguration]); - useEffect(() => onConfigurationChange(configuration, updateValidationStatus()), [configuration]); - - const updateValidationStatus = () => { - const errors = []; - if (!configuration.hgBinary) { - errors.push("hgBinary"); - } - if (!configuration.encoding) { - errors.push("encoding"); - } - - setValidationErrors(errors); - return errors.length === 0; - }; - - const hasValidationError = (name: string) => { - return validationErrors.indexOf(name) >= 0; - }; - - const triggerAutoConfigure = () => { - apiClient - .put( - (initialConfiguration._links.autoConfiguration as Link).href, - { ...initialConfiguration, hgBinary: configuration.hgBinary }, - "application/vnd.scmm-hgConfig+json;v=2" - ) - .then(() => - apiClient - .get((initialConfiguration._links.self as Link).href) - .then(r => r.json()) - .then((config: Configuration) => setConfiguration({ ...configuration, hgBinary: config.hgBinary })) - ) - .then(() => onConfigurationChange(configuration, updateValidationStatus())); - }; - - return ( -
- setConfiguration({ ...configuration, hgBinary: value })} - validationError={hasValidationError("hgBinary")} - errorMessage={t("scm-hg-plugin.config.required")} - disabled={readOnly} - /> - setConfiguration({ ...configuration, encoding: value })} - validationError={hasValidationError("encoding")} - errorMessage={t("scm-hg-plugin.config.required")} - disabled={readOnly} - /> - setConfiguration({ ...configuration, showRevisionInId: value })} - disabled={readOnly} - /> - setConfiguration({ ...configuration, enableHttpPostArgs: value })} - disabled={readOnly} - /> - { - setConfiguration({ ...configuration, disabled: value }); - }} - disabled={readOnly || !configuration.allowDisable} - /> - -
- ); -}; - -export default HgConfigurationForm; diff --git a/scm-plugins/scm-hg-plugin/src/main/js/HgGlobalConfiguration.tsx b/scm-plugins/scm-hg-plugin/src/main/js/HgGlobalConfiguration.tsx index 6235affbd3..91a3646f8d 100644 --- a/scm-plugins/scm-hg-plugin/src/main/js/HgGlobalConfiguration.tsx +++ b/scm-plugins/scm-hg-plugin/src/main/js/HgGlobalConfiguration.tsx @@ -16,23 +16,55 @@ import React, { FC } from "react"; import { useTranslation } from "react-i18next"; -import { Configuration } from "@scm-manager/ui-components"; -import { Title, useDocumentTitle } from "@scm-manager/ui-core"; -import HgConfigurationForm from "./HgConfigurationForm"; +import { Button, ConfigurationForm, Form, Title, useDocumentTitle } from "@scm-manager/ui-core"; +import { SmallLoadingSpinner, validation } from "@scm-manager/ui-components"; +import { HgGlobalConfigurationDto, useHgAutoConfiguration } from "./hooks"; type Props = { link: string; }; const HgGlobalConfiguration: FC = ({ link }) => { + const { mutate: triggerAutoConfiguration, isLoading: isAutoConfigLoading } = useHgAutoConfiguration(link); const [t] = useTranslation("plugins"); useDocumentTitle(t("scm-hg-plugin.config.title")); + const isHgBinaryValid = (hgBinaryPath: string | undefined | null) => { + return !hgBinaryPath || validation.isPathValid(hgBinaryPath); + }; + return ( -
- {t("scm-hg-plugin.config.title")} - } /> -
+ link={link} translationPath={["plugins", "scm-hg-plugin.config"]}> + {({ watch, getValues }) => ( + <> + {t("scm-hg-plugin.config.title")} + + + + {!watch("disabled") ? ( + <> + + + + + + + + + + + + + + + + + ) : null} + + )} + ); }; diff --git a/scm-plugins/scm-hg-plugin/src/main/js/HgRepositoryConfigurationForm.tsx b/scm-plugins/scm-hg-plugin/src/main/js/HgRepositoryConfigurationForm.tsx index 9f4cb407c3..7ad844324e 100644 --- a/scm-plugins/scm-hg-plugin/src/main/js/HgRepositoryConfigurationForm.tsx +++ b/scm-plugins/scm-hg-plugin/src/main/js/HgRepositoryConfigurationForm.tsx @@ -62,8 +62,7 @@ const HgRepositoryConfigurationForm: FC = ({ repository }) => { {updated ? {t("scm-hg-plugin.config.success")} : null} { + const queryClient = useQueryClient(); + const { mutate, isLoading } = useMutation( + (config) => apiClient.put(requiredLink(config, "autoConfiguration"), config), + { + onSuccess: () => { + return queryClient.invalidateQueries(["configLink", configLink]); + }, + } + ); + return { mutate, isLoading }; +}; + +const requiredLink = (halObject: HalRepresentation, linkName: string): string => { + if (!halObject._links[linkName]) { + throw new Error("Could not find link: " + linkName); + } + return (halObject._links[linkName] as Link).href; +}; + export const useHgRepositoryConfiguration = (repository: Repository) => { const [isLoading, setLoading] = useState(false); const [data, setData] = useState(null); diff --git a/scm-plugins/scm-hg-plugin/src/main/resources/locales/de/plugins.json b/scm-plugins/scm-hg-plugin/src/main/resources/locales/de/plugins.json index 79b24ffea3..3bd423d2a5 100644 --- a/scm-plugins/scm-hg-plugin/src/main/resources/locales/de/plugins.json +++ b/scm-plugins/scm-hg-plugin/src/main/resources/locales/de/plugins.json @@ -11,17 +11,22 @@ "config": { "link": "Mercurial", "title": "Mercurial Konfiguration", - "hgBinary": "HG Binary", - "hgBinaryHelpText": "Pfad des Mercurial Binary", - "encoding": "Encoding", - "encodingHelpText": "Repository Encoding", - "showRevisionInId": "Revision anzeigen", - "showRevisionInIdHelpText": "Die Revision als Teil der Node ID anzeigen", - "enableHttpPostArgs": "HttpPostArgs Protocol aktivieren", - "enableHttpPostArgsHelpText": "Aktiviert das experimentelle HttpPostArgs Protokoll von Mercurial. Das HttpPostArgs Protokoll verwendet den Post Request Body anstatt des HTTP Headers um Meta Informationen zu versenden. Dieses Vorgehen reduziert die Header Größe der Mercurial Requests. HttpPostArgs wird seit Mercurial 3.8 unterstützt.", - "disabled": "Deaktiviert", - "disabledHelpText": "Aktiviert oder deaktiviert das Mercurial Plugin. Nur erlaubt, wenn keine Mercurial Repositories existieren.", - "required": "Dieser Konfigurationswert wird benötigt", + "disabled": { + "label": "Deaktiviert das Mercurial Plugin. Nur erlaubt, wenn keine Mercurial Repositories existieren." + }, + "hgBinary": { + "label": "Pfad des Mercurial Binary" + }, + "encoding": { + "label": "Repository Encoding" + }, + "showRevisionInId": { + "label": "Die Revision als Teil der Node ID anzeigen" + }, + "enableHttpPostArgs": { + "label": "HttpPostArgs Protocol aktivieren", + "descriptionText": "Aktiviert das experimentelle HttpPostArgs Protokoll von Mercurial. Das HttpPostArgs Protokoll verwendet den Post Request Body anstatt des HTTP Headers um Meta Informationen zu versenden. Dieses Vorgehen reduziert die Header Größe der Mercurial Requests. HttpPostArgs wird seit Mercurial 3.8 unterstützt." + }, "submit": "Speichern", "success": "Einstellungen wurden erfolgreich geändert", "autoConfigure": "Mercurial automatisch konfigurieren" diff --git a/scm-plugins/scm-hg-plugin/src/main/resources/locales/en/plugins.json b/scm-plugins/scm-hg-plugin/src/main/resources/locales/en/plugins.json index 43df120b83..6ba73785c2 100644 --- a/scm-plugins/scm-hg-plugin/src/main/resources/locales/en/plugins.json +++ b/scm-plugins/scm-hg-plugin/src/main/resources/locales/en/plugins.json @@ -11,17 +11,22 @@ "config": { "link": "Mercurial", "title": "Mercurial Configuration", - "hgBinary": "HG Binary", - "hgBinaryHelpText": "Location of Mercurial binary", - "encoding": "Encoding", - "encodingHelpText": "Repository Encoding", - "showRevisionInId": "Show Revision", - "showRevisionInIdHelpText": "Show revision as part of the node id", - "enableHttpPostArgs": "Enable HttpPostArgs Protocol", - "enableHttpPostArgsHelpText": "Enables the experimental HttpPostArgs Protocol of mercurial. The HttpPostArgs Protocol uses the body of post requests to send the meta information instead of http headers. This helps to reduce the header size of mercurial requests. HttpPostArgs is supported since mercurial 3.8.", - "disabled": "Disabled", - "disabledHelpText": "Enable or disable the Mercurial plugin. Only allowed if no Mercurial repositories exist.", - "required": "This configuration value is required", + "disabled": { + "label": "Disable the Mercurial plugin. Only allowed if no Mercurial repositories exist." + }, + "hgBinary": { + "label": "Path to the mercurial binary" + }, + "encoding": { + "label": "Repository encoding" + }, + "showRevisionInId": { + "label": "Show revision as part of the node id" + }, + "enableHttpPostArgs": { + "label": "Enable HttpPostArgs Protocol", + "descriptionText": "Enables the experimental HttpPostArgs Protocol of mercurial. The HttpPostArgs Protocol uses the body of post requests to send the meta information instead of http headers. This helps to reduce the header size of mercurial requests. HttpPostArgs is supported since mercurial 3.8." + }, "submit": "Submit", "success": "Configuration changed successfully", "autoConfigure": "Configure Mercurial Automatically" diff --git a/scm-plugins/scm-hg-plugin/src/test/java/sonia/scm/api/v2/resources/HgConfigResourceTest.java b/scm-plugins/scm-hg-plugin/src/test/java/sonia/scm/api/v2/resources/HgConfigResourceTest.java index ac66ed441b..eacf26773c 100644 --- a/scm-plugins/scm-hg-plugin/src/test/java/sonia/scm/api/v2/resources/HgConfigResourceTest.java +++ b/scm-plugins/scm-hg-plugin/src/test/java/sonia/scm/api/v2/resources/HgConfigResourceTest.java @@ -161,6 +161,13 @@ public class HgConfigResourceTest { assertEquals(400, response.getStatus()); } + @Test + @SubjectAware(username = "writeOnly") + public void shouldUpdateInvalidBinaryIfConfigIsDisabled() throws URISyntaxException { + MockHttpResponse response = put("{\"disabled\": true, \"hgBinary\":\"3.2.1\"}"); + assertEquals(HttpServletResponse.SC_NO_CONTENT, response.getStatus()); + } + @Test @SubjectAware(username = "readOnly") public void shouldNotUpdateConfigWhenNotAuthorized() throws URISyntaxException, UnsupportedEncodingException { diff --git a/scm-plugins/scm-legacy-plugin/package.json b/scm-plugins/scm-legacy-plugin/package.json index de76eaa440..e51ce3959c 100644 --- a/scm-plugins/scm-legacy-plugin/package.json +++ b/scm-plugins/scm-legacy-plugin/package.json @@ -1,7 +1,7 @@ { "name": "@scm-manager/scm-legacy-plugin", "private": true, - "version": "3.6.1-SNAPSHOT", + "version": "3.7.5-SNAPSHOT", "license": "AGPL-3.0-only", "main": "./src/main/js/index.tsx", "scripts": { @@ -10,7 +10,7 @@ "typecheck": "tsc" }, "dependencies": { - "@scm-manager/ui-plugins": "3.6.1-SNAPSHOT" + "@scm-manager/ui-plugins": "3.7.5-SNAPSHOT" }, "devDependencies": { "@scm-manager/babel-preset": "^2.13.1", diff --git a/scm-plugins/scm-svn-plugin/package.json b/scm-plugins/scm-svn-plugin/package.json index 4765cf909a..2696d15149 100644 --- a/scm-plugins/scm-svn-plugin/package.json +++ b/scm-plugins/scm-svn-plugin/package.json @@ -1,7 +1,7 @@ { "name": "@scm-manager/scm-svn-plugin", "private": true, - "version": "3.6.1-SNAPSHOT", + "version": "3.7.5-SNAPSHOT", "license": "AGPL-3.0-only", "main": "./src/main/js/index.ts", "scripts": { @@ -10,7 +10,7 @@ "typecheck": "tsc" }, "dependencies": { - "@scm-manager/ui-plugins": "3.6.1-SNAPSHOT" + "@scm-manager/ui-plugins": "3.7.5-SNAPSHOT" }, "devDependencies": { "@scm-manager/babel-preset": "^2.13.1", diff --git a/scm-plugins/scm-svn-plugin/src/main/js/SvnConfigurationForm.tsx b/scm-plugins/scm-svn-plugin/src/main/js/SvnConfigurationForm.tsx deleted file mode 100644 index 5d9d9edcda..0000000000 --- a/scm-plugins/scm-svn-plugin/src/main/js/SvnConfigurationForm.tsx +++ /dev/null @@ -1,78 +0,0 @@ -/* - * 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, useEffect, useState } from "react"; -import { useTranslation } from "react-i18next"; -import { Links } from "@scm-manager/ui-types"; -import { Checkbox, Select } from "@scm-manager/ui-components"; - -type Configuration = { - disabled: boolean; - allowDisable: boolean; - compatibility: string; - enabledGZip: boolean; - _links: Links; -}; - -type Props = { - initialConfiguration: Configuration; - readOnly: boolean; - onConfigurationChange: (p1: Configuration, p2: boolean) => void; -}; - -const SvnConfigurationForm: FC = ({ initialConfiguration, readOnly, onConfigurationChange }) => { - const [t] = useTranslation("plugins"); - const [configuration, setConfiguration] = useState(initialConfiguration); - - useEffect(() => setConfiguration(initialConfiguration), [initialConfiguration]); - useEffect(() => onConfigurationChange(configuration, true), [configuration]); - - const options = ["NONE", "PRE14", "PRE15", "PRE16", "PRE17", "WITH17"].map((option: string) => ({ - value: option, - label: t("scm-svn-plugin.config.compatibility-values." + option.toLowerCase()) - })); - - return ( - <> - - - - ) : ( - - )} + ) => { + const descriptionId = descriptionText ? `checkbox-description-${name}` : undefined; + return ( + <> + {descriptionText ?

{descriptionText}

: null} + + {readOnly ? ( + <> + + + + ) : ( + + )} - {label} - {helpText ? : null} - - ) + {label} + {helpText ? : null} + + + ); + } ); export default Checkbox; diff --git a/scm-ui/ui-core/src/base/forms/checkbox/ControlledCheckboxField.tsx b/scm-ui/ui-core/src/base/forms/checkbox/ControlledCheckboxField.tsx index b295ce7b49..28f585e003 100644 --- a/scm-ui/ui-core/src/base/forms/checkbox/ControlledCheckboxField.tsx +++ b/scm-ui/ui-core/src/base/forms/checkbox/ControlledCheckboxField.tsx @@ -35,6 +35,7 @@ function ControlledInputField>({ name, label, helpText, + descriptionText, rules, testId, defaultChecked, @@ -48,6 +49,8 @@ function ControlledInputField>({ const prefixedNameWithoutIndices = prefixWithoutIndices(nameWithPrefix); const labelTranslation = label || t(`${prefixedNameWithoutIndices}.label`) || ""; const helpTextTranslation = helpText || t(`${prefixedNameWithoutIndices}.helpText`); + const descriptionTextTranslation = descriptionText || t(`${prefixedNameWithoutIndices}.descriptionText`); + return ( >({ {...field} label={labelTranslation} helpText={helpTextTranslation} + descriptionText={descriptionTextTranslation} testId={testId ?? `checkbox-${nameWithPrefix}`} /> )} diff --git a/scm-ui/ui-core/src/base/forms/chip-input/ChipInputField.tsx b/scm-ui/ui-core/src/base/forms/chip-input/ChipInputField.tsx index 7c5485bb5b..bb14ab0b49 100644 --- a/scm-ui/ui-core/src/base/forms/chip-input/ChipInputField.tsx +++ b/scm-ui/ui-core/src/base/forms/chip-input/ChipInputField.tsx @@ -28,7 +28,6 @@ import { VisuallyHidden } from "@radix-ui/react-visually-hidden"; import { useTranslation } from "react-i18next"; import { withForwardRef } from "../helpers"; import { Option } from "@scm-manager/ui-types"; -import { waitForRestartAfter } from "@scm-manager/ui-api"; const StyledChipInput: typeof ChipInput = styled(ChipInput)` min-height: 40px; diff --git a/scm-ui/ui-core/src/base/forms/index.ts b/scm-ui/ui-core/src/base/forms/index.ts index b15ad7905a..7c5ae5a252 100644 --- a/scm-ui/ui-core/src/base/forms/index.ts +++ b/scm-ui/ui-core/src/base/forms/index.ts @@ -36,12 +36,14 @@ import RadioButton from "./radio-button/RadioButton"; import RadioGroupFieldComponent from "./radio-button/RadioGroupField"; export { default as Field } from "./base/Field"; +export { default as FieldMessage } from "./base/field-message/FieldMessage"; export { default as Checkbox } from "./checkbox/Checkbox"; export { default as Combobox } from "./combobox/Combobox"; export { default as ConfigurationForm } from "./ConfigurationForm"; export { default as SelectField } from "./select/SelectField"; export { default as ComboboxField } from "./combobox/ComboboxField"; export { default as Input } from "./input/Input"; +export { default as InputField } from "./input/InputField"; export { default as Textarea } from "./input/Textarea"; export { default as Select } from "./select/Select"; export * from "./resourceHooks"; diff --git a/scm-ui/ui-core/src/base/forms/input/ControlledInputField.tsx b/scm-ui/ui-core/src/base/forms/input/ControlledInputField.tsx index 39205549fd..6c7157a3e4 100644 --- a/scm-ui/ui-core/src/base/forms/input/ControlledInputField.tsx +++ b/scm-ui/ui-core/src/base/forms/input/ControlledInputField.tsx @@ -29,17 +29,20 @@ type Props> = Omit< rules?: ComponentProps["rules"]; name: Path; label?: string; + icon?: string; }; function ControlledInputField>({ name, label, helpText, + descriptionText, rules, testId, defaultValue, readOnly, className, + icon, ...props }: Props) { const { control, t, readOnly: formReadonly, formId } = useScmFormContext(); @@ -48,6 +51,8 @@ function ControlledInputField>({ const prefixedNameWithoutIndices = prefixWithoutIndices(nameWithPrefix); const labelTranslation = label || t(`${prefixedNameWithoutIndices}.label`) || ""; const helpTextTranslation = helpText || t(`${prefixedNameWithoutIndices}.helpText`); + const descriptionTextTranslation = descriptionText || t(`${prefixedNameWithoutIndices}.descriptionText`); + return ( >({ {...field} form={formId} label={labelTranslation} + icon={icon} helpText={helpTextTranslation} + descriptionText={descriptionTextTranslation} error={ fieldState.error ? fieldState.error.message || t(`${prefixedNameWithoutIndices}.error.${fieldState.error.type}`) diff --git a/scm-ui/ui-core/src/base/forms/input/InputField.stories.mdx b/scm-ui/ui-core/src/base/forms/input/InputField.stories.mdx index 761470af7e..16c8cad121 100644 --- a/scm-ui/ui-core/src/base/forms/input/InputField.stories.mdx +++ b/scm-ui/ui-core/src/base/forms/input/InputField.stories.mdx @@ -20,3 +20,7 @@ This will be our first form field molecule + + + + diff --git a/scm-ui/ui-core/src/base/forms/input/InputField.tsx b/scm-ui/ui-core/src/base/forms/input/InputField.tsx index bdba7f0c6b..df7c708f5a 100644 --- a/scm-ui/ui-core/src/base/forms/input/InputField.tsx +++ b/scm-ui/ui-core/src/base/forms/input/InputField.tsx @@ -23,27 +23,41 @@ import Input from "./Input"; import Help from "../base/help/Help"; import { useAriaId } from "../../helpers"; -type InputFieldProps = { +export type InputFieldProps = { label: string; + labelClassName?: string; helpText?: string; + descriptionText?: string; error?: string; + icon?: string; } & React.ComponentProps; /** * @see https://bulma.io/documentation/form/input/ */ const InputField = React.forwardRef( - ({ label, helpText, error, className, id, ...props }, ref) => { + ({ name, label, helpText, descriptionText, error, icon, className, labelClassName, id, ...props }, ref) => { const inputId = useAriaId(id ?? props.testId); + const descriptionId = descriptionText ? `input-description-${name}` : undefined; const variant = error ? "danger" : undefined; return ( - diff --git a/scm-ui/ui-core/src/base/notifications/Notification.tsx b/scm-ui/ui-core/src/base/notifications/Notification.tsx index 195919c722..91b4c32a4e 100644 --- a/scm-ui/ui-core/src/base/notifications/Notification.tsx +++ b/scm-ui/ui-core/src/base/notifications/Notification.tsx @@ -30,7 +30,7 @@ const Notification = React.forwardRef +
{onClose ?
diff --git a/scm-ui/ui-extensions/package.json b/scm-ui/ui-extensions/package.json index d564b19142..516d057d0a 100644 --- a/scm-ui/ui-extensions/package.json +++ b/scm-ui/ui-extensions/package.json @@ -1,6 +1,6 @@ { "name": "@scm-manager/ui-extensions", - "version": "3.6.1-SNAPSHOT", + "version": "3.7.5-SNAPSHOT", "license": "AGPL-3.0-only", "private": false, "author": "Sebastian Sdorra ", @@ -21,8 +21,8 @@ "react": "^17.0.1" }, "devDependencies": { - "@scm-manager/ui-types": "3.6.1-SNAPSHOT", - "@scm-manager/ui-tests": "3.6.1-SNAPSHOT", + "@scm-manager/ui-types": "3.7.5-SNAPSHOT", + "@scm-manager/ui-tests": "3.7.5-SNAPSHOT", "@scm-manager/babel-preset": "^2.13.1", "@scm-manager/eslint-config": "^2.17.0", "@scm-manager/jest-preset": "^2.14.1", diff --git a/scm-ui/ui-extensions/src/extensionPoints.tsx b/scm-ui/ui-extensions/src/extensionPoints.tsx index dd5b329742..f35f22e585 100644 --- a/scm-ui/ui-extensions/src/extensionPoints.tsx +++ b/scm-ui/ui-extensions/src/extensionPoints.tsx @@ -684,6 +684,11 @@ export type RepositoryInformationTableBottom = RenderableExtensionPointDefinitio { repository: Repository } >; +export type RepositoryBanner = RenderableExtensionPointDefinition< + "repository.banner", + { repository: Repository; url: string } +>; + export type UserInformationTableBottom = RenderableExtensionPointDefinition< "user.information.table.bottom", { user: User } diff --git a/scm-ui/ui-forms/package.json b/scm-ui/ui-forms/package.json index fb4247dee2..f4efd968c7 100644 --- a/scm-ui/ui-forms/package.json +++ b/scm-ui/ui-forms/package.json @@ -1,7 +1,7 @@ { "name": "@scm-manager/ui-forms", "private": false, - "version": "3.6.1-SNAPSHOT", + "version": "3.7.5-SNAPSHOT", "main": "index.ts", "scripts": { "depcheck": "depcheck" @@ -21,7 +21,7 @@ "styled-components": "^5.3.5" }, "dependencies": { - "@scm-manager/ui-core": "3.6.1-SNAPSHOT" + "@scm-manager/ui-core": "3.7.5-SNAPSHOT" }, "devDependencies": { "@scm-manager/eslint-config": "^2.17.0", diff --git a/scm-ui/ui-layout/package.json b/scm-ui/ui-layout/package.json index e8ff766d56..d6bd372907 100644 --- a/scm-ui/ui-layout/package.json +++ b/scm-ui/ui-layout/package.json @@ -1,7 +1,7 @@ { "name": "@scm-manager/ui-layout", "private": false, - "version": "3.6.1-SNAPSHOT", + "version": "3.7.5-SNAPSHOT", "main": "index.ts", "scripts": { "depcheck": "depcheck" @@ -14,7 +14,7 @@ "react": "^17.0.1" }, "dependencies": { - "@scm-manager/ui-core": "3.6.1-SNAPSHOT" + "@scm-manager/ui-core": "3.7.5-SNAPSHOT" }, "devDependencies": { "@scm-manager/eslint-config": "^2.17.0", diff --git a/scm-ui/ui-legacy/package.json b/scm-ui/ui-legacy/package.json index 37d586aba2..4aeaf81598 100644 --- a/scm-ui/ui-legacy/package.json +++ b/scm-ui/ui-legacy/package.json @@ -1,6 +1,6 @@ { "name": "@scm-manager/ui-legacy", - "version": "3.6.1-SNAPSHOT", + "version": "3.7.5-SNAPSHOT", "private": true, "main": "build/index.js", "module": "build/index.mjs", @@ -13,13 +13,13 @@ "depcheck": "depcheck" }, "dependencies": { - "@scm-manager/ui-api": "3.6.1-SNAPSHOT", - "@scm-manager/ui-extensions": "3.6.1-SNAPSHOT", + "@scm-manager/ui-api": "3.7.5-SNAPSHOT", + "@scm-manager/ui-extensions": "3.7.5-SNAPSHOT", "react-redux": "^5.0.7", "redux": "^4.0.0" }, "devDependencies": { - "@scm-manager/ui-types": "3.6.1-SNAPSHOT", + "@scm-manager/ui-types": "3.7.5-SNAPSHOT", "@types/react-redux": "5.0.7", "@scm-manager/babel-preset": "^2.13.1", "@scm-manager/eslint-config": "^2.17.0", diff --git a/scm-ui/ui-overlays/package.json b/scm-ui/ui-overlays/package.json index 44c99fb14b..2124e9488f 100644 --- a/scm-ui/ui-overlays/package.json +++ b/scm-ui/ui-overlays/package.json @@ -1,7 +1,7 @@ { "name": "@scm-manager/ui-overlays", "private": false, - "version": "3.6.1-SNAPSHOT", + "version": "3.7.5-SNAPSHOT", "main": "index.ts", "scripts": { "depcheck": "depcheck" @@ -19,7 +19,7 @@ "classnames": "^2.3.1" }, "dependencies": { - "@scm-manager/ui-core": "3.6.1-SNAPSHOT" + "@scm-manager/ui-core": "3.7.5-SNAPSHOT" }, "devDependencies": { "@scm-manager/eslint-config": "^2.17.0", diff --git a/scm-ui/ui-plugins/package.json b/scm-ui/ui-plugins/package.json index 8ffb46fb5a..34d97ef908 100644 --- a/scm-ui/ui-plugins/package.json +++ b/scm-ui/ui-plugins/package.json @@ -1,7 +1,7 @@ { "name": "@scm-manager/ui-plugins", "description": "Defines the versions of SCM-Manager plugin dependencies provided by the core webapp. Exclusively used by the postinstall command of @scm-manager/plugin-scripts.", - "version": "3.6.1-SNAPSHOT", + "version": "3.7.5-SNAPSHOT", "license": "AGPL-3.0-only", "type": "module", "main": "./build/provided-modules.js", @@ -17,14 +17,14 @@ "react-router-dom": "^5.3.1", "react-i18next": "11", "styled-components": "^5.3.5", - "@scm-manager/ui-api": "3.6.1-SNAPSHOT", - "@scm-manager/ui-buttons": "3.6.1-SNAPSHOT", - "@scm-manager/ui-components": "3.6.1-SNAPSHOT", - "@scm-manager/ui-core": "3.6.1-SNAPSHOT", - "@scm-manager/ui-extensions": "3.6.1-SNAPSHOT", - "@scm-manager/ui-forms": "3.6.1-SNAPSHOT", - "@scm-manager/ui-layout": "3.6.1-SNAPSHOT", - "@scm-manager/ui-overlays": "3.6.1-SNAPSHOT", + "@scm-manager/ui-api": "3.7.5-SNAPSHOT", + "@scm-manager/ui-buttons": "3.7.5-SNAPSHOT", + "@scm-manager/ui-components": "3.7.5-SNAPSHOT", + "@scm-manager/ui-core": "3.7.5-SNAPSHOT", + "@scm-manager/ui-extensions": "3.7.5-SNAPSHOT", + "@scm-manager/ui-forms": "3.7.5-SNAPSHOT", + "@scm-manager/ui-layout": "3.7.5-SNAPSHOT", + "@scm-manager/ui-overlays": "3.7.5-SNAPSHOT", "classnames": "^2.3.1", "query-string": "6.14.1", "redux": "^4.0.0", @@ -43,8 +43,8 @@ "@scm-manager/plugin-scripts": "^1.6.1", "@scm-manager/prettier-config": "^2.12.0", "@scm-manager/tsconfig": "^2.13.0", - "@scm-manager/ui-tests": "3.6.1-SNAPSHOT", - "@scm-manager/ui-types": "3.6.1-SNAPSHOT", + "@scm-manager/ui-tests": "3.7.5-SNAPSHOT", + "@scm-manager/ui-types": "3.7.5-SNAPSHOT", "@types/classnames": "^2.3.1", "@types/enzyme": "^3.10.18", "@types/i18next": "^13.0.0", diff --git a/scm-ui/ui-shortcuts/package.json b/scm-ui/ui-shortcuts/package.json index f40ca9b13f..b212f1aac3 100644 --- a/scm-ui/ui-shortcuts/package.json +++ b/scm-ui/ui-shortcuts/package.json @@ -1,6 +1,6 @@ { "name": "@scm-manager/ui-shortcuts", - "version": "3.6.1-SNAPSHOT", + "version": "3.7.5-SNAPSHOT", "license": "AGPL-3.0-only", "private": true, "main": "index.ts", @@ -18,7 +18,7 @@ "@scm-manager/tsconfig": "^2.13.0" }, "dependencies": { - "@scm-manager/ui-core": "3.6.1-SNAPSHOT" + "@scm-manager/ui-core": "3.7.5-SNAPSHOT" }, "prettier": "@scm-manager/prettier-config", "eslintConfig": { diff --git a/scm-ui/ui-styles/package.json b/scm-ui/ui-styles/package.json index cd66cf5100..2a640ff813 100644 --- a/scm-ui/ui-styles/package.json +++ b/scm-ui/ui-styles/package.json @@ -1,6 +1,6 @@ { "name": "@scm-manager/ui-styles", - "version": "3.6.1-SNAPSHOT", + "version": "3.7.5-SNAPSHOT", "description": "Styles for SCM-Manager", "main": "src/scm.scss", "license": "AGPL-3.0-only", @@ -16,7 +16,7 @@ "html-webpack-plugin": "^5.5.0", "sass-loader": "^12.3.0", "style-loader": "^3.3.1", - "webpack": "^5.72.0" + "webpack": "^5.76.0" }, "dependencies": { "@fortawesome/fontawesome-free": "^5.11.2", @@ -30,4 +30,4 @@ "eslintConfig": { "extends": "@scm-manager/eslint-config" } -} \ No newline at end of file +} diff --git a/scm-ui/ui-styles/src/components/_main.scss b/scm-ui/ui-styles/src/components/_main.scss index 655ec8fe97..8248d37a56 100644 --- a/scm-ui/ui-styles/src/components/_main.scss +++ b/scm-ui/ui-styles/src/components/_main.scss @@ -128,6 +128,7 @@ hr.header-with-actions { display: none; } } + .is-mobile-action-spacing { @media screen and (max-width: 768px) { display: flow-root !important; @@ -364,7 +365,9 @@ a { } } -button, .button { +// Buttons +button, +.button { &:focus-visible, &.is-focused { outline: $focus-outline-color $focus-outline-style; @@ -375,6 +378,22 @@ button, .button { } } +/* Focus zones for general element focus highlighting. + We explicitly want elements tagged with 'focus-zone' to have the below behavior overriding + Bulma constraints upon focussing an element, which is why !important is used here. + Please use this CSS class with consideration. + */ +.focus-zone { + &:focus-visible, + &.is-focused { + outline: $focus-outline-color $focus-outline-style !important; + outline-offset: $focus-outline-offset !important; + &:not(:active) { + box-shadow: none !important; + } + } +} + // detail/summary summary { &:focus-visible, @@ -385,6 +404,20 @@ summary { } // buttons +@mixin button-variant($name, $variant, $variant-invert: $scheme-main) { + &.is-#{$name} { + &:hover, + &.is-hovered { + background-color: scale-color($variant, $lightness: -10%); + } + &:active, + &.is-active { + color: $variant-invert; + background-color: scale-color($variant, $lightness: -20%); + } + } +} + .button { padding-left: 1.5em; padding-right: 1.5em; @@ -392,96 +425,37 @@ summary { min-width: 2.5rem; font-weight: $weight-semibold; - &.is-primary, - &.is-info, - &.is-link, - &.is-success, - &.is-warning, - &.is-danger { - &.is-outlined { - background-color: inherit; + @include button-variant("primary", $primary, $primary-invert); + @include button-variant("info", $info, $info-invert); + @include button-variant("link", $link, $link-invert); + @include button-variant("success", $success, $success-invert); + @include button-variant("warning", $warning, $warning-invert); + @include button-variant("danger", $danger, $danger-invert); + + &.is-inverted.is-primary { + &:hover, + &.is-hovered { + background-color: transparent; + border-color: $primary; } - } - - &.is-primary:hover, - &.is-primary.is-hovered { - background-color: scale-color($primary, $lightness: -10%); - } - - &.is-primary:active, - &.is-primary.is-active { - background-color: scale-color($primary, $lightness: -20%); - } - - &.is-info:hover, - &.is-info.is-hovered { - background-color: scale-color($info, $lightness: -10%); - } - - &.is-info:active, - &.is-info.is-active { - background-color: scale-color($info, $lightness: -20%); - } - - &.is-link:hover, - &.is-link.is-hovered { - background-color: scale-color($link, $lightness: -10%); - } - - &.is-link:active, - &.is-link.is-active { - background-color: scale-color($link, $lightness: -20%); - } - - &.is-success:hover, - &.is-success.is-hovered { - background-color: scale-color($success, $lightness: -10%); - } - - &.is-success:active, - &.is-success.is-active { - background-color: scale-color($success, $lightness: -20%); - } - - &.is-warning:hover, - &.is-warning.is-hovered { - background-color: scale-color($warning, $lightness: -10%); - } - - &.is-warning:active, - &.is-warning.is-active { - background-color: scale-color($warning, $lightness: -20%); - } - - &.is-danger:hover, - &.is-danger.is-hovered { - background-color: scale-color($danger, $lightness: -10%); - } - - &.is-danger:active, - &.is-danger.is-active { - background-color: scale-color($danger, $lightness: -20%); - } - - &.is-reduced-mobile, - &.reduced-mobile { - @media screen and (max-width: 1087px) { - > span:nth-child(2) { - display: none; - } + &:focus, + &.is-focused { + border-color: $primary; } - @media screen and (max-width: 1087px) and (min-width: 769px) { - // simultaneously with left margin of Bulma - > .icon:first-child:not(:last-child) { - margin: 0; - } + &:active, + &.is-active { + color: $primary-invert; + background-color: $primary; } - @media screen and (max-width: 768px) { - // simultaneously with left margin of Bulma - .icon:first-child:not(:last-child) { - margin-right: calc(-0.375em - 1px); - } + &[disabled] { + color: $primary; + background-color: transparent; + border-color: transparent; } + &.is-loading::after { + border-color: transparent transparent $primary $primary !important; + } + background-color: transparent; } } @@ -806,7 +780,7 @@ form .field:not(.is-grouped) { } .has-scm-background { - background-image: url(images/scmManagerHero.jpg) !important; + background-image: url("images/scmManagerHero.jpg") !important; background-size: cover; background-position: top center; background-color: #002e4b; @@ -815,7 +789,7 @@ form .field:not(.is-grouped) { // hero .hero.is-dark { background-color: #002e4b; - background-image: url(images/scmManagerHero.jpg); + background-image: url("images/scmManagerHero.jpg"); background-size: cover; background-position: top center; @@ -1086,7 +1060,7 @@ form .field:not(.is-grouped) { .space_char::before { font-size: 0.75rem; - content: '·'; + content: "·"; color: var(--diff-whitespace-color); background: transparent; position: absolute; @@ -1094,9 +1068,16 @@ form .field:not(.is-grouped) { .tabulator_char::before { font-size: 0.75rem; - content: '↦'; + content: "↦"; color: var(--diff-whitespace-color); background: transparent; position: absolute; text-align: center; } + +// Hunk expander basic mobile styles +.hunk-expander { + @media screen and (max-width: 768px) { + padding-bottom: 0.25rem; + } +} diff --git a/scm-ui/ui-styles/src/highcontrast.scss b/scm-ui/ui-styles/src/highcontrast.scss index d5f2cbe1c8..30d103e204 100644 --- a/scm-ui/ui-styles/src/highcontrast.scss +++ b/scm-ui/ui-styles/src/highcontrast.scss @@ -59,7 +59,7 @@ $box-background-color: $grey-darker; $table-row-hover-background-color: $grey-darker; /* Focus for Buttons */ -$focus-outline-color: #C777EE; +$focus-outline-color: #c777ee; /* Components */ $menu-label-color: $scheme-main; @@ -125,94 +125,32 @@ $tooltip-color: $scheme-main; --diff-whitespace-color: #fffce0; } -.button { - &.is-primary:hover, - &.is-primary.is-hovered { - background-color: scale-color($primary, $lightness: -5%); - } - - &.is-primary:active, - &.is-primary.is-active { - background-color: scale-color($primary, $lightness: -10%); - } - - &.is-primary[disabled] { - background-color: scale-color($primary, $lightness: -50%); - } - - &.is-info:hover, - &.is-info.is-hovered { - background-color: scale-color($info, $lightness: -5%); - } - - &.is-info:active, - &.is-info.is-active { - background-color: scale-color($info, $lightness: -10%); - } - - &.is-info[disabled] { - background-color: scale-color($info, $lightness: -50%); - } - - &.is-link:hover, - &.is-link.is-hovered { - background-color: scale-color($link, $lightness: -5%); - } - - &.is-link:active, - &.is-link.is-active { - background-color: scale-color($link, $lightness: -10%); - } - - &.is-link[disabled] { - background-color: scale-color($link, $lightness: -50%); - } - - &.is-success:hover, - &.is-success.is-hovered { - background-color: scale-color($success, $lightness: -5%); - } - - &.is-success:active, - &.is-success.is-active { - background-color: scale-color($success, $lightness: -10%); - } - - &.is-success[disabled] { - background-color: scale-color($success, $lightness: -50%); - } - - &.is-warning:hover, - &.is-warning.is-hovered { - background-color: scale-color($warning, $lightness: -5%); - } - - &.is-warning:active, - &.is-warning.is-active { - background-color: scale-color($warning, $lightness: -10%); - } - - &.is-warning[disabled] { - background-color: scale-color($warning, $lightness: -50%); - color: #e1d4c2; - } - - &.is-danger:hover, - &.is-danger.is-hovered { - background-color: scale-color($danger, $lightness: -5%); - } - - &.is-danger:active, - &.is-danger.is-active { - background-color: scale-color($danger, $lightness: -10%); - } - - &.is-danger[disabled] { - background-color: scale-color($danger, $lightness: -50%); +@mixin button-variant($name, $variant, $variant-invert: $scheme-main) { + &.is-#{$name} { + &:hover, + &.is-hovered { + background-color: scale-color($variant, $lightness: -5%); + } + &:active, + &.is-active { + color: $variant-invert; + background-color: scale-color($variant, $lightness: -10%); + } + &:disabled { + background-color: scale-color($variant, $lightness: -50%); + } } } -//footer is overwritten in _main.scss +.button { + @include button-variant("primary", $primary, $primary-invert); + @include button-variant("info", $info, $info-invert); + @include button-variant("link", $link, $link-invert); + @include button-variant("success", $success, $success-invert); + @include button-variant("warning", $warning, $warning-invert); + @include button-variant("danger", $danger, $danger-invert); +} + footer.footer { a { color: scale-color($link, $lightness: 25%); @@ -235,7 +173,6 @@ td button:not(.is-default) { //card .modal-card { border: 1px solid $border; - & .modal-close, & .delete { background-color: $white-ter; @@ -248,11 +185,9 @@ td button:not(.is-default) { a { color: $white-bis; } - &:hover { td { background-color: $grey-darker; - &.is-darker { background-color: $grey-darker; } @@ -294,6 +229,14 @@ td button:not(.is-default) { color: $scheme-main; } +.hunk-expander { + &:hover, + :focus-visible { + color: #98d8f3; + background-color: #050514; + } +} + /* transport meaning with more than color */ td:nth-child(2).diff-gutter-delete:before { content: "-"; diff --git a/scm-ui/ui-styles/src/light.scss b/scm-ui/ui-styles/src/light.scss index 3b6ece8a37..416241fd66 100644 --- a/scm-ui/ui-styles/src/light.scss +++ b/scm-ui/ui-styles/src/light.scss @@ -75,19 +75,6 @@ $popover-border-color: $grey-lightest; --diff-whitespace-color: #363636; } -.button { - &.is-primary, - &.is-info, - &.is-link, - &.is-success, - &.is-warning, - &.is-danger { - &.is-outlined { - background-color: $white; - } - } -} - footer.footer { a { color: darken($info, 15%); diff --git a/scm-ui/ui-syntaxhighlighting/package.json b/scm-ui/ui-syntaxhighlighting/package.json index 23dfde3eda..d45b810eb3 100644 --- a/scm-ui/ui-syntaxhighlighting/package.json +++ b/scm-ui/ui-syntaxhighlighting/package.json @@ -1,6 +1,6 @@ { "name": "@scm-manager/ui-syntaxhighlighting", - "version": "3.6.1-SNAPSHOT", + "version": "3.7.5-SNAPSHOT", "private": true, "main": "src/index.ts", "scripts": { @@ -13,8 +13,8 @@ "depcheck": "depcheck" }, "dependencies": { - "@scm-manager/ui-text": "3.6.1-SNAPSHOT", - "nanoid": "^3.3.2", + "@scm-manager/ui-text": "3.7.5-SNAPSHOT", + "nanoid": "^3.3.8", "refractor": "^4.5.0" }, "peerDependencies": { 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 734c67e932..77ec438e3a 100644 --- a/scm-ui/ui-tests/package.json +++ b/scm-ui/ui-tests/package.json @@ -1,6 +1,6 @@ { "name": "@scm-manager/ui-tests", - "version": "3.6.1-SNAPSHOT", + "version": "3.7.5-SNAPSHOT", "description": "UI-Tests helpers", "author": "Sebastian Sdorra ", "license": "AGPL-3.0-only", @@ -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-text/package.json b/scm-ui/ui-text/package.json index 44a17b5849..600aabdb9c 100644 --- a/scm-ui/ui-text/package.json +++ b/scm-ui/ui-text/package.json @@ -1,6 +1,6 @@ { "name": "@scm-manager/ui-text", - "version": "3.6.1-SNAPSHOT", + "version": "3.7.5-SNAPSHOT", "private": true, "main": "index.ts", "scripts": { @@ -10,7 +10,7 @@ "react": "^17.0.1" }, "dependencies": { - "@scm-manager/ui-core": "3.6.1-SNAPSHOT" + "@scm-manager/ui-core": "3.7.5-SNAPSHOT" }, "devDependencies": { "@scm-manager/eslint-config": "^2.17.0", diff --git a/scm-ui/ui-types/package.json b/scm-ui/ui-types/package.json index 21e4bdedf5..32b6292dbb 100644 --- a/scm-ui/ui-types/package.json +++ b/scm-ui/ui-types/package.json @@ -1,6 +1,6 @@ { "name": "@scm-manager/ui-types", - "version": "3.6.1-SNAPSHOT", + "version": "3.7.5-SNAPSHOT", "description": "Typescript types for SCM-Manager related Objects", "main": "src/index.ts", "files": [ diff --git a/scm-ui/ui-types/src/Config.ts b/scm-ui/ui-types/src/Config.ts index d909022b87..7d8ded89a1 100644 --- a/scm-ui/ui-types/src/Config.ts +++ b/scm-ui/ui-types/src/Config.ts @@ -47,4 +47,6 @@ export type Config = HalRepresentation & { emergencyContacts: string[]; enabledApiKeys: boolean; enabledFileSearch: boolean; + jwtExpirationInH?: number; + enabledJwtEndless?: boolean; }; diff --git a/scm-ui/ui-webapp/package.json b/scm-ui/ui-webapp/package.json index 33104308fc..e0fe5bdb2b 100644 --- a/scm-ui/ui-webapp/package.json +++ b/scm-ui/ui-webapp/package.json @@ -1,6 +1,6 @@ { "name": "@scm-manager/ui-webapp", - "version": "3.6.1-SNAPSHOT", + "version": "3.7.5-SNAPSHOT", "private": true, "scripts": { "test": "jest", @@ -11,16 +11,16 @@ }, "dependencies": { "@headlessui/react": "^1.7.17", - "@scm-manager/ui-components": "3.6.1-SNAPSHOT", - "@scm-manager/ui-api": "3.6.1-SNAPSHOT", - "@scm-manager/ui-extensions": "3.6.1-SNAPSHOT", - "@scm-manager/ui-shortcuts": "3.6.1-SNAPSHOT", - "@scm-manager/ui-legacy": "3.6.1-SNAPSHOT", - "@scm-manager/ui-forms": "3.6.1-SNAPSHOT", - "@scm-manager/ui-core": "3.6.1-SNAPSHOT", - "@scm-manager/ui-overlays": "3.6.1-SNAPSHOT", - "@scm-manager/ui-layout": "3.6.1-SNAPSHOT", - "@scm-manager/ui-buttons": "3.6.1-SNAPSHOT", + "@scm-manager/ui-components": "3.7.5-SNAPSHOT", + "@scm-manager/ui-api": "3.7.5-SNAPSHOT", + "@scm-manager/ui-extensions": "3.7.5-SNAPSHOT", + "@scm-manager/ui-shortcuts": "3.7.5-SNAPSHOT", + "@scm-manager/ui-legacy": "3.7.5-SNAPSHOT", + "@scm-manager/ui-forms": "3.7.5-SNAPSHOT", + "@scm-manager/ui-core": "3.7.5-SNAPSHOT", + "@scm-manager/ui-overlays": "3.7.5-SNAPSHOT", + "@scm-manager/ui-layout": "3.7.5-SNAPSHOT", + "@scm-manager/ui-buttons": "3.7.5-SNAPSHOT", "@radix-ui/react-portal": "^1.0.4", "@react-aria/overlays": "^3.23.1", "classnames": "^2.3.1", @@ -38,7 +38,7 @@ "react-query": "^3.39.2", "string_score": "^0.1.22", "styled-components": "^5.3.5", - "ua-parser-js": "^1.0.2", + "ua-parser-js": "^1.0.33", "decode-named-character-reference": "^1.0.1", "enzyme": "^3.11.0", "date-fns": "^2.4.1" @@ -46,10 +46,10 @@ "devDependencies": { "@scm-manager/eslint-config": "^2.17.0", "@scm-manager/jest-preset": "^2.14.1", - "@scm-manager/ui-tests": "3.6.1-SNAPSHOT", - "@scm-manager/ui-plugins": "3.6.1-SNAPSHOT", + "@scm-manager/ui-tests": "3.7.5-SNAPSHOT", + "@scm-manager/ui-plugins": "3.7.5-SNAPSHOT", "@scm-manager/prettier-config": "^2.12.0", - "@scm-manager/ui-types": "3.6.1-SNAPSHOT", + "@scm-manager/ui-types": "3.7.5-SNAPSHOT", "@types/classnames": "^2.3.1", "@types/enzyme": "^3.10.18", "@types/react": "^17.0.1", diff --git a/scm-ui/ui-webapp/public/locales/de/admin.json b/scm-ui/ui-webapp/public/locales/de/admin.json index 46abb35a67..6bee58dcf6 100644 --- a/scm-ui/ui-webapp/public/locales/de/admin.json +++ b/scm-ui/ui-webapp/public/locales/de/admin.json @@ -38,7 +38,7 @@ "showPending": "Änderungen anzeigen", "executePending": "Warten auf Neustart", "outdatedPlugins": "{{count}} Plugin aktualisieren", - "outdatedPlugins_plural": "{{count}} Plugins aktualisieren", + "outdatedPlugins_other": "{{count}} Plugins aktualisieren", "updateAll": "Alle Plugins aktualisieren", "cancelPending": "Änderungen verwerfen", "noPlugins": "Keine Plugins gefunden.", diff --git a/scm-ui/ui-webapp/public/locales/de/commons.json b/scm-ui/ui-webapp/public/locales/de/commons.json index 682b335d6e..2b58957a83 100644 --- a/scm-ui/ui-webapp/public/locales/de/commons.json +++ b/scm-ui/ui-webapp/public/locales/de/commons.json @@ -82,6 +82,7 @@ "breadcrumb": { "home": "Hauptseite", "copyPermalink": "Link in Zwischenablage kopieren", + "currentPath": "Aktueller Pfad", "loading": "Lade ..." }, "errorNotification": { @@ -232,7 +233,7 @@ "notifications": { "toastTitle": "Benachrichtigung", "xMore": "+{{ count }} Benachrichtigung", - "xMore_plural": "+{{ count }} Benachrichtigungen", + "xMore_other": "+{{ count }} Benachrichtigungen", "loading": "Lade ...", "bellTitle": "Benachrichtigungen", "empty": "Keine Benachrichtigungen", @@ -249,17 +250,17 @@ }, "duration": { "ms": "{{count}} Millisekunde", - "ms_plural": "{{count}} Millisekunden", + "ms_other": "{{count}} Millisekunden", "s": "{{count}} Sekunde", - "s_plural": "{{count}} Sekunden", + "s_other": "{{count}} Sekunden", "m": "{{count}} Minute", - "m_plural": "{{count}} Minuten", + "m_other": "{{count}} Minuten", "h": "{{count}} Stunde", - "h_plural": "{{count}} Stunden", + "h_other": "{{count}} Stunden", "d": "{{count}} Tag", - "d_plural": "{{count}} Tage", + "d_other": "{{count}} Tage", "w": "{{count}} Woche", - "w_plural": "{{count}} Wochen" + "w_other": "{{count}} Wochen" }, "search": { "ariaLabel": "Globale Suche", @@ -281,7 +282,7 @@ "hints": "Hinweise zu ihrer Suche", "screenReaderHintNoResult": "Keine Repositories gefunden. Mögliche weitere Ergebnistypen mit Enter anzeigen.", "screenReaderHint": "Ein Repository gefunden. Mögliche weitere Ergebnistypen mit Enter anzeigen oder Pfeiltasten verwenden um zu den gefunden Repositories zu navigieren und mit Enter bestätigen.", - "screenReaderHint_plural": "Mindestens {{ count }} Repositories gefunden. Mögliche weitere Ergebnistypen mit Enter anzeigen oder Pfeiltasten verwenden um zu den gefunden Repositories zu navigieren und mit Enter bestätigen.", + "screenReaderHint_other": "Mindestens {{ count }} Repositories gefunden. Mögliche weitere Ergebnistypen mit Enter anzeigen oder Pfeiltasten verwenden um zu den gefunden Repositories zu navigieren und mit Enter bestätigen.", "searchRepo": "In Repository suchen", "searchNamespace": "In Namespace suchen", "searchEverywhere": "Überall suchen" diff --git a/scm-ui/ui-webapp/public/locales/de/config.json b/scm-ui/ui-webapp/public/locales/de/config.json index fd0798b7c1..ba5bb5739b 100644 --- a/scm-ui/ui-webapp/public/locales/de/config.json +++ b/scm-ui/ui-webapp/public/locales/de/config.json @@ -25,6 +25,13 @@ "notConfiguredHint": "Authentifizierungs URL ist nicht gesetzt" } }, + "jwtSettings": { + "subtitle": "JWT Einstellungen", + "label": "Ablaufzeit", + "help": "Legen Sie die Ablaufzeit des JWT in Stunden fest. Wenn Sie die Zeit auf 'endlos' setzen möchten, verwenden Sie die Option 'endlessJwt' in der 'config.yml'.", + "hoursWarning": "Es wird nicht empfohlen, die Ablaufzeit auf mehr als 24 Stunden einzustellen", + "endlessWarning": "Die Ablaufzeit ist auf endlos eingestellt. Siehe 'config.yml'." + }, "proxySettings": { "subtitle": "Proxy Einstellungen", "enable": "Proxy aktivieren", diff --git a/scm-ui/ui-webapp/public/locales/de/repos.json b/scm-ui/ui-webapp/public/locales/de/repos.json index ed255214ce..be2125461a 100644 --- a/scm-ui/ui-webapp/public/locales/de/repos.json +++ b/scm-ui/ui-webapp/public/locales/de/repos.json @@ -2,7 +2,7 @@ "repository": { "namespace": "Namespace", "name": "Name", - "type": "Typ", + "type": "Typ des Repositorys", "contact": "Kontakt", "description": "Beschreibung", "creationDate": "Erstellt", @@ -24,8 +24,7 @@ "help": { "namespaceHelpText": "Der Namespace des Repository. Dieser wird Teil der URL des Repository sein.", "nameHelpText": "Der Name des Repository. Dieser wird Teil der URL des Repository sein.", - "typeHelpText": "Der Typ des Repository (Mercurial, Git oder Subversion).", - "contactHelpText": "E-Mail Adresse der Person, die für das Repository verantwortlich ist.", + "contactHelpText": "E-Mail-Adresse der Person, die für das Repository verantwortlich ist.", "descriptionHelpText": "Eine kurze Beschreibung des Repository.", "initializeRepository": "Erstellt einen ersten Branch und committet eine README.md.", "importUrlHelpText": "Importiert das gesamte Repository inkl. aller Branches und Tags über die Remote URL.", @@ -312,7 +311,7 @@ "shortSummary": "Committet <0/> <1/>", "tags": "Tags", "diffNotSupported": "Diff des Changesets wird von diesem Repositorytyp nicht unterstützt", - "keyOwner": "Schlüssel Besitzer", + "keyOwner": "Schlüsselbesitzer", "signatureStatus": "Status", "keyId": "Schlüssel-ID", "keyContacts": "Kontakte", @@ -326,7 +325,7 @@ }, "parents": { "label": "Parent", - "label_plural": "Parents" + "label_other": "Parents" }, "contributors": { "mailto": "Mail senden an", @@ -338,7 +337,7 @@ "coAuthoredBy": "Co-Autoren", "more": "{{count}} mehr", "count": "{{count}} Mitwirkender", - "count_plural": "{{count}} Mitwirkende" + "count_other": "{{count}} Mitwirkende" }, "buttons": { "details": "Details", @@ -347,6 +346,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", @@ -516,8 +527,8 @@ "modal": { "title": "Repository umbenennen", "label": { - "repoName": "Repository Name", - "repoNamespace": "Repository Namespace" + "repoName": "Repository-Name", + "repoNamespace": "Repository-Namespace" }, "button": { "rename": "Umbenennen", @@ -607,23 +618,25 @@ }, "fileSearch": { "button": { - "title": "Dateipfad Suche" + "title": "Dateipfadsuche" }, "file": "Datei", "home": "Zurück zu Sources", "input": { - "placeholder": "Dateipfad Suche", - "help": "Tippe 2 oder mehr Zeichen ein, um die Suche zu starten" + "placeholder": "Dateipfadsuche", + "help": "Tippen Sie mindestens zwei Zeichen ein, um die Suche zu starten." }, + "results_one": "{{count}} Ergebnis gefunden:", + "results_other": "{{count}} Ergebnisse gefunden:", "notifications": { - "queryToShort": "Tippe mindestens 2 Zeichen ein, um die Suche zu starten", - "emptyResult": "Es wurden keine Ergebnisse für <0>{{query}} gefunden" + "emptyResult": "Keine Ergebnisse gefunden für: <0>{{query}}." }, "searchWithRevisionAndNamespaceName": "Suche auf {{revision}} in {{namespace}}/{{name}}" }, "shortcuts": { "info": "Wechsel zur Repository-Info", "branches": "Wechsel zu den Branches", + "fileSearch": "Wechsel zur Dateisuche", "tags": "Wechsel zu den Tags", "code": "Wechsel zum Code", "settings": "Wechsel zu den Einstellungen" diff --git a/scm-ui/ui-webapp/public/locales/en/admin.json b/scm-ui/ui-webapp/public/locales/en/admin.json index f43ca878aa..5d27143974 100644 --- a/scm-ui/ui-webapp/public/locales/en/admin.json +++ b/scm-ui/ui-webapp/public/locales/en/admin.json @@ -38,7 +38,7 @@ "showPending": "Show Changes", "executePending": "Waiting for restart", "outdatedPlugins": "Update {{count}} Plugin", - "outdatedPlugins_plural": "Update {{count}} Plugins", + "outdatedPlugins_other": "Update {{count}} Plugins", "updateAll": "Update All Plugins", "cancelPending": "Discard Changes", "noPlugins": "No plugins found.", diff --git a/scm-ui/ui-webapp/public/locales/en/commons.json b/scm-ui/ui-webapp/public/locales/en/commons.json index 429d0cf87e..f06ca9b4bb 100644 --- a/scm-ui/ui-webapp/public/locales/en/commons.json +++ b/scm-ui/ui-webapp/public/locales/en/commons.json @@ -83,6 +83,7 @@ "breadcrumb": { "home": "Main page", "copyPermalink": "Copy Permalink to Clipboard", + "currentPath": "Current path", "loading": "Loading ..." }, "errorNotification": { @@ -233,7 +234,7 @@ "notifications": { "toastTitle": "Notification", "xMore": "+{{ count }} Notification", - "xMore_plural": "+{{ count }} Notifications", + "xMore_other": "+{{ count }} Notifications", "loading": "Loading ...", "bellTitle": "Notifications", "empty": "No notifications", @@ -250,17 +251,17 @@ }, "duration": { "ms": "{{count}} millisecond", - "ms_plural": "{{count}} milliseconds", + "ms_other": "{{count}} milliseconds", "s": "{{count}} second", - "s_plural": "{{count}} seconds", + "s_other": "{{count}} seconds", "m": "{{count}} minute", - "m_plural": "{{count}} minutes", + "m_other": "{{count}} minutes", "h": "{{count}} hour", - "h_plural": "{{count}} hours", + "h_other": "{{count}} hours", "d": "{{count}} day", - "d_plural": "{{count}} days", + "d_other": "{{count}} days", "w": "{{count}} week", - "w_plural": "{{count}} weeks" + "w_other": "{{count}} weeks" }, "search": { "ariaLabel": "Global search", @@ -282,7 +283,7 @@ "hints": "Hints for your Search", "screenReaderHintNoResult": "No repositories found. Other result types may be available, hit enter to navigate to complete search result.", "screenReaderHint": "Found one repository. Hit enter to see search results of all types or use arrow keys to navigate to repository quick results and hit enter to select one of them.", - "screenReaderHint_plural": "Found at least {{ count }} repositories. Hit enter to see search results of all types or use arrow keys to navigate to repository quick results and hit enter to select one of them.", + "screenReaderHint_other": "Found at least {{ count }} repositories. Hit enter to see search results of all types or use arrow keys to navigate to repository quick results and hit enter to select one of them.", "searchRepo": "Search in repository", "searchNamespace": "Search in namespace", "searchEverywhere": "Search everywhere" diff --git a/scm-ui/ui-webapp/public/locales/en/config.json b/scm-ui/ui-webapp/public/locales/en/config.json index e320746c3d..1a7af32ec6 100644 --- a/scm-ui/ui-webapp/public/locales/en/config.json +++ b/scm-ui/ui-webapp/public/locales/en/config.json @@ -25,6 +25,13 @@ "notConfiguredHint": "Authentication URL is not configured" } }, + "jwtSettings": { + "subtitle": "JWT Settings", + "label": "Expiration time", + "help": "Set the JWT expiration time in hours. If you want to set the time to endless consider the 'endlessJwt' option inside the 'config.yml'.", + "hoursWarning": "It is not recommended to set the expiration time over 24 hours.", + "endlessWarning": "The expiration time is set to endless." + }, "proxySettings": { "subtitle": "Proxy Settings", "enable": "Enable Proxy", diff --git a/scm-ui/ui-webapp/public/locales/en/repos.json b/scm-ui/ui-webapp/public/locales/en/repos.json index 44b0df9e97..13e7983fd6 100644 --- a/scm-ui/ui-webapp/public/locales/en/repos.json +++ b/scm-ui/ui-webapp/public/locales/en/repos.json @@ -2,7 +2,7 @@ "repository": { "namespace": "Namespace", "name": "Name", - "type": "Type", + "type": "Type of repository", "contact": "Contact", "description": "Description", "creationDate": "Creation Date", @@ -24,7 +24,6 @@ "help": { "namespaceHelpText": "The namespace of the repository. This name will be part of the repository url.", "nameHelpText": "The name of the repository. This name will be part of the repository url.", - "typeHelpText": "The type of the repository (e.g. Mercurial, Git or Subversion).", "contactHelpText": "Email address of the person who is responsible for this repository.", "descriptionHelpText": "A short description of the repository.", "initializeRepository": "Creates an initial branch and commits a basic README.md.", @@ -330,7 +329,7 @@ }, "parents": { "label": "Parent", - "label_plural": "Parents" + "label_other": "Parents" }, "contributors": { "mailto": "Send mail to", @@ -342,11 +341,23 @@ "coAuthoredBy": "co authored by", "more": "{{count}} more", "count": "{{count}} Contributor", - "count_plural": "{{count}} Contributors" + "count_other": "{{count}} Contributors" }, "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", @@ -613,17 +624,19 @@ "home": "Go back to source root", "input": { "placeholder": "Search filepath", - "help": "Type 2 or more letters to search for a filepath in the repository" + "help": "Type at least two letters to start the search." }, + "results_one": "{{count}} result found:", + "results_other": "{{count}} results found:", "notifications": { - "queryToShort": "Type at least two characters to start the search", - "emptyResult": "Nothing found for query <0>{{query}}" + "emptyResult": "Nothing found for: <0>{{query}}." }, "searchWithRevisionAndNamespaceName": "Search on {{revision}} in {{namespace}}/{{name}}" }, "shortcuts": { "info": "Switch to repository info", "branches": "Switch to branches", + "fileSearch": "Switch to file search", "tags": "Switch to tags", "code": "Switch to code", "settings": "Switch to settings" diff --git a/scm-ui/ui-webapp/public/locales/es/repos.json b/scm-ui/ui-webapp/public/locales/es/repos.json index 2b456e627e..c61730b956 100644 --- a/scm-ui/ui-webapp/public/locales/es/repos.json +++ b/scm-ui/ui-webapp/public/locales/es/repos.json @@ -2,7 +2,7 @@ "repository": { "namespace": "Espacio de nombres", "name": "Nombre", - "type": "Tipo", + "type": "Tipo del repositorio", "contact": "Contacto", "description": "Descripción", "creationDate": "Fecha de creación", @@ -19,7 +19,6 @@ "help": { "namespaceHelpText": "El espacio de nombres del repositorio. Este nombre formará parte de la URL del repositorio.", "nameHelpText": "El nombre del repositorio. Este nombre formará parte de la URL del repositorio.", - "typeHelpText": "El tipo del repositorio (Mercurial, Git or Subversion).", "contactHelpText": "Dirección del correo electrónico de la persona responsable del repositorio.", "descriptionHelpText": "Breve descripción del repositorio.", "initializeRepository": "Creates an initial branch and commits a basic README.md." diff --git a/scm-ui/ui-webapp/src/admin/components/form/ConfigForm.tsx b/scm-ui/ui-webapp/src/admin/components/form/ConfigForm.tsx index 0f0ac35168..045e6e0acf 100644 --- a/scm-ui/ui-webapp/src/admin/components/form/ConfigForm.tsx +++ b/scm-ui/ui-webapp/src/admin/components/form/ConfigForm.tsx @@ -26,6 +26,7 @@ import PluginSettings from "./PluginSettings"; import FunctionSettings from "./FunctionSettings"; import InvalidateCaches from "./InvalidateCaches"; import InvalidateSearchIndex from "./InvalidateSearchIndex"; +import JwtSettings from "./JwtSettings"; type Props = { submitForm: (p: Config) => void; @@ -60,6 +61,7 @@ const ConfigForm: FC = ({ dateFormat: "", anonymousAccessEnabled: false, anonymousMode: "OFF", + enabledFileSearch: true, baseUrl: "", forceBaseUrl: false, loginAttemptLimit: 0, @@ -77,6 +79,8 @@ const ConfigForm: FC = ({ mailDomainName: "", emergencyContacts: [], enabledApiKeys: true, + jwtExpirationInH: 1, + enabledJwtEndless: false, _links: {}, }); const [showNotification, setShowNotification] = useState(false); @@ -184,6 +188,13 @@ const ConfigForm: FC = ({ hasUpdatePermission={configUpdatePermission} />
+ +
= ({ jwtExpirationInH, onChange, hasUpdatePermission, enabledJwtEndless }) => { + const { t } = useTranslation("config"); + const [warning, setWarning] = useState(undefined); + const jwtOverTwentyFourWarning = t("jwtSettings.hoursWarning"); + const jwtIsSetToEndless = t("jwtSettings.endlessWarning"); + + const handleJwtTimeChange = (value: string) => { + if (Number(value) > 24) { + setWarning(jwtOverTwentyFourWarning); + } else { + setWarning(undefined); + } + onChange(true, Number(value), "jwtExpirationInH"); + }; + + return ( +
+ +
+
+ +
+
+
+ ); +}; + +export default JwtSettings; diff --git a/scm-ui/ui-webapp/src/admin/plugins/components/CloudoguPlatformBanner.tsx b/scm-ui/ui-webapp/src/admin/plugins/components/CloudoguPlatformBanner.tsx index 28de638304..f658129d76 100644 --- a/scm-ui/ui-webapp/src/admin/plugins/components/CloudoguPlatformBanner.tsx +++ b/scm-ui/ui-webapp/src/admin/plugins/components/CloudoguPlatformBanner.tsx @@ -19,7 +19,7 @@ import { Trans, useTranslation } from "react-i18next"; import { Link, PluginCenterAuthenticationInfo } from "@scm-manager/ui-types"; import classNames from "classnames"; import styled from "styled-components"; -import { Button } from "@scm-manager/ui-components"; +import { ExternalLinkButton, Icon } from "@scm-manager/ui-core"; type Props = { info: PluginCenterAuthenticationInfo; @@ -57,13 +57,13 @@ const FailedAuthentication: FC = ({ info, link }) => { components={[cloudogu platform, ]} />

- + ); }; @@ -91,22 +91,24 @@ const Unauthenticated: FC = ({ link, info }) => { const [t] = useTranslation("admin"); return ( - +

- cloudogu platform, - Data Processing - ]} - /> + + cloudogu platform, + Data Processing, + ]} + /> +

); diff --git a/scm-ui/ui-webapp/src/admin/roles/containers/SingleRepositoryRole.tsx b/scm-ui/ui-webapp/src/admin/roles/containers/SingleRepositoryRole.tsx index 47898e936a..81bf561231 100644 --- a/scm-ui/ui-webapp/src/admin/roles/containers/SingleRepositoryRole.tsx +++ b/scm-ui/ui-webapp/src/admin/roles/containers/SingleRepositoryRole.tsx @@ -18,7 +18,7 @@ import React, { FC } from "react"; import { Route, useParams, useRouteMatch } from "react-router-dom"; import { useTranslation } from "react-i18next"; import { ExtensionPoint, extensionPoints } from "@scm-manager/ui-extensions"; -import { ErrorPage, Loading, Title, urls } from "@scm-manager/ui-components"; +import { ErrorPage, Loading, urls } from "@scm-manager/ui-components"; import PermissionRoleDetail from "../components/PermissionRoleDetails"; import EditRepositoryRole from "./EditRepositoryRole"; import { useRepositoryRole } from "@scm-manager/ui-api"; diff --git a/scm-ui/ui-webapp/src/components/HeaderDropDown.tsx b/scm-ui/ui-webapp/src/components/HeaderDropDown.tsx index cc81112913..6287832e09 100644 --- a/scm-ui/ui-webapp/src/components/HeaderDropDown.tsx +++ b/scm-ui/ui-webapp/src/components/HeaderDropDown.tsx @@ -27,6 +27,8 @@ const DropDownMenu = styled.div` min-width: 20rem; @media screen and (min-width: ${devices.desktop.width}px) { + right: 0; + left: auto; min-width: 30rem; } @@ -35,23 +37,9 @@ const DropDownMenu = styled.div` } @media screen and (max-width: ${devices.mobile.width}px) { - ${(props) => - props.mobilePosition === "right" && - css` - right: -1.5rem; - left: auto; - `}; position: fixed; top: auto; - } - - @media screen and (max-width: ${devices.desktop.width - 1}px) { - margin-right: 1rem; - } - - @media screen and (min-width: ${devices.desktop.width}px) { - right: 0; - left: auto; + width: 100%; } &:before { @@ -69,6 +57,7 @@ const DropDownMenu = styled.div` } @media screen and (min-width: ${devices.mobile.width + 1}px) and (max-width: ${devices.desktop.width - 1}px) { + margin-right: 1rem; left: 1.3rem; } @@ -80,8 +69,11 @@ const DropDownMenu = styled.div` props.mobilePosition === "right" && css` @media screen and (max-width: ${devices.mobile.width}px) { - left: auto; - right: 1.75rem; + left: 21.75rem; + } + + @media screen and (min-width: ${devices.mobile.width + 1}px) and (max-width: ${devices.tablet.width - 1}px) { + left: 19.65rem; } `}; } diff --git a/scm-ui/ui-webapp/src/containers/Alerts.tsx b/scm-ui/ui-webapp/src/containers/Alerts.tsx index 0d642fe5d9..08e0079386 100644 --- a/scm-ui/ui-webapp/src/containers/Alerts.tsx +++ b/scm-ui/ui-webapp/src/containers/Alerts.tsx @@ -18,17 +18,17 @@ import React, { FC } from "react"; import { useTranslation } from "react-i18next"; import classNames from "classnames"; import styled from "styled-components"; -import { Alert } from "@scm-manager/ui-types"; -import { DateFromNow, Icon } from "@scm-manager/ui-components"; import { useAlerts } from "@scm-manager/ui-api"; -import HeaderDropDown, { Column, OnlyMobileWrappingColumn, Table } from "../components/HeaderDropDown"; +import { DateFromNow } from "@scm-manager/ui-components"; +import { Icon } from "@scm-manager/ui-core"; +import { Alert } from "@scm-manager/ui-types"; +import HeaderDropDown from "../components/HeaderDropDown"; -const FullHeightTable = styled(Table)` - height: 100%; -`; - -const RightColumn = styled(OnlyMobileWrappingColumn)` - height: 100%; +const AlertContainer = styled.ul` + > *:not(:last-child) { + border-bottom: solid 2px var(--scm-border-color); + } + border-left: 3px solid var(--scm-danger-color); `; type EntryProps = { @@ -36,27 +36,26 @@ type EntryProps = { }; const AlertsEntry: FC = ({ alert }) => { - const navigateTo = () => { - if (alert.link) { - window.open(alert.link)?.focus(); - } - }; + const content = ( +
+

+ {alert.title} ({alert.component} {alert.affectedVersions}) +

+

{alert.description}

+ +
+ ); return ( - - -

{alert.title}

-

{alert.description}

-
- -
-

- {alert.component} {alert.affectedVersions} -

- -
-
- +
  • + {alert.link ? ( + + {content} + + ) : ( + <>{content} + )} +
  • ); }; @@ -66,19 +65,21 @@ type Props = { const AlertsList: FC = ({ data }) => (
    - - - {data.map((a, i) => ( - - ))} - - + + {data.map((a, i) => ( + + ))} +
    ); const ShieldNotificationIcon: FC = () => { const [t] = useTranslation("commons"); - return ; + return ( + + shield-alt + + ); }; type ComponentAlert = Alert & { @@ -89,8 +90,8 @@ const useFlattenedAlerts = () => { const { data, error } = useAlerts(); if (data) { - const flattenedAlerts: ComponentAlert[] = data.alerts?.map(a => ({ ...a, component: "core" })) || []; - data.plugins?.forEach(p => flattenedAlerts.push(...(p.alerts || []).map(a => ({ ...a, component: p.name })))); + const flattenedAlerts: ComponentAlert[] = data.alerts?.map((a) => ({ ...a, component: "core" })) || []; + data.plugins?.forEach((p) => flattenedAlerts.push(...(p.alerts || []).map((a) => ({ ...a, component: p.name })))); flattenedAlerts.sort((a, b) => { if (new Date(a.issuedAt) < new Date(b.issuedAt)) { return 1; @@ -99,13 +100,13 @@ const useFlattenedAlerts = () => { }); return { data: flattenedAlerts, - error + error, }; } return { data, - error + error, }; }; 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/branches/components/BranchButtonGroup.tsx b/scm-ui/ui-webapp/src/repos/branches/components/BranchButtonGroup.tsx index 992711b403..257c4469ea 100644 --- a/scm-ui/ui-webapp/src/repos/branches/components/BranchButtonGroup.tsx +++ b/scm-ui/ui-webapp/src/repos/branches/components/BranchButtonGroup.tsx @@ -17,8 +17,9 @@ import React from "react"; import { WithTranslation, withTranslation } from "react-i18next"; import { Branch, Repository } from "@scm-manager/ui-types"; -import { Button, ButtonAddons } from "@scm-manager/ui-components"; +import { ButtonAddons } from "@scm-manager/ui-components"; import { encodePart } from "../../sources/components/content/FileLink"; +import { Icon, LinkButton } from "@scm-manager/ui-core"; type Props = WithTranslation & { repository: Repository; @@ -36,8 +37,14 @@ class BranchButtonGroup extends React.Component { return ( - )} - - - + + {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 ( <> @@ -177,9 +198,9 @@ 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..051e4ccad9 --- /dev/null +++ b/scm-ui/ui-webapp/src/repos/components/changesets/RevertModal.tsx @@ -0,0 +1,146 @@ +/* + * 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), + })} +

    +