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.NullProgressMonitor;
import org.eclipse.jgit.lib.Repository;
import org.eclipse.jgit.transport.*;
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.List;

/**
 * Handles synchronization between the internal system repository and an
 * external Git remote (e.g. Gitea, GitHub, GitLab).
 *
 * <p>This service is only active when the system repository is configured
 * in {@link RepositoryTopologyMode#EXTERNAL_CANONICAL} mode. It uses JGit's
 * {@link Transport} API to perform fetch and push operations directly on the
 * underlying DFS repository.
 *
 * <p>Typical workflow:
 * <ol>
 *   <li>{@link #fetchFromExternal()} — fetch all branches from the remote</li>
 *   <li>{@link #pushToExternal(String)} — push a local branch to the remote</li>
 *   <li>{@link #fullSync(String)} — fetch + merge remote into shared branch</li>
 * </ol>
 */
@Service
public class ExternalGitSyncService {

    private static final Logger log = LoggerFactory.getLogger(ExternalGitSyncService.class);

    private final DslGitRepositoryFactory repositoryFactory;
    private final SystemRepositoryService systemRepositoryService;

    @Autowired
    public ExternalGitSyncService(DslGitRepositoryFactory repositoryFactory,
                                  SystemRepositoryService systemRepositoryService) {
        this.repositoryFactory = repositoryFactory;
        this.systemRepositoryService = systemRepositoryService;
    }

    /**
     * Fetch all branches from the external remote into the system repository.
     *
     * @return the JGit FetchResult
     * @throws Exception if the fetch operation fails
     */
    public FetchResult fetchFromExternal() throws Exception {
        SystemRepository sysRepo = systemRepositoryService.getPrimaryRepository();
        validateExternalMode(sysRepo);

        Repository gitRepo = repositoryFactory.getSystemRepository().getGitRepository();
        URIish uri = new URIish(sysRepo.getExternalUrl());

        log.info("Fetching from external remote: {}", sysRepo.getExternalUrl());
        try (Transport transport = Transport.open(gitRepo, uri)) {
            configureCredentials(transport, sysRepo);
            FetchResult result = transport.fetch(NullProgressMonitor.INSTANCE,
                    List.of(new RefSpec("+refs/heads/*:refs/remotes/origin/*")));

            log.info("Fetch complete from {}: {} updates",
                    sysRepo.getExternalUrl(),
                    result.getTrackingRefUpdates().size());

            // Persist sync timestamps
            sysRepo.setLastFetchAt(Instant.now());
            if (!result.getTrackingRefUpdates().isEmpty()) {
                var firstUpdate = result.getTrackingRefUpdates().iterator().next();
                sysRepo.setLastFetchCommit(firstUpdate.getNewObjectId().name());
            }
            systemRepositoryService.save(sysRepo);

            return result;
        }
    }

    /**
     * Push a local branch to the external remote.
     *
     * @param localBranch the local branch name to push
     * @return the JGit PushResult
     * @throws Exception if the push operation fails
     */
    public PushResult pushToExternal(String localBranch) throws Exception {
        SystemRepository sysRepo = systemRepositoryService.getPrimaryRepository();
        validateExternalMode(sysRepo);

        Repository gitRepo = repositoryFactory.getSystemRepository().getGitRepository();
        URIish uri = new URIish(sysRepo.getExternalUrl());

        log.info("Pushing branch '{}' to external remote: {}", localBranch, sysRepo.getExternalUrl());
        try (Transport transport = Transport.open(gitRepo, uri)) {
            configureCredentials(transport, sysRepo);
            RemoteRefUpdate refUpdate = new RemoteRefUpdate(
                    gitRepo,
                    "refs/heads/" + localBranch,
                    "refs/heads/" + localBranch,
                    false, null, null);
            PushResult result = transport.push(NullProgressMonitor.INSTANCE,
                    java.util.List.of(refUpdate));

            log.info("Push complete: branch '{}' → {}", localBranch, sysRepo.getExternalUrl());

            // Persist sync timestamp
            sysRepo.setLastPushAt(Instant.now());
            systemRepositoryService.save(sysRepo);

            return result;
        }
    }

    /**
     * Full sync cycle: fetch from remote, then merge into the shared branch.
     *
     * <p>After fetching, this method resolves the remote-tracking ref
     * ({@code refs/remotes/origin/<branch>}) directly from the underlying
     * JGit repository to read the remote content, since
     * {@link DslGitRepository#getDslAtHead(String)} only resolves
     * {@code refs/heads/} prefixed refs.
     *
     * @param username the user performing the sync
     * @return the merge commit SHA, or null if no merge was needed
     * @throws Exception if any operation fails
     */
    public String fullSync(String username) throws Exception {
        fetchFromExternal();

        DslGitRepository sysRepo = repositoryFactory.getSystemRepository();
        String sharedBranch = systemRepositoryService.getSharedBranch();
        Repository gitRepo = sysRepo.getGitRepository();

        // Resolve the remote-tracking ref directly from JGit (not via getDslAtHead,
        // which only resolves refs/heads/* refs)
        String remoteRefName = "refs/remotes/origin/" + sharedBranch;
        org.eclipse.jgit.lib.Ref remoteRef = gitRepo.findRef(remoteRefName);

        if (remoteRef != null) {
            // Read the DSL content from the remote-tracking ref's tree
            String remoteDsl = readDslFromRef(gitRepo, remoteRef);
            if (remoteDsl != null) {
                String localDsl = sysRepo.getDslAtHead(sharedBranch);
                if (localDsl == null || !remoteDsl.equals(localDsl)) {
                    String commitId = sysRepo.commitDsl(sharedBranch, remoteDsl, username,
                            "Synced from external remote");
                    log.info("Full sync complete: merged remote into shared branch '{}'", sharedBranch);
                    return commitId;
                }
                log.info("Full sync: shared branch '{}' already up-to-date", sharedBranch);
                return sysRepo.getHeadCommit(sharedBranch);
            }
        }

        log.info("Full sync: no remote content found for branch '{}'", sharedBranch);
        return null;
    }

    /**
     * Read the DSL file content from a specific Git ref.
     *
     * @param gitRepo the JGit repository
     * @param ref     the ref to read from
     * @return the DSL content, or null if the ref has no content
     */
    private String readDslFromRef(Repository gitRepo, org.eclipse.jgit.lib.Ref ref) {
        try {
            var commitId = ref.getObjectId();
            if (commitId == null) return null;

            try (var revWalk = new org.eclipse.jgit.revwalk.RevWalk(gitRepo)) {
                var commit = revWalk.parseCommit(commitId);
                var tree = commit.getTree();
                try (var treeWalk = org.eclipse.jgit.treewalk.TreeWalk.forPath(
                        gitRepo, "architecture.dsl", tree)) {
                    if (treeWalk == null) return null;
                    var loader = gitRepo.open(treeWalk.getObjectId(0));
                    return new String(loader.getBytes(), java.nio.charset.StandardCharsets.UTF_8);
                }
            }
        } catch (Exception e) {
            log.warn("Could not read DSL from ref {}: {}", ref.getName(), e.getMessage());
            return null;
        }
    }

    /**
     * Get the current external sync status.
     *
     * @return a status summary
     */
    public ExternalSyncStatus getStatus() {
        try {
            SystemRepository sysRepo = systemRepositoryService.getPrimaryRepository();
            return new ExternalSyncStatus(
                    sysRepo.getTopologyMode() == RepositoryTopologyMode.EXTERNAL_CANONICAL,
                    sysRepo.getExternalUrl(),
                    sysRepo.getLastFetchAt(),
                    sysRepo.getLastPushAt(),
                    sysRepo.getLastFetchCommit()
            );
        } catch (Exception e) {
            return new ExternalSyncStatus(false, null, null, null, null);
        }
    }

    private void validateExternalMode(SystemRepository sysRepo) {
        if (sysRepo.getTopologyMode() != RepositoryTopologyMode.EXTERNAL_CANONICAL) {
            throw new IllegalStateException(
                    "External sync operations require EXTERNAL_CANONICAL topology mode, " +
                            "but current mode is " + sysRepo.getTopologyMode());
        }
        if (sysRepo.getExternalUrl() == null || sysRepo.getExternalUrl().isBlank()) {
            throw new IllegalStateException("External URL is not configured");
        }
    }

    private void configureCredentials(Transport transport, SystemRepository sysRepo) {
        String token = sysRepo.getExternalAuthToken();
        if (token != null && !token.isBlank()) {
            transport.setCredentialsProvider(
                    new UsernamePasswordCredentialsProvider(token, ""));
        }
    }

    /**
     * Summary of the external sync configuration and state.
     */
    public record ExternalSyncStatus(
            boolean externalEnabled,
            String externalUrl,
            Instant lastFetchAt,
            Instant lastPushAt,
            String lastFetchCommit
    ) {}
}