ExternalGitSyncService.java
package com.taxonomy.workspace.service;
import com.taxonomy.dsl.storage.DslGitRepository;
import com.taxonomy.dsl.storage.DslGitRepositoryFactory;
import com.taxonomy.workspace.model.RepositoryTopologyMode;
import com.taxonomy.workspace.model.SystemRepository;
import org.eclipse.jgit.lib.Constants;
import org.eclipse.jgit.lib.NullProgressMonitor;
import org.eclipse.jgit.lib.ObjectId;
import org.eclipse.jgit.lib.PersonIdent;
import org.eclipse.jgit.lib.Ref;
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.transport.FetchResult;
import org.eclipse.jgit.transport.PushResult;
import org.eclipse.jgit.transport.RefSpec;
import org.eclipse.jgit.transport.RemoteRefUpdate;
import org.eclipse.jgit.transport.Transport;
import org.eclipse.jgit.transport.URIish;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.io.IOException;
import java.time.Instant;
import java.util.EnumSet;
import java.util.List;
import java.util.Locale;
import java.util.Set;
/**
* Handles synchronization between the internal system repository and an
* external Git remote (for example Gitea, GitHub or GitLab).
*
* <p>Synchronization is ancestry-aware. A remote branch is fast-forwarded or
* merged through JGit; remote file contents are never committed wholesale on
* top of an unrelated local history. Diverging edits that cannot be merged are
* reported as conflicts and leave the local shared branch unchanged.</p>
*/
@Service
public class ExternalGitSyncService {
private static final Logger log = LoggerFactory.getLogger(ExternalGitSyncService.class);
private static final Set<RemoteRefUpdate.Status> SUCCESSFUL_PUSH_STATUSES =
EnumSet.of(RemoteRefUpdate.Status.OK, RemoteRefUpdate.Status.UP_TO_DATE);
private final DslGitRepositoryFactory repositoryFactory;
private final SystemRepositoryService systemRepositoryService;
private final ExternalGitCredentials credentials;
@Autowired
public ExternalGitSyncService(DslGitRepositoryFactory repositoryFactory,
SystemRepositoryService systemRepositoryService,
ExternalGitCredentials credentials) {
this.repositoryFactory = repositoryFactory;
this.systemRepositoryService = systemRepositoryService;
this.credentials = credentials;
}
/** Convenience constructor for focused tests without configured credentials. */
public ExternalGitSyncService(DslGitRepositoryFactory repositoryFactory,
SystemRepositoryService systemRepositoryService) {
this(repositoryFactory, systemRepositoryService, ExternalGitCredentials.none());
}
/** Fetch all external branches into {@code refs/remotes/origin/*}. */
public FetchResult fetchFromExternal() throws Exception {
SystemRepository systemRepository = systemRepositoryService.getPrimaryRepository();
URIish uri = validateExternalModeAndCreateUri(systemRepository);
Repository gitRepository = repositoryFactory.getSystemRepository().getGitRepository();
log.info("Fetching from configured external Git remote");
try (Transport transport = Transport.open(gitRepository, uri)) {
credentials.configure(transport);
FetchResult result = transport.fetch(
NullProgressMonitor.INSTANCE,
List.of(new RefSpec("+refs/heads/*:refs/remotes/origin/*")));
String sharedBranch = systemRepositoryService.getSharedBranch();
Ref sharedRemoteRef = gitRepository.getRefDatabase()
.exactRef(remoteTrackingRefName(sharedBranch));
systemRepository.setLastFetchAt(Instant.now());
if (sharedRemoteRef != null && sharedRemoteRef.getObjectId() != null) {
systemRepository.setLastFetchCommit(sharedRemoteRef.getObjectId().name());
}
systemRepositoryService.save(systemRepository);
log.info("External fetch completed with {} tracking updates",
result.getTrackingRefUpdates().size());
return result;
}
}
/**
* Push a local branch and fail when the remote rejects or does not perform
* the requested update.
*/
public PushResult pushToExternal(String localBranch) throws Exception {
SystemRepository systemRepository = systemRepositoryService.getPrimaryRepository();
URIish uri = validateExternalModeAndCreateUri(systemRepository);
validateBranchName(localBranch);
Repository gitRepository = repositoryFactory.getSystemRepository().getGitRepository();
log.info("Pushing branch '{}' to configured external Git remote", localBranch);
try (Transport transport = Transport.open(gitRepository, uri)) {
credentials.configure(transport);
RemoteRefUpdate refUpdate = new RemoteRefUpdate(
gitRepository,
localRefName(localBranch),
localRefName(localBranch),
false,
null,
null);
PushResult result = transport.push(
NullProgressMonitor.INSTANCE,
List.of(refUpdate));
verifyPushResult(result, localBranch);
systemRepository.setLastPushAt(Instant.now());
systemRepositoryService.save(systemRepository);
log.info("Push completed successfully for branch '{}'", localBranch);
return result;
}
}
/**
* Fetch and integrate the configured remote shared branch.
*
* <p>The method is synchronized to prevent two in-process synchronizations
* from reusing the temporary merge ref concurrently. JGit expected-old
* object IDs still protect every durable ref update.</p>
*
* @return the resulting local shared-branch commit, or {@code null} when the
* configured remote does not contain the shared branch
* @throws ExternalSyncConflictException when histories diverge and cannot be merged
*/
public synchronized String fullSync(String username) throws Exception {
fetchFromExternal();
DslGitRepository dslRepository = repositoryFactory.getSystemRepository();
Repository gitRepository = dslRepository.getGitRepository();
String sharedBranch = systemRepositoryService.getSharedBranch();
validateBranchName(sharedBranch);
String localRefName = localRefName(sharedBranch);
Ref remoteRef = gitRepository.getRefDatabase()
.exactRef(remoteTrackingRefName(sharedBranch));
if (remoteRef == null || remoteRef.getObjectId() == null) {
log.info("External sync found no remote shared branch '{}'", sharedBranch);
return null;
}
Ref localRef = gitRepository.getRefDatabase().exactRef(localRefName);
if (localRef == null || localRef.getObjectId() == null) {
updateRef(
gitRepository,
localRefName,
remoteRef.getObjectId(),
ObjectId.zeroId(),
false,
username,
"external-sync: create shared branch from remote");
log.info("Created local shared branch '{}' at fetched remote commit {}",
sharedBranch, remoteRef.getObjectId().name());
return remoteRef.getObjectId().name();
}
try (RevWalk walk = new RevWalk(gitRepository)) {
RevCommit localCommit = walk.parseCommit(localRef.getObjectId());
RevCommit remoteCommit = walk.parseCommit(remoteRef.getObjectId());
if (walk.isMergedInto(remoteCommit, localCommit)) {
log.info("External sync: local shared branch '{}' is already up to date", sharedBranch);
return localCommit.name();
}
}
String temporaryBranch = temporaryMergeBranch(sharedBranch);
String temporaryRefName = localRefName(temporaryBranch);
Ref previousTemporaryRef = gitRepository.getRefDatabase().exactRef(temporaryRefName);
ObjectId expectedTemporaryHead = previousTemporaryRef != null
&& previousTemporaryRef.getObjectId() != null
? previousTemporaryRef.getObjectId()
: ObjectId.zeroId();
updateRef(
gitRepository,
temporaryRefName,
remoteRef.getObjectId(),
expectedTemporaryHead,
true,
username,
"external-sync: stage fetched shared branch");
try {
String resultingCommit = dslRepository.merge(temporaryBranch, sharedBranch);
if (resultingCommit == null) {
throw new ExternalSyncConflictException(
"The local and external shared branches contain conflicting changes");
}
log.info("External sync integrated remote branch into '{}' at {}",
sharedBranch, resultingCommit);
return resultingCommit;
} finally {
deleteTemporaryRef(gitRepository, temporaryRefName);
}
}
/** Get the current external synchronization status without exposing credentials. */
public ExternalSyncStatus getStatus() {
try {
SystemRepository systemRepository = systemRepositoryService.getPrimaryRepository();
return new ExternalSyncStatus(
systemRepository.getTopologyMode() == RepositoryTopologyMode.EXTERNAL_CANONICAL,
systemRepository.getExternalUrl(),
credentials.isConfigured(),
systemRepository.getLastFetchAt(),
systemRepository.getLastPushAt(),
systemRepository.getLastFetchCommit());
} catch (Exception exception) {
log.debug("External sync status is unavailable", exception);
return new ExternalSyncStatus(false, null, credentials.isConfigured(), null, null, null);
}
}
/**
* Validate a remote URL without returning or logging embedded credentials.
* HTTP(S) user information and passwords in any URI form are rejected. SSH
* user names such as {@code git@example.org:team/repo.git} remain supported,
* but authentication material must come from deployment secret configuration.
*/
static URIish validateExternalUrl(String externalUrl) {
if (externalUrl == null || 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");
}
return uri;
} catch (IllegalArgumentException exception) {
throw exception;
} catch (Exception exception) {
throw new IllegalArgumentException("External Git URL is invalid", exception);
}
}
private URIish validateExternalModeAndCreateUri(SystemRepository systemRepository) {
if (systemRepository.getTopologyMode() != RepositoryTopologyMode.EXTERNAL_CANONICAL) {
throw new IllegalStateException(
"External sync operations require EXTERNAL_CANONICAL topology mode, but current mode is "
+ systemRepository.getTopologyMode());
}
try {
return validateExternalUrl(systemRepository.getExternalUrl());
} catch (IllegalArgumentException exception) {
throw new IllegalStateException(exception.getMessage(), exception);
}
}
private void verifyPushResult(PushResult result, String branch) throws ExternalPushRejectedException {
if (result.getRemoteUpdates().isEmpty()) {
throw new ExternalPushRejectedException(
"External Git remote did not report an update for branch '" + branch + "'");
}
for (RemoteRefUpdate update : result.getRemoteUpdates()) {
if (!SUCCESSFUL_PUSH_STATUSES.contains(update.getStatus())) {
String reason = update.getMessage();
String suffix = reason == null || reason.isBlank() ? "" : " (" + reason + ")";
throw new ExternalPushRejectedException(
"External Git remote rejected branch '" + branch + "': "
+ update.getStatus() + suffix);
}
}
}
private static void validateBranchName(String branch) {
if (branch == null || branch.isBlank()
|| !Repository.isValidRefName(localRefName(branch))) {
throw new IllegalArgumentException("Invalid Git branch name");
}
}
private static String localRefName(String branch) {
return Constants.R_HEADS + branch;
}
private static String remoteTrackingRefName(String branch) {
return Constants.R_REMOTES + "origin/" + branch;
}
private static String temporaryMergeBranch(String sharedBranch) {
return "__external_sync__/" + sharedBranch.replaceAll("[^A-Za-z0-9._/-]", "_");
}
private static void updateRef(Repository repository,
String refName,
ObjectId newObjectId,
ObjectId expectedOldObjectId,
boolean force,
String username,
String reflogMessage) throws IOException {
RefUpdate update = repository.updateRef(refName);
update.setNewObjectId(newObjectId);
update.setExpectedOldObjectId(expectedOldObjectId);
update.setForceUpdate(force);
update.setRefLogIdent(actor(username));
update.setRefLogMessage(reflogMessage, false);
RefUpdate.Result result = update.update();
if (result != RefUpdate.Result.NEW
&& result != RefUpdate.Result.FAST_FORWARD
&& result != RefUpdate.Result.FORCED
&& result != RefUpdate.Result.NO_CHANGE) {
throw new IOException("Git ref update failed for " + refName + ": " + result);
}
}
private static void deleteTemporaryRef(Repository repository, String refName) {
try {
Ref existing = repository.getRefDatabase().exactRef(refName);
if (existing == null || existing.getObjectId() == null) {
return;
}
RefUpdate delete = repository.updateRef(refName);
delete.setExpectedOldObjectId(existing.getObjectId());
delete.setForceUpdate(true);
delete.setRefLogIdent(actor("taxonomy"));
delete.setRefLogMessage("external-sync: remove temporary merge ref", false);
RefUpdate.Result result = delete.delete();
if (result != RefUpdate.Result.FORCED
&& result != RefUpdate.Result.NO_CHANGE
&& result != RefUpdate.Result.NEW) {
log.warn("Could not remove temporary external-sync ref {}: {}", refName, result);
}
} catch (IOException exception) {
log.warn("Could not remove temporary external-sync ref {}", refName, exception);
}
}
private static PersonIdent actor(String username) {
String name = username == null || username.isBlank() ? "taxonomy" : username;
String safeName = name.replaceAll("[\\r\\n<>]", "_");
return new PersonIdent(safeName, "taxonomy@localhost");
}
/** Raised when a fetched branch cannot be merged without conflicts. */
public static class ExternalSyncConflictException extends IOException {
public ExternalSyncConflictException(String message) {
super(message);
}
}
/** Raised when the remote does not accept the requested push update. */
public static class ExternalPushRejectedException extends IOException {
public ExternalPushRejectedException(String message) {
super(message);
}
}
public record ExternalSyncStatus(
boolean externalEnabled,
String externalUrl,
boolean credentialConfigured,
Instant lastFetchAt,
Instant lastPushAt,
String lastFetchCommit) {
}
}