diff --git a/application/pom.xml b/application/pom.xml
index 1b89752925..2bfab2c858 100644
--- a/application/pom.xml
+++ b/application/pom.xml
@@ -337,6 +337,10 @@
Java-WebSocket
test
+
+ org.eclipse.jgit
+ org.eclipse.jgit
+
diff --git a/application/src/main/java/org/thingsboard/server/controller/EntitiesVersionControlController.java b/application/src/main/java/org/thingsboard/server/controller/EntitiesVersionControlController.java
new file mode 100644
index 0000000000..10928f1b51
--- /dev/null
+++ b/application/src/main/java/org/thingsboard/server/controller/EntitiesVersionControlController.java
@@ -0,0 +1,118 @@
+/**
+ * Copyright © 2016-2022 The Thingsboard Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.thingsboard.server.controller;
+
+import lombok.RequiredArgsConstructor;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+import org.thingsboard.server.common.data.EntityType;
+import org.thingsboard.server.common.data.ExportableEntity;
+import org.thingsboard.server.common.data.exception.ThingsboardException;
+import org.thingsboard.server.common.data.id.EntityId;
+import org.thingsboard.server.common.data.id.EntityIdFactory;
+import org.thingsboard.server.service.sync.exporting.data.EntityExportData;
+import org.thingsboard.server.service.sync.importing.EntityImportResult;
+import org.thingsboard.server.service.sync.vcs.DefaultEntitiesVersionControlService;
+import org.thingsboard.server.service.sync.vcs.data.EntitiesVersionControlSettings;
+import org.thingsboard.server.service.sync.vcs.data.EntityVersion;
+
+import java.util.List;
+import java.util.Set;
+import java.util.UUID;
+
+@RestController
+@RequestMapping("/api/entities/vc")
+@RequiredArgsConstructor
+public class EntitiesVersionControlController extends BaseController {
+
+ private final DefaultEntitiesVersionControlService versionControlService;
+
+
+
+ @PostMapping("/version/{entityType}/{entityId}")
+ @PreAuthorize("hasAuthority('TENANT_ADMIN')")
+ public EntityVersion saveEntityVersion(@PathVariable EntityType entityType,
+ @PathVariable("entityId") UUID entityUuid,
+ @RequestParam String branch,
+ @RequestBody String versionName) throws Exception {
+ EntityId entityId = EntityIdFactory.getByTypeAndUuid(entityType, entityUuid);
+ return versionControlService.saveEntityVersion(getTenantId(), entityId, branch, versionName);
+ }
+
+ @GetMapping("/version/{entityType}/{entityId}")
+ @PreAuthorize("hasAuthority('TENANT_ADMIN')")
+ public List listEntityVersions(@PathVariable EntityType entityType,
+ @PathVariable("entityId") UUID entityUuid,
+ @RequestParam String branch) throws Exception {
+ EntityId entityId = EntityIdFactory.getByTypeAndUuid(entityType, entityUuid);
+ return versionControlService.listEntityVersions(getTenantId(), entityId, branch, Integer.MAX_VALUE);
+ }
+
+
+
+ @GetMapping("/entity/{entityType}/{entityId}/{versionId}")
+ @PreAuthorize("hasAuthority('TENANT_ADMIN')")
+ public EntityExportData> getEntityAtVersion(@PathVariable EntityType entityType,
+ @PathVariable("entityId") UUID entityUuid,
+ @PathVariable String versionId) throws Exception {
+ EntityId entityId = EntityIdFactory.getByTypeAndUuid(entityType, entityUuid);
+ return versionControlService.getEntityAtVersion(getTenantId(), entityId, versionId);
+ }
+
+ @PostMapping("/entity/{entityType}/{entityId}/{versionId}")
+ @PreAuthorize("hasAuthority('TENANT_ADMIN')")
+ public EntityImportResult> loadEntityVersion(@PathVariable EntityType entityType,
+ @PathVariable("entityId") UUID entityUuid,
+ @PathVariable String versionId) throws Exception {
+ EntityId entityId = EntityIdFactory.getByTypeAndUuid(entityType, entityUuid);
+ return versionControlService.loadEntityVersion(getTenantId(), entityId, versionId);
+ }
+
+
+
+ @GetMapping("/branches")
+ public Set getAllowedBranches() throws ThingsboardException {
+ return versionControlService.getAllowedBranches(getTenantId());
+ }
+
+
+ @PostMapping("/settings")
+ @PreAuthorize("hasAuthority('SYS_ADMIN')")
+ public void saveSettings(@RequestBody EntitiesVersionControlSettings settings) throws Exception {
+ versionControlService.saveSettings(settings);
+ }
+
+ @GetMapping("/settings")
+ @PreAuthorize("hasAuthority('SYS_ADMIN')")
+ public EntitiesVersionControlSettings getSettings() {
+ return versionControlService.getSettings();
+ }
+
+
+
+ @PostMapping("/repository/reset")
+ @PreAuthorize("hasAuthority('SYS_ADMIN')")
+ public void resetLocalRepository() throws Exception {
+ versionControlService.resetRepository();
+ }
+
+}
diff --git a/application/src/main/java/org/thingsboard/server/service/sync/vcs/DefaultEntitiesVersionControlService.java b/application/src/main/java/org/thingsboard/server/service/sync/vcs/DefaultEntitiesVersionControlService.java
new file mode 100644
index 0000000000..0d78496f16
--- /dev/null
+++ b/application/src/main/java/org/thingsboard/server/service/sync/vcs/DefaultEntitiesVersionControlService.java
@@ -0,0 +1,274 @@
+/**
+ * Copyright © 2016-2022 The Thingsboard Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.thingsboard.server.service.sync.vcs;
+
+import com.fasterxml.jackson.core.type.TypeReference;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.ObjectWriter;
+import com.fasterxml.jackson.databind.SerializationFeature;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.apache.commons.io.FileUtils;
+import org.springframework.scheduling.annotation.Scheduled;
+import org.springframework.stereotype.Service;
+import org.thingsboard.common.util.JacksonUtil;
+import org.thingsboard.server.common.data.AdminSettings;
+import org.thingsboard.server.common.data.EntityType;
+import org.thingsboard.server.common.data.ExportableEntity;
+import org.thingsboard.server.common.data.id.EntityId;
+import org.thingsboard.server.common.data.id.TenantId;
+import org.thingsboard.server.dao.settings.AdminSettingsService;
+import org.thingsboard.server.dao.tenant.TenantService;
+import org.thingsboard.server.queue.util.AfterStartUp;
+import org.thingsboard.server.queue.util.TbCoreComponent;
+import org.thingsboard.server.service.sync.EntitiesExportImportService;
+import org.thingsboard.server.service.sync.exporting.EntityExportSettings;
+import org.thingsboard.server.service.sync.exporting.data.EntityExportData;
+import org.thingsboard.server.service.sync.importing.EntityImportResult;
+import org.thingsboard.server.service.sync.importing.EntityImportSettings;
+import org.thingsboard.server.service.sync.vcs.data.EntitiesVersionControlSettings;
+import org.thingsboard.server.service.sync.vcs.data.EntityVersion;
+import org.thingsboard.server.service.sync.vcs.data.GitSettings;
+import org.thingsboard.server.utils.git.Repository;
+import org.thingsboard.server.utils.git.data.Commit;
+
+import java.io.File;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.Collections;
+import java.util.List;
+import java.util.Optional;
+import java.util.Set;
+import java.util.concurrent.locks.Lock;
+import java.util.concurrent.locks.ReentrantLock;
+import java.util.stream.Collectors;
+
+@Service
+@TbCoreComponent
+@RequiredArgsConstructor
+@Slf4j
+public class DefaultEntitiesVersionControlService implements EntitiesVersionControlService {
+ // TODO [viacheslav]: start up only on one of the cores
+
+ private final TenantService tenantService;
+ private final EntitiesExportImportService exportImportService;
+ private final AdminSettingsService adminSettingsService;
+
+ private final ObjectWriter jsonWriter = new ObjectMapper().writer(SerializationFeature.INDENT_OUTPUT);
+ private static final String SETTINGS_KEY = "vc";
+
+ private Repository repository;
+ private final ReentrantLock fetchLock = new ReentrantLock();
+ private final Lock writeLock = new ReentrantLock();
+
+ @AfterStartUp
+ public void init() throws Exception {
+ try {
+ EntitiesVersionControlSettings settings = getSettings();
+ if (settings != null && settings.getGitSettings() != null) {
+ this.repository = initRepository(settings.getGitSettings());
+ }
+ } catch (Exception e) {
+ log.error("Failed to initialize entities version control service", e);
+ }
+ }
+
+
+ @Scheduled(initialDelay = 10 * 1000, fixedDelay = 10 * 1000)
+ public void fetch() throws Exception {
+ if (repository == null) return;
+
+ if (fetchLock.tryLock()) {
+ try {
+ log.info("Fetching remote repository");
+ repository.fetch();
+ } finally {
+ fetchLock.unlock();
+ }
+ }
+ }
+
+
+ @Override
+ public EntityVersion saveEntityVersion(TenantId tenantId, EntityId entityId, String branch, String versionName) throws Exception {
+ return saveEntitiesVersion(tenantId, List.of(entityId), branch, versionName);
+ }
+
+ @Override
+ public EntityVersion saveEntitiesVersion(TenantId tenantId, List entitiesIds, String branch, String versionName) throws Exception {
+ checkRepository();
+ checkBranch(tenantId, branch);
+
+ EntityExportSettings exportSettings = EntityExportSettings.builder()
+ .exportInboundRelations(false)
+ .exportOutboundRelations(false)
+ .build();
+ List>> entityDataList = entitiesIds.stream()
+ .map(entityId -> {
+ return exportImportService.exportEntity(tenantId, entityId, exportSettings);
+ })
+ .collect(Collectors.toList());
+
+ if (fetchLock.tryLock()) {
+ try {
+ repository.fetch();
+ } finally {
+ fetchLock.unlock();
+ }
+ }
+
+ writeLock.lock();
+ try {
+ if (repository.listBranches().contains(branch)) {
+ repository.checkout(branch);
+ repository.merge(branch);
+ } else {
+ repository.createAndCheckoutOrphanBranch(branch);
+ }
+
+ for (EntityExportData> entityData : entityDataList) {
+ String entityDataJson = jsonWriter.writeValueAsString(entityData);
+ FileUtils.write(new File(repository.getDirectory() + "/" + getRelativePathForEntity(entityData.getEntity().getId())),
+ entityDataJson, StandardCharsets.UTF_8);
+ }
+
+ Commit commit = repository.commit(versionName, ".", "Tenant " + tenantId);
+ repository.push();
+ return new EntityVersion(commit.getId(), commit.getMessage(), commit.getAuthorName());
+ } finally {
+ writeLock.unlock();
+ }
+ }
+
+
+
+ @Override
+ public List listEntityVersions(TenantId tenantId, EntityId entityId, String branch, int limit) throws Exception {
+ checkRepository();
+ checkBranch(tenantId, branch);
+
+ return repository.listCommits(branch, getRelativePathForEntity(entityId), limit).stream()
+ .map(commit -> new EntityVersion(commit.getId(), commit.getMessage(), commit.getAuthorName()))
+ .collect(Collectors.toList());
+ }
+
+ @Override
+ public List listEntityTypeVersions(TenantId tenantId, EntityType entityType, String branch, int limit) throws Exception {
+ checkRepository();
+ checkBranch(tenantId, branch);
+
+ return repository.listCommits(branch, getRelativePathForEntityType(entityType), limit).stream()
+ .map(commit -> new EntityVersion(commit.getId(), commit.getMessage(), commit.getAuthorName()))
+ .collect(Collectors.toList());
+ }
+
+
+
+ @Override
+ public , I extends EntityId> EntityExportData getEntityAtVersion(TenantId tenantId, I entityId, String versionId) throws Exception {
+ checkRepository();
+ // FIXME [viacheslav]: validate access
+
+ String entityDataJson = repository.getFileContentAtCommit(getRelativePathForEntity(entityId), versionId);
+ return JacksonUtil.fromString(entityDataJson, new TypeReference>() {});
+ }
+
+ @Override
+ public , I extends EntityId> EntityImportResult loadEntityVersion(TenantId tenantId, I entityId, String versionId) throws Exception {
+ EntityExportData entityData = getEntityAtVersion(tenantId, entityId, versionId);
+ return exportImportService.importEntity(tenantId, entityData, EntityImportSettings.builder()
+ .importInboundRelations(false)
+ .importOutboundRelations(false)
+ .updateReferencesToOtherEntities(true)
+ .build());
+ }
+
+
+
+ private String getRelativePathForEntity(EntityId entityId) {
+ return getRelativePathForEntityType(entityId.getEntityType())
+ + "/" + entityId.getId() + ".json";
+ }
+
+ private String getRelativePathForEntityType(EntityType entityType) {
+ return entityType.name().toLowerCase();
+ }
+
+
+ private void checkBranch(TenantId tenantId, String branch) {
+ if (!getAllowedBranches(tenantId).contains(branch)) {
+ throw new IllegalArgumentException("Tenant does not have access to this branch");
+ }
+ }
+
+ public Set getAllowedBranches(TenantId tenantId) {
+ return Optional.ofNullable(getSettings())
+ .flatMap(settings -> Optional.ofNullable(settings.getAllowedBranches()))
+ .flatMap(tenantsAllowedBranches -> Optional.ofNullable(tenantsAllowedBranches.get(tenantId.getId())))
+ .orElse(Collections.emptySet());
+ }
+
+ @Override
+ public void saveSettings(EntitiesVersionControlSettings settings) throws Exception {
+ this.repository = initRepository(settings.getGitSettings());
+
+ AdminSettings adminSettings = Optional.ofNullable(adminSettingsService.findAdminSettingsByKey(TenantId.SYS_TENANT_ID, SETTINGS_KEY))
+ .orElseGet(() -> {
+ AdminSettings newSettings = new AdminSettings();
+ newSettings.setKey(SETTINGS_KEY);
+ return newSettings;
+ });
+ adminSettings.setJsonValue(JacksonUtil.valueToTree(settings));
+ adminSettingsService.saveAdminSettings(TenantId.SYS_TENANT_ID, adminSettings);
+ }
+
+ @Override
+ public EntitiesVersionControlSettings getSettings() {
+ return Optional.ofNullable(adminSettingsService.findAdminSettingsByKey(TenantId.SYS_TENANT_ID, SETTINGS_KEY))
+ .map(adminSettings -> JacksonUtil.treeToValue(adminSettings.getJsonValue(), EntitiesVersionControlSettings.class))
+ .orElse(null);
+ }
+
+
+
+ private void checkRepository() {
+ if (repository == null) {
+ throw new IllegalStateException("Repository is not initialized");
+ }
+ }
+
+ private static Repository initRepository(GitSettings gitSettings) throws Exception {
+ if (Files.exists(Path.of(gitSettings.getRepositoryDirectory()))) {
+ return Repository.open(gitSettings.getRepositoryDirectory(),
+ gitSettings.getUsername(), gitSettings.getPassword());
+ } else {
+ Files.createDirectories(Path.of(gitSettings.getRepositoryDirectory()));
+ return Repository.clone(gitSettings.getRepositoryUri(), gitSettings.getRepositoryDirectory(),
+ gitSettings.getUsername(), gitSettings.getPassword());
+ }
+ }
+
+ public void resetRepository() throws Exception {
+ if (this.repository != null) {
+ FileUtils.deleteDirectory(new File(repository.getDirectory()));
+ this.repository = null;
+ }
+ EntitiesVersionControlSettings settings = getSettings();
+ this.repository = initRepository(settings.getGitSettings());
+ }
+
+}
diff --git a/application/src/main/java/org/thingsboard/server/service/sync/vcs/EntitiesVersionControlService.java b/application/src/main/java/org/thingsboard/server/service/sync/vcs/EntitiesVersionControlService.java
new file mode 100644
index 0000000000..a9a3c0e3e8
--- /dev/null
+++ b/application/src/main/java/org/thingsboard/server/service/sync/vcs/EntitiesVersionControlService.java
@@ -0,0 +1,50 @@
+/**
+ * Copyright © 2016-2022 The Thingsboard Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.thingsboard.server.service.sync.vcs;
+
+import org.thingsboard.server.common.data.EntityType;
+import org.thingsboard.server.common.data.ExportableEntity;
+import org.thingsboard.server.common.data.id.EntityId;
+import org.thingsboard.server.common.data.id.TenantId;
+import org.thingsboard.server.service.sync.exporting.data.EntityExportData;
+import org.thingsboard.server.service.sync.importing.EntityImportResult;
+import org.thingsboard.server.service.sync.vcs.data.EntitiesVersionControlSettings;
+import org.thingsboard.server.service.sync.vcs.data.EntityVersion;
+
+import java.util.List;
+
+public interface EntitiesVersionControlService {
+
+ EntityVersion saveEntityVersion(TenantId tenantId, EntityId entityId, String branch, String versionName) throws Exception;
+
+ EntityVersion saveEntitiesVersion(TenantId tenantId, List entitiesIds, String branch, String versionName) throws Exception;
+
+
+ List listEntityVersions(TenantId tenantId, EntityId entityId, String branch, int limit) throws Exception;
+
+ List listEntityTypeVersions(TenantId tenantId, EntityType entityType, String branch, int limit) throws Exception;
+
+
+ , I extends EntityId> EntityExportData getEntityAtVersion(TenantId tenantId, I entityId, String versionId) throws Exception;
+
+ , I extends EntityId> EntityImportResult loadEntityVersion(TenantId tenantId, I entityId, String versionId) throws Exception;
+
+
+ void saveSettings(EntitiesVersionControlSettings settings) throws Exception;
+
+ EntitiesVersionControlSettings getSettings();
+
+}
diff --git a/application/src/main/java/org/thingsboard/server/service/sync/vcs/data/EntitiesVersionControlSettings.java b/application/src/main/java/org/thingsboard/server/service/sync/vcs/data/EntitiesVersionControlSettings.java
new file mode 100644
index 0000000000..6a46606845
--- /dev/null
+++ b/application/src/main/java/org/thingsboard/server/service/sync/vcs/data/EntitiesVersionControlSettings.java
@@ -0,0 +1,28 @@
+/**
+ * Copyright © 2016-2022 The Thingsboard Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.thingsboard.server.service.sync.vcs.data;
+
+import lombok.Data;
+
+import java.util.Map;
+import java.util.Set;
+import java.util.UUID;
+
+@Data
+public class EntitiesVersionControlSettings {
+ private Map> allowedBranches;
+ private GitSettings gitSettings;
+}
diff --git a/application/src/main/java/org/thingsboard/server/service/sync/vcs/data/EntityVersion.java b/application/src/main/java/org/thingsboard/server/service/sync/vcs/data/EntityVersion.java
new file mode 100644
index 0000000000..42a2e2f555
--- /dev/null
+++ b/application/src/main/java/org/thingsboard/server/service/sync/vcs/data/EntityVersion.java
@@ -0,0 +1,29 @@
+/**
+ * Copyright © 2016-2022 The Thingsboard Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.thingsboard.server.service.sync.vcs.data;
+
+import lombok.AllArgsConstructor;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+@Data
+@NoArgsConstructor
+@AllArgsConstructor
+public class EntityVersion {
+ private String id;
+ private String name;
+ private String authorName;
+}
diff --git a/application/src/main/java/org/thingsboard/server/service/sync/vcs/data/GitSettings.java b/application/src/main/java/org/thingsboard/server/service/sync/vcs/data/GitSettings.java
new file mode 100644
index 0000000000..0d97bed2d1
--- /dev/null
+++ b/application/src/main/java/org/thingsboard/server/service/sync/vcs/data/GitSettings.java
@@ -0,0 +1,32 @@
+/**
+ * Copyright © 2016-2022 The Thingsboard Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.thingsboard.server.service.sync.vcs.data;
+
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+@Data
+@AllArgsConstructor
+@NoArgsConstructor
+@Builder
+public class GitSettings {
+ private String repositoryUri;
+ private String repositoryDirectory;
+ private String username;
+ private String password;
+}
diff --git a/application/src/main/java/org/thingsboard/server/utils/git/Repository.java b/application/src/main/java/org/thingsboard/server/utils/git/Repository.java
new file mode 100644
index 0000000000..8099be8259
--- /dev/null
+++ b/application/src/main/java/org/thingsboard/server/utils/git/Repository.java
@@ -0,0 +1,256 @@
+/**
+ * Copyright © 2016-2022 The Thingsboard Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.thingsboard.server.utils.git;
+
+import com.google.common.collect.Streams;
+import lombok.Getter;
+import org.apache.commons.lang3.StringUtils;
+import org.eclipse.jgit.api.Git;
+import org.eclipse.jgit.api.GitCommand;
+import org.eclipse.jgit.api.ListBranchCommand;
+import org.eclipse.jgit.api.LogCommand;
+import org.eclipse.jgit.api.RmCommand;
+import org.eclipse.jgit.api.TransportCommand;
+import org.eclipse.jgit.api.errors.GitAPIException;
+import org.eclipse.jgit.lib.Constants;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectLoader;
+import org.eclipse.jgit.lib.ObjectReader;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.filter.RevFilter;
+import org.eclipse.jgit.transport.CredentialsProvider;
+import org.eclipse.jgit.transport.UsernamePasswordCredentialsProvider;
+import org.eclipse.jgit.treewalk.TreeWalk;
+import org.eclipse.jgit.treewalk.filter.PathFilter;
+import org.thingsboard.server.utils.git.data.Commit;
+
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+public class Repository {
+
+ private final Git git;
+ private final CredentialsProvider credentialsProvider;
+
+ @Getter
+ private final String directory;
+
+ private Repository(Git git, CredentialsProvider credentialsProvider, String directory) {
+ this.git = git;
+ this.credentialsProvider = credentialsProvider;
+ this.directory = directory;
+ }
+
+ public static Repository clone(String uri, String directory,
+ String username, String password) throws GitAPIException {
+ CredentialsProvider credentialsProvider = newCredentialsProvider(username, password);
+ Git git = Git.cloneRepository()
+ .setURI(uri)
+ .setDirectory(new java.io.File(directory))
+ .setNoCheckout(true)
+ .setCredentialsProvider(credentialsProvider)
+ .call();
+ return new Repository(git, credentialsProvider, directory);
+ }
+
+ public static Repository open(String directory, String username, String password) throws IOException {
+ Git git = Git.open(new java.io.File(directory));
+ return new Repository(git, newCredentialsProvider(username, password), directory);
+ }
+
+
+ public void fetch() throws GitAPIException {
+ execute(git.fetch()
+ .setRemoveDeletedRefs(true));
+ }
+
+
+ public List listBranches() throws GitAPIException {
+ return execute(git.branchList()
+ .setListMode(ListBranchCommand.ListMode.ALL)).stream()
+ .filter(ref -> !ref.getName().equals(Constants.HEAD))
+ .map(ref -> org.eclipse.jgit.lib.Repository.shortenRefName(ref.getName()))
+ .map(name -> StringUtils.removeStart(name, "origin/"))
+ .distinct().collect(Collectors.toList());
+ }
+
+
+ public List listCommits(String branchName, int limit) throws IOException, GitAPIException {
+ return listCommits(branchName, null, limit);
+ }
+
+ public List listCommits(String branchName, String path, int limit) throws IOException, GitAPIException {
+ ObjectId branchId = resolve("origin/" + branchName);
+ if (branchId == null) {
+ throw new IllegalArgumentException("Branch not found");
+ }
+ LogCommand command = git.log()
+ .add(branchId).setMaxCount(limit)
+ .setRevFilter(RevFilter.NO_MERGES);
+ if (StringUtils.isNotEmpty(path)) {
+ command.addPath(path);
+ }
+ return Streams.stream(execute(command))
+ .map(this::toCommit)
+ .collect(Collectors.toList());
+ }
+
+
+ public List listFilesAtCommit(Commit commit) throws IOException {
+ return listFilesAtCommit(commit, null);
+ }
+
+ public List listFilesAtCommit(Commit commit, String path) throws IOException {
+ List files = new ArrayList<>();
+ RevCommit revCommit = resolveCommit(commit.getId());
+ try (TreeWalk treeWalk = new TreeWalk(git.getRepository())) {
+ treeWalk.reset(revCommit.getTree().getId());
+ if (StringUtils.isNotEmpty(path)) {
+ treeWalk.setFilter(PathFilter.create(path));
+ }
+ treeWalk.setRecursive(true);
+ while (treeWalk.next()) {
+ files.add(treeWalk.getPathString());
+ }
+ }
+ return files;
+ }
+
+
+ public String getFileContentAtCommit(String file, String commitId) throws IOException {
+ RevCommit revCommit = resolveCommit(commitId);
+ try (TreeWalk treeWalk = TreeWalk.forPath(git.getRepository(), file, revCommit.getTree())) {
+ if (treeWalk == null) {
+ throw new IllegalArgumentException("Not found");
+ }
+ ObjectId blobId = treeWalk.getObjectId(0);
+ try (ObjectReader objectReader = git.getRepository().newObjectReader()) {
+ ObjectLoader objectLoader = objectReader.open(blobId);
+ byte[] bytes = objectLoader.getBytes();
+ return new String(bytes, StandardCharsets.UTF_8);
+ }
+ }
+ }
+
+
+ public void checkout(String branchName) throws GitAPIException {
+ execute(git.checkout()
+ .setName(branchName));
+ }
+
+ public void merge(String branchName) throws IOException, GitAPIException {
+ ObjectId branchId = resolve("origin/" + branchName);
+ if (branchId == null) {
+ throw new IllegalArgumentException("Branch not found");
+ }
+ execute(git.merge()
+ .include(branchId));
+ }
+
+ public void createAndCheckoutOrphanBranch(String name) throws GitAPIException {
+ execute(git.checkout()
+ .setOrphan(true)
+ .setName(name));
+ Set uncommittedChanges = git.status().call().getUncommittedChanges();
+ if (!uncommittedChanges.isEmpty()) {
+ RmCommand rm = git.rm();
+ uncommittedChanges.forEach(rm::addFilepattern);
+ execute(rm);
+ }
+ execute(git.clean());
+ }
+
+ public void clean() throws GitAPIException {
+ execute(git.clean().setCleanDirectories(true));
+ }
+
+ public Commit commit(String message, String filePattern, String author) throws GitAPIException {
+ execute(git.add().addFilepattern(filePattern));
+ RevCommit revCommit = execute(git.commit()
+ .setMessage(message)
+ .setAuthor(author, author));
+ return toCommit(revCommit);
+ }
+
+
+ public void push() throws GitAPIException {
+ execute(git.push());
+ }
+
+
+// public List getCommitChanges(Commit commit) throws IOException, GitAPIException {
+// RevCommit revCommit = resolveCommit(commit.getId());
+// if (revCommit.getParentCount() == 0) {
+// return null; // just takes the first parent of a commit, but should find a parent in branch provided
+// }
+// return execute(git.diff()
+// .setOldTree(prepareTreeParser(git.getRepository().parseCommit(revCommit.getParent(0))))
+// .setNewTree(prepareTreeParser(revCommit))).stream()
+// .map(diffEntry -> new Diff(diffEntry.getChangeType().name(), diffEntry.getOldPath(), diffEntry.getNewPath()))
+// .collect(Collectors.toList());
+// }
+//
+//
+// private AbstractTreeIterator prepareTreeParser(RevCommit revCommit) throws IOException {
+// // from the commit we can build the tree which allows us to construct the TreeParser
+// //noinspection Duplicates
+// org.eclipse.jgit.lib.Repository repository = git.getRepository();
+// try (RevWalk walk = new RevWalk(repository)) {
+// RevTree tree = walk.parseTree(revCommit.getTree().getId());
+//
+// CanonicalTreeParser treeParser = new CanonicalTreeParser();
+// try (ObjectReader reader = repository.newObjectReader()) {
+// treeParser.reset(reader, tree.getId());
+// }
+//
+// walk.dispose();
+//
+// return treeParser;
+// }
+// }
+
+ private Commit toCommit(RevCommit revCommit) {
+ return new Commit(revCommit.getName(), revCommit.getFullMessage(), revCommit.getAuthorIdent().getName());
+ }
+
+ private RevCommit resolveCommit(String id) throws IOException {
+ return git.getRepository().parseCommit(resolve(id));
+ }
+
+ private ObjectId resolve(String rev) throws IOException {
+ return git.getRepository().resolve(rev);
+ }
+
+ private , T> T execute(C command) throws GitAPIException {
+ if (command instanceof TransportCommand) {
+ ((TransportCommand, ?>) command).setCredentialsProvider(credentialsProvider);
+// SshSessionFactory sshSessionFactory = SshSessionFactory.getInstance();
+// transportCommand.setTransportConfigCallback(transport -> {
+// ((SshTransport) transport).setSshSessionFactory(sshSessionFactory);
+// });
+ }
+ return command.call();
+ }
+
+ private static CredentialsProvider newCredentialsProvider(String username, String password) {
+ return new UsernamePasswordCredentialsProvider(username, password);
+ }
+
+}
diff --git a/application/src/main/java/org/thingsboard/server/utils/git/data/Branch.java b/application/src/main/java/org/thingsboard/server/utils/git/data/Branch.java
new file mode 100644
index 0000000000..a458f9f5aa
--- /dev/null
+++ b/application/src/main/java/org/thingsboard/server/utils/git/data/Branch.java
@@ -0,0 +1,23 @@
+/**
+ * Copyright © 2016-2022 The Thingsboard Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.thingsboard.server.utils.git.data;
+
+import lombok.Data;
+
+@Data
+public class Branch {
+ private final String shortName;
+}
diff --git a/application/src/main/java/org/thingsboard/server/utils/git/data/Commit.java b/application/src/main/java/org/thingsboard/server/utils/git/data/Commit.java
new file mode 100644
index 0000000000..7567dba1c5
--- /dev/null
+++ b/application/src/main/java/org/thingsboard/server/utils/git/data/Commit.java
@@ -0,0 +1,25 @@
+/**
+ * Copyright © 2016-2022 The Thingsboard Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.thingsboard.server.utils.git.data;
+
+import lombok.Data;
+
+@Data
+public class Commit {
+ private final String id;
+ private final String message;
+ private final String authorName;
+}
diff --git a/application/src/main/java/org/thingsboard/server/utils/git/data/Diff.java b/application/src/main/java/org/thingsboard/server/utils/git/data/Diff.java
new file mode 100644
index 0000000000..7572a003d2
--- /dev/null
+++ b/application/src/main/java/org/thingsboard/server/utils/git/data/Diff.java
@@ -0,0 +1,25 @@
+/**
+ * Copyright © 2016-2022 The Thingsboard Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.thingsboard.server.utils.git.data;
+
+import lombok.Data;
+
+@Data
+public class Diff {
+ private final String type;
+ private final String oldPath;
+ private final String newPath;
+}
diff --git a/pom.xml b/pom.xml
index 9713e86993..ea5df86409 100755
--- a/pom.xml
+++ b/pom.xml
@@ -134,6 +134,7 @@
1.16.0
1.12
+ 6.1.0.202203080745-r
@@ -1875,6 +1876,11 @@
${zeroturnaround.version}
test
+
+ org.eclipse.jgit
+ org.eclipse.jgit
+ ${jgit.version}
+