ExternalSyncController.java
package com.taxonomy.workspace.controller;
import com.taxonomy.workspace.model.RepositoryTopologyMode;
import com.taxonomy.workspace.model.SystemRepository;
import com.taxonomy.workspace.service.ExternalGitSyncService;
import com.taxonomy.workspace.service.SystemRepositoryService;
import com.taxonomy.workspace.service.WorkspaceResolver;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.eclipse.jgit.transport.URIish;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.LinkedHashMap;
import java.util.Locale;
import java.util.Map;
/** REST API for conflict-safe external Git repository synchronization. */
@RestController
@RequestMapping("/api/workspace/external")
@Tag(name = "External Git Sync")
public class ExternalSyncController {
private static final Logger log = LoggerFactory.getLogger(ExternalSyncController.class);
private static final String OPERATION_FAILED_MESSAGE =
"The external Git operation failed; see the server log for diagnostic details";
private final ExternalGitSyncService externalGitSyncService;
private final SystemRepositoryService systemRepositoryService;
private final WorkspaceResolver workspaceResolver;
public ExternalSyncController(ExternalGitSyncService externalGitSyncService,
SystemRepositoryService systemRepositoryService,
WorkspaceResolver workspaceResolver) {
this.externalGitSyncService = externalGitSyncService;
this.systemRepositoryService = systemRepositoryService;
this.workspaceResolver = workspaceResolver;
}
@PostMapping("/fetch")
@Operation(summary = "Fetch from external remote",
description = "Fetches all branches from the configured external Git remote "
+ "into remote-tracking refs. Requires EXTERNAL_CANONICAL topology mode.")
public ResponseEntity<Map<String, Object>> fetchFromExternal() {
try {
var result = externalGitSyncService.fetchFromExternal();
Map<String, Object> response = new LinkedHashMap<>();
response.put("success", true);
response.put("updates", result.getTrackingRefUpdates().size());
return ResponseEntity.ok(response);
} catch (IllegalStateException | IllegalArgumentException exception) {
return configurationError(exception);
} catch (Exception exception) {
log.error("External Git fetch failed", exception);
return operationFailure("FETCH_FAILED");
}
}
@PostMapping("/push")
@Operation(summary = "Push to external remote",
description = "Pushes the selected local branch and verifies the remote update status.")
public ResponseEntity<Map<String, Object>> pushToExternal(
@RequestParam(required = false) String branch) {
try {
String targetBranch = branch != null
? branch
: systemRepositoryService.getSharedBranch();
var result = externalGitSyncService.pushToExternal(targetBranch);
Map<String, Object> response = new LinkedHashMap<>();
response.put("success", true);
response.put("branch", targetBranch);
response.put("updates", result.getRemoteUpdates().size());
return ResponseEntity.ok(response);
} catch (ExternalGitSyncService.ExternalPushRejectedException exception) {
log.warn("External Git push was rejected: {}", exception.getMessage());
return ResponseEntity.status(HttpStatus.CONFLICT).body(Map.of(
"success", false,
"error", "PUSH_REJECTED",
"message", "The external repository rejected the branch update"));
} catch (IllegalStateException | IllegalArgumentException exception) {
return configurationError(exception);
} catch (Exception exception) {
log.error("External Git push failed", exception);
return operationFailure("PUSH_FAILED");
}
}
@PostMapping("/full-sync")
@Operation(summary = "Synchronize with external remote",
description = "Fetches the configured shared branch and integrates it using Git "
+ "ancestry, fast-forward, or a three-way merge. Conflicting local changes "
+ "are preserved and reported with HTTP 409.")
public ResponseEntity<Map<String, Object>> fullSync() {
try {
String username = workspaceResolver.resolveCurrentUsername();
String commitId = externalGitSyncService.fullSync(username);
Map<String, Object> response = new LinkedHashMap<>();
response.put("success", true);
response.put("status", commitId != null ? "INTEGRATED" : "NO_REMOTE_BRANCH");
response.put("commitId", commitId);
return ResponseEntity.ok(response);
} catch (ExternalGitSyncService.ExternalSyncConflictException exception) {
log.warn("External Git synchronization found a merge conflict");
return ResponseEntity.status(HttpStatus.CONFLICT).body(Map.of(
"success", false,
"error", "MERGE_CONFLICT",
"message", "Local and external changes conflict; "
+ "the local shared branch was not changed"));
} catch (IllegalStateException | IllegalArgumentException exception) {
return configurationError(exception);
} catch (Exception exception) {
log.error("External Git full synchronization failed", exception);
return operationFailure("FULL_SYNC_FAILED");
}
}
@GetMapping("/status")
@Operation(summary = "Get external sync status",
description = "Returns external synchronization configuration and timestamps; "
+ "credentials are never included.")
public ResponseEntity<Map<String, Object>> getStatus() {
var status = externalGitSyncService.getStatus();
Map<String, Object> response = new LinkedHashMap<>();
response.put("externalEnabled", status.externalEnabled());
response.put("externalUrl", status.externalUrl());
response.put("credentialConfigured", status.credentialConfigured());
response.put("lastFetchAt", status.lastFetchAt());
response.put("lastPushAt", status.lastPushAt());
response.put("lastFetchCommit", status.lastFetchCommit());
return ResponseEntity.ok(response);
}
@PutMapping("/configure")
@PreAuthorize("hasRole('ADMIN')")
@Operation(summary = "Configure external repository",
description = "Sets the external URL and topology mode for the system repository. "
+ "Credentials are configured separately and are write-only.")
public ResponseEntity<Map<String, Object>> configure(
@RequestParam(required = false) String externalUrl,
@RequestParam(required = false) String topologyMode) {
try {
SystemRepository systemRepository = systemRepositoryService.getPrimaryRepository();
if (externalUrl != null) {
validateExternalUrl(externalUrl);
systemRepository.setExternalUrl(externalUrl.strip());
}
if (topologyMode != null) {
systemRepository.setTopologyMode(
RepositoryTopologyMode.valueOf(topologyMode));
}
systemRepositoryService.save(systemRepository);
Map<String, Object> response = new LinkedHashMap<>();
response.put("success", true);
response.put("topologyMode", systemRepository.getTopologyMode().name());
response.put("externalUrl", systemRepository.getExternalUrl());
return ResponseEntity.ok(response);
} catch (IllegalArgumentException exception) {
return ResponseEntity.badRequest().body(Map.of(
"error", "Invalid parameter",
"message", exception.getMessage()));
} catch (Exception exception) {
log.error("External Git configuration failed", exception);
return operationFailure("CONFIGURATION_FAILED");
}
}
private static void validateExternalUrl(String externalUrl) {
if (externalUrl.isBlank()) {
throw new IllegalArgumentException("External URL is not configured");
}
try {
URIish uri = new URIish(externalUrl.strip());
String scheme = uri.getScheme() == null
? ""
: uri.getScheme().toLowerCase(Locale.ROOT);
boolean httpUserInfo = ("http".equals(scheme) || "https".equals(scheme))
&& uri.getUser() != null;
if (uri.getPass() != null || httpUserInfo) {
throw new IllegalArgumentException(
"External Git URL must not contain credentials or HTTP user information");
}
} catch (IllegalArgumentException exception) {
throw exception;
} catch (Exception exception) {
throw new IllegalArgumentException("External Git URL is invalid", exception);
}
}
private static ResponseEntity<Map<String, Object>> configurationError(
RuntimeException exception) {
return ResponseEntity.badRequest().body(Map.of(
"error", "Configuration error",
"message", exception.getMessage()));
}
private static ResponseEntity<Map<String, Object>> operationFailure(String code) {
return ResponseEntity.internalServerError().body(Map.of(
"success", false,
"error", code,
"message", OPERATION_FAILED_MESSAGE));
}
}