ConflictDetectionService.java
package com.taxonomy.versioning.service;
import com.taxonomy.dsl.storage.DslGitRepository;
import com.taxonomy.dsl.storage.DslGitRepositoryFactory;
import com.taxonomy.workspace.service.WorkspaceContext;
import org.eclipse.jgit.lib.Constants;
import org.eclipse.jgit.lib.Ref;
import org.eclipse.jgit.merge.MergeStrategy;
import org.eclipse.jgit.merge.ThreeWayMerger;
import org.eclipse.jgit.revwalk.RevCommit;
import org.eclipse.jgit.revwalk.RevWalk;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import java.io.IOException;
import java.util.List;
/**
* Detects potential conflicts before merge or cherry-pick operations.
*
* <p>Provides preview functionality so the UI can warn the user before
* they start an operation that would fail.
*/
@Service
public class ConflictDetectionService {
private static final Logger log = LoggerFactory.getLogger(ConflictDetectionService.class);
private final DslGitRepositoryFactory repositoryFactory;
public ConflictDetectionService(DslGitRepositoryFactory repositoryFactory) {
this.repositoryFactory = repositoryFactory;
}
/**
* Resolve the Git repository for the given workspace context.
*
* @param ctx the workspace context (use {@link WorkspaceContext#SHARED}
* for the system repository)
* @return the resolved DslGitRepository
*/
private DslGitRepository resolveRepository(WorkspaceContext ctx) {
return repositoryFactory.resolveRepository(ctx);
}
/**
* Result of a merge preview.
*
* @param canMerge whether the merge would succeed without conflicts
* @param fromBranch the source branch
* @param intoBranch the target branch
* @param fromCommit HEAD commit of source branch
* @param intoCommit HEAD commit of target branch
* @param alreadyMerged true if source is already merged into target
* @param fastForwardable true if a fast-forward merge is possible
* @param warnings any warnings about the merge
*/
public record MergePreview(
boolean canMerge,
String fromBranch,
String intoBranch,
String fromCommit,
String intoCommit,
boolean alreadyMerged,
boolean fastForwardable,
List<String> warnings
) {}
/**
* Result of a cherry-pick preview.
*
* @param canCherryPick whether the cherry-pick would succeed without conflicts
* @param commitId the commit to cherry-pick
* @param targetBranch the target branch
* @param targetCommit HEAD commit of target branch
* @param warnings any warnings about the cherry-pick
*/
public record CherryPickPreview(
boolean canCherryPick,
String commitId,
String targetBranch,
String targetCommit,
List<String> warnings
) {}
/**
* Preview a merge to check for conflicts.
*
* <p>Uses the system repository (SHARED context). Use
* {@link #previewMerge(String, String, WorkspaceContext)} for workspace-aware resolution.
*
* @param fromBranch the source branch
* @param intoBranch the target branch
* @return the merge preview result
*/
public MergePreview previewMerge(String fromBranch, String intoBranch) {
return previewMerge(fromBranch, intoBranch, WorkspaceContext.SHARED);
}
/**
* Preview a merge to check for conflicts.
*
* @param fromBranch the source branch
* @param intoBranch the target branch
* @param ctx the workspace context for repository resolution
* @return the merge preview result
*/
public MergePreview previewMerge(String fromBranch, String intoBranch, WorkspaceContext ctx) {
try {
var repo = resolveRepository(ctx).getGitRepository();
String fromRefName = Constants.R_HEADS + fromBranch;
String intoRefName = Constants.R_HEADS + intoBranch;
Ref fromRef = repo.getRefDatabase().exactRef(fromRefName);
Ref intoRef = repo.getRefDatabase().exactRef(intoRefName);
if (fromRef == null) {
return new MergePreview(false, fromBranch, intoBranch, null, null,
false, false, List.of("Source branch '" + fromBranch + "' not found"));
}
if (intoRef == null) {
return new MergePreview(false, fromBranch, intoBranch, null, null,
false, false, List.of("Target branch '" + intoBranch + "' not found"));
}
String fromCommit = fromRef.getObjectId().name();
String intoCommit = intoRef.getObjectId().name();
try (RevWalk walk = new RevWalk(repo)) {
RevCommit fromRev = walk.parseCommit(fromRef.getObjectId());
RevCommit intoRev = walk.parseCommit(intoRef.getObjectId());
// Check if already merged
if (walk.isMergedInto(fromRev, intoRev)) {
return new MergePreview(true, fromBranch, intoBranch, fromCommit, intoCommit,
true, false, List.of("Already merged: '" + fromBranch + "' is ancestor of '" + intoBranch + "'"));
}
// Check fast-forward
if (walk.isMergedInto(intoRev, fromRev)) {
return new MergePreview(true, fromBranch, intoBranch, fromCommit, intoCommit,
false, true, List.of());
}
// Try three-way merge (dry run)
ThreeWayMerger merger = MergeStrategy.RECURSIVE.newMerger(repo, true);
boolean success = merger.merge(intoRev, fromRev);
if (success) {
return new MergePreview(true, fromBranch, intoBranch, fromCommit, intoCommit,
false, false, List.of());
} else {
return new MergePreview(false, fromBranch, intoBranch, fromCommit, intoCommit,
false, false, List.of("Merge would result in conflicts"));
}
}
} catch (IOException e) {
log.error("Failed to preview merge '{}' → '{}'", fromBranch, intoBranch, e);
return new MergePreview(false, fromBranch, intoBranch, null, null,
false, false, List.of("Error: " + e.getMessage()));
}
}
/**
* Details about a merge or cherry-pick conflict, including the content
* from both sides so the UI can display a side-by-side resolution view.
*
* @param conflictType "merge" or "cherry-pick"
* @param oursLabel label for "our" side (e.g. target branch name)
* @param theirsLabel label for "their" side (e.g. source branch name)
* @param oursContent DSL content from the target branch HEAD
* @param theirsContent DSL content from the source branch HEAD (or cherry-pick commit)
* @param baseContent DSL content from the common ancestor (may be null)
*/
public record ConflictDetails(
String conflictType,
String oursLabel,
String theirsLabel,
String oursContent,
String theirsContent,
String baseContent
) {}
/**
* Get conflict details for a merge that would conflict.
*
* <p>Uses the system repository (SHARED context). Use
* {@link #getMergeConflictDetails(String, String, WorkspaceContext)} for workspace-aware resolution.
*
* @param fromBranch the source branch
* @param intoBranch the target branch
* @return conflict details, or null if no conflict detected
*/
public ConflictDetails getMergeConflictDetails(String fromBranch, String intoBranch) {
return getMergeConflictDetails(fromBranch, intoBranch, WorkspaceContext.SHARED);
}
/**
* Get conflict details for a merge that would conflict.
*
* <p>Returns the DSL content from both sides so the UI can display
* a side-by-side comparison for manual resolution.
*
* @param fromBranch the source branch
* @param intoBranch the target branch
* @param ctx the workspace context for repository resolution
* @return conflict details, or null if no conflict detected
*/
public ConflictDetails getMergeConflictDetails(String fromBranch, String intoBranch, WorkspaceContext ctx) {
try {
MergePreview preview = previewMerge(fromBranch, intoBranch, ctx);
if (preview.canMerge()) {
return null; // No conflict
}
// Non-conflict failures (branch not found, errors) should not
// be presented as conflicts — return null so the controller
// responds with conflict:false and the UI shows a warning toast.
if (preview.fromCommit() == null || preview.intoCommit() == null) {
return null;
}
DslGitRepository repo = resolveRepository(ctx);
String oursContent = repo.getDslAtHead(intoBranch);
String theirsContent = repo.getDslAtHead(fromBranch);
return new ConflictDetails(
"merge",
intoBranch,
fromBranch,
oursContent != null ? oursContent : "",
theirsContent != null ? theirsContent : "",
null
);
} catch (Exception e) {
log.error("Failed to get merge conflict details '{}' → '{}'", fromBranch, intoBranch, e);
return null;
}
}
/**
* Get conflict details for a cherry-pick that would conflict.
*
* <p>Uses the system repository (SHARED context). Use
* {@link #getCherryPickConflictDetails(String, String, WorkspaceContext)} for workspace-aware resolution.
*
* @param commitId the commit to cherry-pick
* @param targetBranch the target branch
* @return conflict details, or null if no conflict detected
*/
public ConflictDetails getCherryPickConflictDetails(String commitId, String targetBranch) {
return getCherryPickConflictDetails(commitId, targetBranch, WorkspaceContext.SHARED);
}
/**
* Get conflict details for a cherry-pick that would conflict.
*
* @param commitId the commit to cherry-pick
* @param targetBranch the target branch
* @param ctx the workspace context for repository resolution
* @return conflict details, or null if no conflict detected
*/
public ConflictDetails getCherryPickConflictDetails(String commitId, String targetBranch, WorkspaceContext ctx) {
try {
CherryPickPreview preview = previewCherryPick(commitId, targetBranch, ctx);
if (preview.canCherryPick()) {
return null; // No conflict
}
// Non-conflict failures (missing target branch, invalid commit)
// should not be presented as conflicts.
if (preview.targetCommit() == null) {
return null;
}
DslGitRepository repo = resolveRepository(ctx);
String oursContent = repo.getDslAtHead(targetBranch);
String theirsContent = repo.getDslAtCommit(commitId);
return new ConflictDetails(
"cherry-pick",
targetBranch,
"commit " + commitId.substring(0, Math.min(7, commitId.length())),
oursContent != null ? oursContent : "",
theirsContent != null ? theirsContent : "",
null
);
} catch (Exception e) {
log.error("Failed to get cherry-pick conflict details {} → '{}'", commitId, targetBranch, e);
return null;
}
}
/**
* Preview a cherry-pick to check for conflicts.
*
* <p>Uses the system repository (SHARED context). Use
* {@link #previewCherryPick(String, String, WorkspaceContext)} for workspace-aware resolution.
*
* @param commitId the commit to cherry-pick
* @param targetBranch the target branch
* @return the cherry-pick preview result
*/
public CherryPickPreview previewCherryPick(String commitId, String targetBranch) {
return previewCherryPick(commitId, targetBranch, WorkspaceContext.SHARED);
}
/**
* Preview a cherry-pick to check for conflicts.
*
* @param commitId the commit to cherry-pick
* @param targetBranch the target branch
* @param ctx the workspace context for repository resolution
* @return the cherry-pick preview result
*/
public CherryPickPreview previewCherryPick(String commitId, String targetBranch, WorkspaceContext ctx) {
try {
var repo = resolveRepository(ctx).getGitRepository();
String targetRefName = Constants.R_HEADS + targetBranch;
Ref targetRef = repo.getRefDatabase().exactRef(targetRefName);
if (targetRef == null) {
return new CherryPickPreview(false, commitId, targetBranch, null,
List.of("Target branch '" + targetBranch + "' not found"));
}
String targetCommit = targetRef.getObjectId().name();
try (RevWalk walk = new RevWalk(repo)) {
RevCommit pickCommit = walk.parseCommit(
org.eclipse.jgit.lib.ObjectId.fromString(commitId));
RevCommit targetHead = walk.parseCommit(targetRef.getObjectId());
// Try three-way merge (dry run) — mirrors the merge order used
// in DslGitRepository.cherryPick() for consistent conflict prediction
ThreeWayMerger merger = MergeStrategy.RECURSIVE.newMerger(repo, true);
boolean success = merger.merge(targetHead, pickCommit);
if (success) {
return new CherryPickPreview(true, commitId, targetBranch, targetCommit,
List.of());
} else {
return new CherryPickPreview(false, commitId, targetBranch, targetCommit,
List.of("Cherry-pick would result in conflicts"));
}
}
} catch (Exception e) {
log.error("Failed to preview cherry-pick {} → '{}'", commitId, targetBranch, e);
return new CherryPickPreview(false, commitId, targetBranch, null,
List.of("Error: " + e.getMessage()));
}
}
}