SemanticGitMergeService.java

package com.taxonomy.versioning.service;

import com.taxonomy.dsl.merge.TaxDslMergeResult;
import com.taxonomy.dsl.merge.TaxDslSemanticMerger;
import com.taxonomy.dsl.storage.DslGitRepository;
import org.eclipse.jgit.lib.CommitBuilder;
import org.eclipse.jgit.lib.Constants;
import org.eclipse.jgit.lib.ObjectId;
import org.eclipse.jgit.lib.ObjectInserter;
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.lib.TreeFormatter;
import org.eclipse.jgit.revwalk.RevCommit;
import org.eclipse.jgit.revwalk.RevWalk;
import org.eclipse.jgit.revwalk.filter.RevFilter;
import org.springframework.stereotype.Service;

import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.List;

/**
 * Executes the normal Git merge first and falls back to a block-semantic DSL
 * merge when the canonical architecture file conflicts textually.
 */
@Service
public class SemanticGitMergeService {

    private static final String DSL_FILE = "architecture.taxdsl";

    private final TaxDslSemanticMerger semanticMerger = new TaxDslSemanticMerger();

    public MergeOutcome mergeBranches(DslGitRepository repository,
                                      String fromBranch,
                                      String intoBranch,
                                      String author) throws IOException {
        return mergeBranches(repository, fromBranch, intoBranch, author, null);
    }

    public MergeOutcome mergeBranches(DslGitRepository repository,
                                      String fromBranch,
                                      String intoBranch,
                                      String author,
                                      String message) throws IOException {
        String requestedMessage = normalizeMessage(message);
        Repository git = repository.getGitRepository();
        ObjectId targetHeadBefore = branchHead(git, intoBranch);
        String ordinaryCommit = repository.merge(fromBranch, intoBranch);
        if (ordinaryCommit != null) {
            String effectiveCommit = applyRequestedMergeMessage(
                    git,
                    intoBranch,
                    targetHeadBefore,
                    ordinaryCommit,
                    author,
                    requestedMessage);
            return new MergeOutcome(true, effectiveCommit, false, List.of(), null);
        }

        SemanticInputs inputs = semanticInputs(repository, fromBranch, intoBranch);
        if (inputs.error() != null) {
            return new MergeOutcome(false, null, false, List.of(inputs.error()), null);
        }
        TaxDslMergeResult merge = semanticMerger.merge(
                inputs.baseDsl(), inputs.oursDsl(), inputs.theirsDsl());
        if (!merge.isSuccessful()) {
            return new MergeOutcome(
                    false, null, true, merge.conflictIdentifiers(), merge);
        }

        String commitId = createMergeCommit(
                git,
                inputs.oursCommit(),
                inputs.theirsCommit(),
                intoBranch,
                fromBranch,
                merge.mergedText(),
                author,
                requestedMessage);
        return new MergeOutcome(true, commitId, true, List.of(), merge);
    }

    public MergeOutcome preview(DslGitRepository repository,
                                String fromBranch,
                                String intoBranch) throws IOException {
        SemanticInputs inputs = semanticInputs(repository, fromBranch, intoBranch);
        if (inputs.error() != null) {
            return new MergeOutcome(false, null, false, List.of(inputs.error()), null);
        }
        TaxDslMergeResult merge = semanticMerger.merge(
                inputs.baseDsl(), inputs.oursDsl(), inputs.theirsDsl());
        return new MergeOutcome(
                merge.isSuccessful(), null, true,
                merge.conflictIdentifiers(), merge);
    }

    /** Merge three DSL payloads when source and target live in different repositories. */
    public TaxDslMergeResult mergeContent(String baseDsl, String oursDsl, String theirsDsl) {
        return semanticMerger.merge(baseDsl, oursDsl, theirsDsl);
    }

    private SemanticInputs semanticInputs(DslGitRepository repository,
                                          String fromBranch,
                                          String intoBranch) throws IOException {
        Repository git = repository.getGitRepository();
        Ref fromRef = git.getRefDatabase().exactRef(Constants.R_HEADS + fromBranch);
        Ref intoRef = git.getRefDatabase().exactRef(Constants.R_HEADS + intoBranch);
        if (fromRef == null) {
            return SemanticInputs.error("Source branch '" + fromBranch + "' not found");
        }
        if (intoRef == null) {
            return SemanticInputs.error("Target branch '" + intoBranch + "' not found");
        }

        try (RevWalk walk = new RevWalk(git)) {
            RevCommit theirs = walk.parseCommit(fromRef.getObjectId());
            RevCommit ours = walk.parseCommit(intoRef.getObjectId());
            RevCommit base = findMergeBase(git, ours, theirs);
            String baseDsl = base == null ? "" : repository.getDslAtCommit(base.name());
            String oursDsl = repository.getDslAtCommit(ours.name());
            String theirsDsl = repository.getDslAtCommit(theirs.name());
            return new SemanticInputs(baseDsl, oursDsl, theirsDsl, ours, theirs, null);
        }
    }

    private static RevCommit findMergeBase(Repository repository,
                                           RevCommit ours,
                                           RevCommit theirs) throws IOException {
        try (RevWalk baseWalk = new RevWalk(repository)) {
            baseWalk.setRevFilter(RevFilter.MERGE_BASE);
            baseWalk.markStart(baseWalk.parseCommit(ours));
            baseWalk.markStart(baseWalk.parseCommit(theirs));
            return baseWalk.next();
        }
    }

    /**
     * The generic repository merge supplies a conventional default message.
     * When a caller explicitly requested a message, replace only a two-parent
     * merge commit created by this invocation. Fast-forward and already-merged
     * results keep their existing commit identity and message.
     */
    private static String applyRequestedMergeMessage(Repository repository,
                                                     String intoBranch,
                                                     ObjectId targetHeadBefore,
                                                     String commitId,
                                                     String authorName,
                                                     String requestedMessage) throws IOException {
        if (requestedMessage == null) {
            return commitId;
        }

        ObjectId originalId = ObjectId.fromString(commitId);
        if (originalId.equals(targetHeadBefore)) {
            return commitId;
        }
        Ref branch = repository.getRefDatabase().exactRef(Constants.R_HEADS + intoBranch);
        if (branch == null || !originalId.equals(branch.getObjectId())) {
            return commitId;
        }

        try (RevWalk walk = new RevWalk(repository)) {
            RevCommit original = walk.parseCommit(originalId);
            if (original.getParentCount() < 2) {
                return commitId;
            }

            PersonIdent actor = actor(authorName);
            try (ObjectInserter inserter = repository.newObjectInserter()) {
                CommitBuilder replacement = new CommitBuilder();
                replacement.setTreeId(original.getTree());
                replacement.setParentIds(original.getParents());
                replacement.setAuthor(actor);
                replacement.setCommitter(actor);
                replacement.setMessage(requestedMessage);
                ObjectId replacementId = inserter.insert(replacement);
                inserter.flush();

                RefUpdate update = repository.updateRef(Constants.R_HEADS + intoBranch);
                update.setExpectedOldObjectId(originalId);
                update.setNewObjectId(replacementId);
                update.setForceUpdate(true);
                update.setRefLogIdent(actor);
                update.setRefLogMessage("merge: " + requestedMessage, false);
                requireUpdated(update.update(), intoBranch);
                return replacementId.name();
            }
        }
    }

    private static String createMergeCommit(Repository repository,
                                            RevCommit ours,
                                            RevCommit theirs,
                                            String intoBranch,
                                            String fromBranch,
                                            String mergedDsl,
                                            String authorName,
                                            String requestedMessage) throws IOException {
        PersonIdent actor = actor(authorName);

        try (ObjectInserter inserter = repository.newObjectInserter()) {
            ObjectId blob = inserter.insert(Constants.OBJ_BLOB,
                    mergedDsl.getBytes(StandardCharsets.UTF_8));
            TreeFormatter tree = new TreeFormatter();
            tree.append(DSL_FILE, org.eclipse.jgit.lib.FileMode.REGULAR_FILE, blob);
            ObjectId treeId = inserter.insert(tree);

            CommitBuilder commit = new CommitBuilder();
            commit.setTreeId(treeId);
            commit.setParentIds(ours, theirs);
            commit.setAuthor(actor);
            commit.setCommitter(actor);
            commit.setMessage(requestedMessage != null
                    ? requestedMessage
                    : "Semantic merge branch '" + fromBranch + "' into " + intoBranch);
            ObjectId commitId = inserter.insert(commit);
            inserter.flush();

            RefUpdate update = repository.updateRef(Constants.R_HEADS + intoBranch);
            update.setExpectedOldObjectId(ours);
            update.setNewObjectId(commitId);
            update.setRefLogIdent(actor);
            update.setRefLogMessage("semantic merge: " + fromBranch + " into " + intoBranch, false);
            requireUpdated(update.update(), intoBranch);
            return commitId.name();
        }
    }

    private static ObjectId branchHead(Repository repository,
                                       String branch) throws IOException {
        Ref ref = repository.getRefDatabase().exactRef(Constants.R_HEADS + branch);
        return ref != null ? ref.getObjectId() : null;
    }

    private static PersonIdent actor(String authorName) {
        String effectiveAuthor = authorName == null || authorName.isBlank()
                ? "semantic-merge" : authorName.strip();
        return new PersonIdent(effectiveAuthor, "noreply@taxonomy.local");
    }

    private static String normalizeMessage(String message) {
        if (message == null || message.isBlank()) {
            return null;
        }
        return message.strip();
    }

    private static void requireUpdated(RefUpdate.Result result,
                                       String branch) throws IOException {
        if (result != RefUpdate.Result.NEW
                && result != RefUpdate.Result.FAST_FORWARD
                && result != RefUpdate.Result.FORCED
                && result != RefUpdate.Result.NO_CHANGE) {
            throw new IOException("Could not update branch '" + branch
                    + "' after merge: " + result);
        }
    }

    public record MergeOutcome(
            boolean success,
            String commitId,
            boolean semanticFallback,
            List<String> conflicts,
            TaxDslMergeResult semanticResult) {
        public MergeOutcome {
            conflicts = conflicts == null ? List.of() : List.copyOf(conflicts);
        }
    }

    private record SemanticInputs(
            String baseDsl,
            String oursDsl,
            String theirsDsl,
            RevCommit oursCommit,
            RevCommit theirsCommit,
            String error) {
        static SemanticInputs error(String error) {
            return new SemanticInputs(null, null, null, null, null, error);
        }
    }
}