diff --git a/build-logic/src/main/kotlin/org.jabref.gradle.feature.test.gradle.kts b/build-logic/src/main/kotlin/org.jabref.gradle.feature.test.gradle.kts index f30830bbcf9..9c23b42ced1 100644 --- a/build-logic/src/main/kotlin/org.jabref.gradle.feature.test.gradle.kts +++ b/build-logic/src/main/kotlin/org.jabref.gradle.feature.test.gradle.kts @@ -28,8 +28,8 @@ testlogger { showPassed = false showSkipped = false - showCauses = false - showStackTraces = false + showCauses = true + showStackTraces = true } configurations.testCompileOnly { diff --git a/docs/code-howtos/git.md b/docs/code-howtos/git.md new file mode 100644 index 00000000000..630ebe030bf --- /dev/null +++ b/docs/code-howtos/git.md @@ -0,0 +1,71 @@ +# git + +## Conflict Scenarios + +- **T1.** Remote changed a field, local did not + → No conflict. + The local version remained unchanged, so the remote change can be safely applied. + +- **T2.** Local changed a field, remote did not + → No conflict. + The remote version did not touch the field, so the local change is preserved. + +- **T3.** Both local and remote changed the same field to the same value + → No conflict. + Although both sides changed the field, the result is identical—therefore, no conflict. + +- **T4.** Both local and remote changed the same field to different values + → Conflict. + This is a true semantic conflict that requires resolution. + +- **T5.** Local deleted a field, remote modified the same field + → Conflict. + One side deleted the field while the other updated it—this is contradictory. + +- **T6.** Local modified a field, remote deleted it + → Conflict. + Similar to T5, one side deletes, the other edits—this is a conflict. + +- **T7.** Local unchanged, remote deleted a field + → No conflict. + Local did not modify anything, so remote deletion is accepted. + +- **T8.** Local changed field A, remote changed field B (within the same entry) + → No conflict. + Changes are on separate fields, so they can be merged safely. + +- **T9.** Both changed the same entry, but only field order changed + → No conflict. + Field order is not semantically meaningful, so no conflict is detected. + +- **T10.** Local modified entry A, remote modified entry B + → No conflict. + Modifications are on different entries, which are always safe to merge. + +- **T11.** Remote added a new field, local did nothing + → No conflict. + Remote addition can be applied without issues. + +- **T12.** Remote added a field, local also added the same field, but with different value + → Conflict. + One side added while the other side modified—there is a semantic conflict. + +- **T13.** Local added a field, remote did nothing + → No conflict. + Safe to preserve the local addition. + +- **T14.** Both added the same field with the same value + → No conflict. + Even though both sides added it, the value is the same—no need for resolution. + +- **T15.** Both added the same field with different values + → Conflict. + The same field is introduced with different values, which creates a conflict. + +- **T16.** Both added the same entry key with different values + → Conflict. + Both sides created a new entry with the same citation key, but the fields differ. + +- **T17.** Both added the same entry key with identical values + → No conflict. + Both sides created a new entry with the same citation key and identical fields, so it can be merged safely. diff --git a/jabgui/src/main/java/org/jabref/gui/git/GitConflictResolverDialog.java b/jabgui/src/main/java/org/jabref/gui/git/GitConflictResolverDialog.java new file mode 100644 index 00000000000..485f9076afc --- /dev/null +++ b/jabgui/src/main/java/org/jabref/gui/git/GitConflictResolverDialog.java @@ -0,0 +1,45 @@ +package org.jabref.gui.git; + +import java.util.Optional; + +import org.jabref.gui.DialogService; +import org.jabref.gui.mergeentries.MergeEntriesDialog; +import org.jabref.gui.mergeentries.newmergedialog.ShowDiffConfig; +import org.jabref.gui.mergeentries.newmergedialog.diffhighlighter.DiffHighlighter; +import org.jabref.gui.mergeentries.newmergedialog.toolbar.ThreeWayMergeToolbar; +import org.jabref.gui.preferences.GuiPreferences; +import org.jabref.logic.git.conflicts.ThreeWayEntryConflict; +import org.jabref.model.entry.BibEntry; + +/** + * UI wrapper + * Receives a semantic conflict (ThreeWayEntryConflict), pops up an interactive GUI (belonging to mergeentries), and returns a user-confirmed BibEntry merge result. + */ +public class GitConflictResolverDialog { + private final DialogService dialogService; + private final GuiPreferences preferences; + + public GitConflictResolverDialog(DialogService dialogService, GuiPreferences preferences) { + this.dialogService = dialogService; + this.preferences = preferences; + } + + public Optional resolveConflict(ThreeWayEntryConflict conflict) { + BibEntry base = conflict.base(); + BibEntry local = conflict.local(); + BibEntry remote = conflict.remote(); + + // Create Dialog + Set Title + Configure Diff Highlighting + MergeEntriesDialog dialog = new MergeEntriesDialog(local, remote, preferences); + dialog.setLeftHeaderText("Local"); + dialog.setRightHeaderText("Remote"); + ShowDiffConfig diffConfig = new ShowDiffConfig( + ThreeWayMergeToolbar.DiffView.SPLIT, + DiffHighlighter.BasicDiffMethod.WORDS + ); + dialog.configureDiff(diffConfig); + + return dialogService.showCustomDialogAndWait(dialog) + .map(result -> result.mergedEntry()); + } +} diff --git a/jabgui/src/main/java/org/jabref/gui/git/GitPullAction.java b/jabgui/src/main/java/org/jabref/gui/git/GitPullAction.java new file mode 100644 index 00000000000..17d110ac4a9 --- /dev/null +++ b/jabgui/src/main/java/org/jabref/gui/git/GitPullAction.java @@ -0,0 +1,83 @@ +package org.jabref.gui.git; + +import java.io.IOException; +import java.nio.file.Path; + +import javax.swing.undo.UndoManager; + +import org.jabref.gui.DialogService; +import org.jabref.gui.StateManager; +import org.jabref.gui.actions.SimpleCommand; +import org.jabref.gui.preferences.GuiPreferences; +import org.jabref.logic.JabRefException; +import org.jabref.logic.git.GitHandler; +import org.jabref.logic.git.GitSyncService; +import org.jabref.logic.git.conflicts.GitConflictResolverStrategy; +import org.jabref.logic.git.model.MergeResult; +import org.jabref.model.database.BibDatabaseContext; + +import org.eclipse.jgit.api.errors.GitAPIException; + +/** + * - Check if Git is enabled + * - Verify activeDatabase is not null + * - Call GitPullViewModel.pull() + */ +public class GitPullAction extends SimpleCommand { + + private final DialogService dialogService; + private final StateManager stateManager; + private final GuiPreferences guiPreferences; + private final UndoManager undoManager; + + public GitPullAction(DialogService dialogService, + StateManager stateManager, + GuiPreferences guiPreferences, + UndoManager undoManager) { + this.dialogService = dialogService; + this.stateManager = stateManager; + this.guiPreferences = guiPreferences; + this.undoManager = undoManager; + } + + @Override + public void execute() { + // TODO: reconsider error handling + if (stateManager.getActiveDatabase().isEmpty()) { + dialogService.showErrorDialogAndWait("No database open", "Please open a database before pulling."); + return; + } + + BibDatabaseContext database = stateManager.getActiveDatabase().get(); + if (database.getDatabasePath().isEmpty()) { + dialogService.showErrorDialogAndWait("No .bib file path", "Cannot pull from Git: No file is associated with this database."); + return; + } + + Path bibFilePath = database.getDatabasePath().get(); + try { + GitHandler handler = new GitHandler(bibFilePath.getParent()); + GitConflictResolverDialog dialog = new GitConflictResolverDialog(dialogService, guiPreferences); + GitConflictResolverStrategy resolver = new GuiConflictResolverStrategy(dialog); + + GitSyncService syncService = new GitSyncService(guiPreferences.getImportFormatPreferences(), handler, resolver); + GitStatusViewModel statusViewModel = new GitStatusViewModel(bibFilePath); + + GitPullViewModel viewModel = new GitPullViewModel(syncService, statusViewModel); + MergeResult result = viewModel.pull(); + + if (result.isSuccessful()) { + dialogService.showInformationDialogAndWait("Git Pull", "Successfully merged and updated."); + } else { + dialogService.showWarningDialogAndWait("Git Pull", "Merge completed with conflicts."); + } + } catch (JabRefException e) { + dialogService.showErrorDialogAndWait("Git Pull Failed", e); + // TODO: error handling + } catch (GitAPIException e) { + throw new RuntimeException(e); + } catch (IOException e) { + throw new RuntimeException(e); + } + } +} diff --git a/jabgui/src/main/java/org/jabref/gui/git/GitPullViewModel.java b/jabgui/src/main/java/org/jabref/gui/git/GitPullViewModel.java new file mode 100644 index 00000000000..ddaf0e9e3cc --- /dev/null +++ b/jabgui/src/main/java/org/jabref/gui/git/GitPullViewModel.java @@ -0,0 +1,33 @@ +package org.jabref.gui.git; + +import java.io.IOException; +import java.nio.file.Path; + +import org.jabref.gui.AbstractViewModel; +import org.jabref.logic.JabRefException; +import org.jabref.logic.git.GitSyncService; +import org.jabref.logic.git.model.MergeResult; + +import org.eclipse.jgit.api.errors.GitAPIException; + +public class GitPullViewModel extends AbstractViewModel { + private final GitSyncService syncService; + private final GitStatusViewModel gitStatusViewModel; + private final Path bibFilePath; + + public GitPullViewModel(GitSyncService syncService, GitStatusViewModel gitStatusViewModel) { + this.syncService = syncService; + this.gitStatusViewModel = gitStatusViewModel; + this.bibFilePath = gitStatusViewModel.getCurrentBibFile(); + } + + public MergeResult pull() throws IOException, GitAPIException, JabRefException { + MergeResult result = syncService.fetchAndMerge(bibFilePath); + + if (result.isSuccessful()) { + gitStatusViewModel.updateStatusFromPath(bibFilePath); + } + + return result; + } +} diff --git a/jabgui/src/main/java/org/jabref/gui/git/GitStatusViewModel.java b/jabgui/src/main/java/org/jabref/gui/git/GitStatusViewModel.java new file mode 100644 index 00000000000..b22531b7cf4 --- /dev/null +++ b/jabgui/src/main/java/org/jabref/gui/git/GitStatusViewModel.java @@ -0,0 +1,130 @@ +package org.jabref.gui.git; + +import java.nio.file.Path; +import java.util.Optional; + +import javafx.beans.property.BooleanProperty; +import javafx.beans.property.ObjectProperty; +import javafx.beans.property.SimpleBooleanProperty; +import javafx.beans.property.SimpleObjectProperty; +import javafx.beans.property.SimpleStringProperty; +import javafx.beans.property.StringProperty; + +import org.jabref.gui.AbstractViewModel; +import org.jabref.logic.git.GitHandler; +import org.jabref.logic.git.status.GitStatusChecker; +import org.jabref.logic.git.status.GitStatusSnapshot; +import org.jabref.logic.git.status.SyncStatus; + +/** + * ViewModel that holds current Git sync status for the open .bib database. + * It maintains the state of the GitHandler bound to the current file path, including: + * - Whether the current file is inside a Git repository + * - Whether the file is tracked by Git + * - Whether there are unresolved merge conflicts + * - The current sync status (e.g., UP_TO_DATE, DIVERGED, etc.) + */ +public class GitStatusViewModel extends AbstractViewModel { + private final Path currentBibFile; + private final ObjectProperty syncStatus = new SimpleObjectProperty<>(SyncStatus.UNTRACKED); + private final BooleanProperty isTracking = new SimpleBooleanProperty(false); + private final BooleanProperty conflictDetected = new SimpleBooleanProperty(false); + private final StringProperty lastPulledCommit = new SimpleStringProperty(""); + private GitHandler activeHandler = null; + + public GitStatusViewModel(Path bibFilePath) { + this.currentBibFile = bibFilePath; + updateStatusFromPath(bibFilePath); + } + + /** + * Try to detect Git repository status from the given file or folder path. + * + * @param fileOrFolderInRepo Any path (file or folder) assumed to be inside a Git repository + */ + public void updateStatusFromPath(Path fileOrFolderInRepo) { + Optional maybeHandler = GitHandler.fromAnyPath(fileOrFolderInRepo); + + if (maybeHandler.isEmpty()) { + reset(); + return; + } + + GitHandler handler = maybeHandler.get(); + this.activeHandler = handler; + + GitStatusSnapshot snapshot = GitStatusChecker.checkStatus(fileOrFolderInRepo); + + setTracking(snapshot.tracking()); + setSyncStatus(snapshot.syncStatus()); + setConflictDetected(snapshot.conflict()); + snapshot.lastPulledCommit().ifPresent(this::setLastPulledCommit); + } + + /** + * Clears all internal state to defaults. + * Should be called when switching projects or Git context is lost + */ + public void reset() { + setSyncStatus(SyncStatus.UNTRACKED); + setTracking(false); + setConflictDetected(false); + setLastPulledCommit(""); + } + + public ObjectProperty syncStatusProperty() { + return syncStatus; + } + + public SyncStatus getSyncStatus() { + return syncStatus.get(); + } + + public void setSyncStatus(SyncStatus status) { + this.syncStatus.set(status); + } + + public BooleanProperty isTrackingProperty() { + return isTracking; + } + + public boolean isTracking() { + return isTracking.get(); + } + + public void setTracking(boolean tracking) { + this.isTracking.set(tracking); + } + + public BooleanProperty conflictDetectedProperty() { + return conflictDetected; + } + + public boolean isConflictDetected() { + return conflictDetected.get(); + } + + public void setConflictDetected(boolean conflict) { + this.conflictDetected.set(conflict); + } + + public StringProperty lastPulledCommitProperty() { + return lastPulledCommit; + } + + public String getLastPulledCommit() { + return lastPulledCommit.get(); + } + + public void setLastPulledCommit(String commitHash) { + this.lastPulledCommit.set(commitHash); + } + + public Optional getActiveHandler() { + return Optional.ofNullable(activeHandler); + } + + public Path getCurrentBibFile() { + return currentBibFile; + } +} diff --git a/jabgui/src/main/java/org/jabref/gui/git/GuiConflictResolverStrategy.java b/jabgui/src/main/java/org/jabref/gui/git/GuiConflictResolverStrategy.java new file mode 100644 index 00000000000..ce3178ab0d6 --- /dev/null +++ b/jabgui/src/main/java/org/jabref/gui/git/GuiConflictResolverStrategy.java @@ -0,0 +1,32 @@ +package org.jabref.gui.git; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +import org.jabref.logic.git.conflicts.GitConflictResolverStrategy; +import org.jabref.logic.git.conflicts.ThreeWayEntryConflict; +import org.jabref.logic.git.merge.GitMergeUtil; +import org.jabref.model.database.BibDatabaseContext; +import org.jabref.model.entry.BibEntry; + +public class GuiConflictResolverStrategy implements GitConflictResolverStrategy { + private final GitConflictResolverDialog dialog; + + public GuiConflictResolverStrategy(GitConflictResolverDialog dialog) { + this.dialog = dialog; + } + + @Override + public Optional resolveConflicts(List conflicts, BibDatabaseContext remote) { + List resolved = new ArrayList<>(); + for (ThreeWayEntryConflict conflict : conflicts) { + Optional maybeConflict = dialog.resolveConflict(conflict); + if (maybeConflict.isEmpty()) { + return Optional.empty(); + } + resolved.add(maybeConflict.get()); + } + return Optional.of(GitMergeUtil.replaceEntries(remote, resolved)); + } +} diff --git a/jablib/src/main/java/module-info.java b/jablib/src/main/java/module-info.java index 07bd3b6884e..af09654af97 100644 --- a/jablib/src/main/java/module-info.java +++ b/jablib/src/main/java/module-info.java @@ -106,6 +106,11 @@ exports org.jabref.logic.git; exports org.jabref.logic.pseudonymization; exports org.jabref.logic.citation.repository; + exports org.jabref.logic.git.conflicts; + exports org.jabref.logic.git.merge; + exports org.jabref.logic.git.io; + exports org.jabref.logic.git.model; + exports org.jabref.logic.git.status; requires java.base; diff --git a/jablib/src/main/java/org/jabref/logic/git/GitHandler.java b/jablib/src/main/java/org/jabref/logic/git/GitHandler.java index 77a78681428..916b2dbbdd2 100644 --- a/jablib/src/main/java/org/jabref/logic/git/GitHandler.java +++ b/jablib/src/main/java/org/jabref/logic/git/GitHandler.java @@ -202,4 +202,40 @@ public String getCurrentlyCheckedOutBranch() throws IOException { return git.getRepository().getBranch(); } } + + public void fetchOnCurrentBranch() throws IOException { + try (Git git = Git.open(this.repositoryPathAsFile)) { + git.fetch() + .setCredentialsProvider(credentialsProvider) + .call(); + } catch (GitAPIException e) { + LOGGER.info("Failed to fetch from remote", e); + } + } + + /** + * Try to locate the Git repository root by walking up the directory tree starting from the given path. + * If a directory containing a .git folder is found, a new GitHandler is created and returned. + * + * @param anyPathInsideRepo Any file or directory path that is assumed to be inside a Git repository + * @return Optional containing a GitHandler initialized with the repository root, or empty if not found + */ + public static Optional fromAnyPath(Path anyPathInsideRepo) { + Path current = anyPathInsideRepo.toAbsolutePath(); + while (current != null) { + if (Files.exists(current.resolve(".git"))) { + return Optional.of(new GitHandler(current)); + } + current = current.getParent(); + } + return Optional.empty(); + } + + public File getRepositoryPathAsFile() { + return repositoryPathAsFile; + } + + public Git open() throws IOException { + return Git.open(this.repositoryPathAsFile); + } } diff --git a/jablib/src/main/java/org/jabref/logic/git/GitSyncService.java b/jablib/src/main/java/org/jabref/logic/git/GitSyncService.java new file mode 100644 index 00000000000..6e3a75b7800 --- /dev/null +++ b/jablib/src/main/java/org/jabref/logic/git/GitSyncService.java @@ -0,0 +1,205 @@ +package org.jabref.logic.git; + +import java.io.IOException; +import java.nio.file.Path; +import java.util.List; +import java.util.Optional; + +import org.jabref.logic.JabRefException; +import org.jabref.logic.git.conflicts.GitConflictResolverStrategy; +import org.jabref.logic.git.conflicts.SemanticConflictDetector; +import org.jabref.logic.git.conflicts.ThreeWayEntryConflict; +import org.jabref.logic.git.io.GitBibParser; +import org.jabref.logic.git.io.GitFileReader; +import org.jabref.logic.git.io.GitFileWriter; +import org.jabref.logic.git.io.GitRevisionLocator; +import org.jabref.logic.git.io.RevisionTriple; +import org.jabref.logic.git.merge.MergePlan; +import org.jabref.logic.git.merge.SemanticMerger; +import org.jabref.logic.git.model.MergeResult; +import org.jabref.logic.git.status.GitStatusChecker; +import org.jabref.logic.git.status.GitStatusSnapshot; +import org.jabref.logic.git.status.SyncStatus; +import org.jabref.logic.importer.ImportFormatPreferences; +import org.jabref.model.database.BibDatabaseContext; + +import org.eclipse.jgit.api.Git; +import org.eclipse.jgit.api.errors.GitAPIException; +import org.eclipse.jgit.revwalk.RevCommit; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * GitSyncService currently serves as an orchestrator for Git pull/push logic. + * if (hasConflict) + * → UI merge; + * else + * → autoMerge := local + remoteDiff + */ +public class GitSyncService { + private static final Logger LOGGER = LoggerFactory.getLogger(GitSyncService.class); + + private static final boolean AMEND = true; + private final ImportFormatPreferences importFormatPreferences; + private final GitHandler gitHandler; + private final GitConflictResolverStrategy gitConflictResolverStrategy; + + public GitSyncService(ImportFormatPreferences importFormatPreferences, GitHandler gitHandler, GitConflictResolverStrategy gitConflictResolverStrategy) { + this.importFormatPreferences = importFormatPreferences; + this.gitHandler = gitHandler; + this.gitConflictResolverStrategy = gitConflictResolverStrategy; + } + + public MergeResult fetchAndMerge(Path bibFilePath) throws GitAPIException, IOException, JabRefException { + Optional maybeHandler = GitHandler.fromAnyPath(bibFilePath); + if (maybeHandler.isEmpty()) { + LOGGER.warn("Pull aborted: The file is not inside a Git repository."); + return MergeResult.failure(); + } + + GitStatusSnapshot status = GitStatusChecker.checkStatus(bibFilePath); + + if (!status.tracking()) { + LOGGER.warn("Pull aborted: The file is not under Git version control."); + return MergeResult.failure(); + } + + if (status.conflict()) { + LOGGER.warn("Pull aborted: Local repository has unresolved merge conflicts."); + return MergeResult.failure(); + } + + if (status.syncStatus() == SyncStatus.UP_TO_DATE || status.syncStatus() == SyncStatus.AHEAD) { + LOGGER.info("Pull skipped: Local branch is already up to date with remote."); + return MergeResult.success(); + } + + try (Git git = gitHandler.open()) { + // 1. Fetch latest remote branch + gitHandler.fetchOnCurrentBranch(); + + // 2. Locate base / local / remote commits + GitRevisionLocator locator = new GitRevisionLocator(); + RevisionTriple triple = locator.locateMergeCommits(git); + + // 3. Perform semantic merge + MergeResult result = performSemanticMerge(git, triple.base(), triple.local(), triple.remote(), bibFilePath); + + // 4. Auto-commit merge result if successful + if (result.isSuccessful()) { + gitHandler.createCommitOnCurrentBranch("Auto-merged by JabRef", !AMEND); + } + + return result; + } + } + + public MergeResult performSemanticMerge(Git git, + RevCommit baseCommit, + RevCommit localCommit, + RevCommit remoteCommit, + Path bibFilePath) throws IOException, JabRefException { + + Path bibPath = bibFilePath.toRealPath(); + Path workTree = git.getRepository().getWorkTree().toPath().toRealPath(); + Path relativePath; + + if (!bibPath.startsWith(workTree)) { + throw new IllegalStateException("Given .bib file is not inside repository"); + } + relativePath = workTree.relativize(bibPath); + + // 1. Load three versions + String baseContent = GitFileReader.readFileFromCommit(git, baseCommit, relativePath); + String localContent = GitFileReader.readFileFromCommit(git, localCommit, relativePath); + String remoteContent = GitFileReader.readFileFromCommit(git, remoteCommit, relativePath); + + BibDatabaseContext base = GitBibParser.parseBibFromGit(baseContent, importFormatPreferences); + BibDatabaseContext local = GitBibParser.parseBibFromGit(localContent, importFormatPreferences); + BibDatabaseContext remote = GitBibParser.parseBibFromGit(remoteContent, importFormatPreferences); + + // 2. Conflict detection + List conflicts = SemanticConflictDetector.detectConflicts(base, local, remote); + + BibDatabaseContext effectiveRemote; + if (conflicts.isEmpty()) { + effectiveRemote = remote; + } else { + // 3. If there are conflicts, ask strategy to resolve + Optional maybeRemote = gitConflictResolverStrategy.resolveConflicts(conflicts, remote); + if (maybeRemote.isEmpty()) { + LOGGER.warn("Merge aborted: Conflict resolution was canceled or denied."); + return MergeResult.failure(); + } + effectiveRemote = maybeRemote.get(); + } + + // 4. Apply resolved remote (either original or conflict-resolved) to local + MergePlan plan = SemanticConflictDetector.extractMergePlan(base, effectiveRemote); + SemanticMerger.applyMergePlan(local, plan); + + // 5. Write back merged result + GitFileWriter.write(bibFilePath, local, importFormatPreferences); + + return MergeResult.success(); + } + + public void push(Path bibFilePath) throws GitAPIException, IOException, JabRefException { + GitStatusSnapshot status = GitStatusChecker.checkStatus(bibFilePath); + + if (!status.tracking()) { + LOGGER.warn("Push aborted: file is not tracked by Git"); + return; + } + + switch (status.syncStatus()) { + case UP_TO_DATE -> { + boolean committed = gitHandler.createCommitOnCurrentBranch("Changes committed by JabRef", !AMEND); + if (committed) { + gitHandler.pushCommitsToRemoteRepository(); + } else { + LOGGER.info("No changes to commit — skipping push"); + } + } + + case AHEAD -> { + gitHandler.pushCommitsToRemoteRepository(); + } + + case BEHIND -> { + LOGGER.warn("Push aborted: Local branch is behind remote. Please pull first."); + } + + case DIVERGED -> { + try (Git git = gitHandler.open()) { + GitRevisionLocator locator = new GitRevisionLocator(); + RevisionTriple triple = locator.locateMergeCommits(git); + + MergeResult mergeResult = performSemanticMerge(git, triple.base(), triple.local(), triple.remote(), bibFilePath); + + if (!mergeResult.isSuccessful()) { + LOGGER.warn("Semantic merge failed — aborting push"); + return; + } + + boolean committed = gitHandler.createCommitOnCurrentBranch("Merged changes", !AMEND); + + if (committed) { + gitHandler.pushCommitsToRemoteRepository(); + } else { + LOGGER.info("Nothing to commit after semantic merge — skipping push"); + } + } + } + + case CONFLICT -> { + LOGGER.warn("Push aborted: Local repository has unresolved merge conflicts."); + } + + case UNTRACKED, UNKNOWN -> { + LOGGER.warn("Push aborted: Untracked or unknown Git status."); + } + } + } +} + diff --git a/jablib/src/main/java/org/jabref/logic/git/conflicts/CliConflictResolverStrategy.java b/jablib/src/main/java/org/jabref/logic/git/conflicts/CliConflictResolverStrategy.java new file mode 100644 index 00000000000..24b6afd30c4 --- /dev/null +++ b/jablib/src/main/java/org/jabref/logic/git/conflicts/CliConflictResolverStrategy.java @@ -0,0 +1,14 @@ +package org.jabref.logic.git.conflicts; + +import java.util.List; +import java.util.Optional; + +import org.jabref.model.database.BibDatabaseContext; + +public class CliConflictResolverStrategy implements GitConflictResolverStrategy { + + @Override + public Optional resolveConflicts(List conflicts, BibDatabaseContext remote) { + return Optional.empty(); + } +} diff --git a/jablib/src/main/java/org/jabref/logic/git/conflicts/GitConflictResolverStrategy.java b/jablib/src/main/java/org/jabref/logic/git/conflicts/GitConflictResolverStrategy.java new file mode 100644 index 00000000000..392497ce819 --- /dev/null +++ b/jablib/src/main/java/org/jabref/logic/git/conflicts/GitConflictResolverStrategy.java @@ -0,0 +1,18 @@ +package org.jabref.logic.git.conflicts; + +import java.util.List; +import java.util.Optional; + +import org.jabref.model.database.BibDatabaseContext; + +public interface GitConflictResolverStrategy { + /** + * Resolves all given entry-level semantic conflicts, and produces a new, resolved remote state. + * + * @param conflicts the list of detected three-way entry conflicts + * @param remote the original remote state + * @return the modified BibDatabaseContext containing resolved entries, + * or empty if user canceled merge or CLI refuses to merge. + */ + Optional resolveConflicts(List conflicts, BibDatabaseContext remote); +} diff --git a/jablib/src/main/java/org/jabref/logic/git/conflicts/SemanticConflictDetector.java b/jablib/src/main/java/org/jabref/logic/git/conflicts/SemanticConflictDetector.java new file mode 100644 index 00000000000..b10c6d2e6e6 --- /dev/null +++ b/jablib/src/main/java/org/jabref/logic/git/conflicts/SemanticConflictDetector.java @@ -0,0 +1,163 @@ +package org.jabref.logic.git.conflicts; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; +import java.util.function.Function; +import java.util.stream.Collectors; + +import org.jabref.logic.bibtex.comparator.BibDatabaseDiff; +import org.jabref.logic.bibtex.comparator.BibEntryDiff; +import org.jabref.logic.git.merge.MergePlan; +import org.jabref.model.database.BibDatabaseContext; +import org.jabref.model.entry.BibEntry; +import org.jabref.model.entry.field.Field; + +public class SemanticConflictDetector { + /** + * result := local + remoteDiff + * and then create merge commit having result as file content and local and remote branch as parent + */ + public static List detectConflicts(BibDatabaseContext base, BibDatabaseContext local, BibDatabaseContext remote) { + // 1. get diffs between base and remote + List remoteDiffs = BibDatabaseDiff.compare(base, remote).getEntryDifferences(); + if (remoteDiffs == null) { + return List.of(); + } + // 2. map citation key to entry for local/remote diffs + Map baseEntries = toEntryMap(base); + Map localEntries = toEntryMap(local); + + List conflicts = new ArrayList<>(); + + // 3. look for entries modified in both local and remote + for (BibEntryDiff remoteDiff : remoteDiffs) { + Optional keyOpt = remoteDiff.newEntry().getCitationKey(); + if (keyOpt.isEmpty()) { + continue; + } + + String citationKey = keyOpt.get(); + BibEntry baseEntry = baseEntries.get(citationKey); + BibEntry localEntry = localEntries.get(citationKey); + BibEntry remoteEntry = remoteDiff.newEntry(); + + // Conflict 1: if the entry exists in all 3 versions + if (baseEntry != null && localEntry != null && remoteEntry != null) { + if (hasConflictingFields(baseEntry, localEntry, remoteEntry)) { + conflicts.add(new ThreeWayEntryConflict(baseEntry, localEntry, remoteEntry)); + } + // Conflict 2: base missing, but local + remote both added same citation key with different content + } else if (baseEntry == null && localEntry != null && remoteEntry != null) { + if (!Objects.equals(localEntry, remoteEntry)) { + conflicts.add(new ThreeWayEntryConflict(null, localEntry, remoteEntry)); + } + // Case 3: one side deleted, other side modified + } else if (baseEntry != null) { + if (localEntry != null && remoteEntry == null && !Objects.equals(baseEntry, localEntry)) { + conflicts.add(new ThreeWayEntryConflict(baseEntry, localEntry, null)); + } + if (localEntry == null && remoteEntry != null && !Objects.equals(baseEntry, remoteEntry)) { + conflicts.add(new ThreeWayEntryConflict(baseEntry, null, remoteEntry)); + } + } + } + return conflicts; + } + + public static Map toEntryMap(BibDatabaseContext context) { + return context.getDatabase().getEntries().stream() + .filter(entry -> entry.getCitationKey().isPresent()) + .collect(Collectors.toMap( + entry -> entry.getCitationKey().get(), + Function.identity(), + (existing, replacement) -> replacement, + LinkedHashMap::new + )); + } + + private static boolean hasConflictingFields(BibEntry base, BibEntry local, BibEntry remote) { + // Go through union of all fields + Set fields = new HashSet<>(); + fields.addAll(base.getFields()); + fields.addAll(local.getFields()); + fields.addAll(remote.getFields()); + + for (Field field : fields) { + String baseVal = base.getField(field).orElse(null); + String localVal = local.getField(field).orElse(null); + String remoteVal = remote.getField(field).orElse(null); + + boolean localChanged = !Objects.equals(baseVal, localVal); + boolean remoteChanged = !Objects.equals(baseVal, remoteVal); + + if (localChanged && remoteChanged && !Objects.equals(localVal, remoteVal)) { + return true; + } + } + return false; + } + + /** + * Compares base and remote, finds all semantic-level changes (new entries, updated fields), and builds a patch plan. + * This plan is meant to be applied to local during merge: + * result = local + (remote − base) + * + * @param base The base version of the database. + * @param remote The remote version to be merged. + * @return A {@link MergePlan} describing how to update the local copy with remote changes. + */ + public static MergePlan extractMergePlan(BibDatabaseContext base, BibDatabaseContext remote) { + Map baseMap = toEntryMap(base); + Map remoteMap = toEntryMap(remote); + + Map> fieldPatches = new LinkedHashMap<>(); + List newEntries = new ArrayList<>(); + + for (Map.Entry remoteEntryPair : remoteMap.entrySet()) { + String key = remoteEntryPair.getKey(); + BibEntry remoteEntry = remoteEntryPair.getValue(); + BibEntry baseEntry = baseMap.get(key); + + if (baseEntry == null) { + // New entry (not in base) + newEntries.add(remoteEntry); + } else { + Map patch = computeFieldPatch(baseEntry, remoteEntry); + if (!patch.isEmpty()) { + fieldPatches.put(key, patch); + } + } + } + + return new MergePlan(fieldPatches, newEntries); + } + + /** + * Compares base and remote and constructs a patch at the field level. null == the field is deleted. + */ + private static Map computeFieldPatch(BibEntry base, BibEntry remote) { + Map patch = new LinkedHashMap<>(); + + Set allFields = new LinkedHashSet<>(); + allFields.addAll(base.getFields()); + allFields.addAll(remote.getFields()); + + for (Field field : allFields) { + String baseValue = base.getField(field).orElse(null); + String remoteValue = remote.getField(field).orElse(null); + + if (!Objects.equals(baseValue, remoteValue)) { + patch.put(field, remoteValue); + } + } + + return patch; + } +} diff --git a/jablib/src/main/java/org/jabref/logic/git/conflicts/ThreeWayEntryConflict.java b/jablib/src/main/java/org/jabref/logic/git/conflicts/ThreeWayEntryConflict.java new file mode 100644 index 00000000000..694d0e210f5 --- /dev/null +++ b/jablib/src/main/java/org/jabref/logic/git/conflicts/ThreeWayEntryConflict.java @@ -0,0 +1,9 @@ +package org.jabref.logic.git.conflicts; + +import org.jabref.model.entry.BibEntry; + +public record ThreeWayEntryConflict( + BibEntry base, + BibEntry local, + BibEntry remote +) { } diff --git a/jablib/src/main/java/org/jabref/logic/git/io/GitBibParser.java b/jablib/src/main/java/org/jabref/logic/git/io/GitBibParser.java new file mode 100644 index 00000000000..74b59691629 --- /dev/null +++ b/jablib/src/main/java/org/jabref/logic/git/io/GitBibParser.java @@ -0,0 +1,25 @@ +package org.jabref.logic.git.io; + +import java.io.IOException; +import java.io.Reader; + +import org.jabref.logic.JabRefException; +import org.jabref.logic.importer.ImportFormatPreferences; +import org.jabref.logic.importer.ParserResult; +import org.jabref.logic.importer.fileformat.BibtexParser; +import org.jabref.model.database.BibDatabaseContext; +import org.jabref.model.util.DummyFileUpdateMonitor; + +public class GitBibParser { + public static BibDatabaseContext parseBibFromGit(String bibContent, ImportFormatPreferences importFormatPreferences) throws JabRefException { + BibtexParser parser = new BibtexParser(importFormatPreferences, new DummyFileUpdateMonitor()); + ParserResult result; + try { + Reader reader = Reader.of(bibContent); + result = parser.parse(reader); + return result.getDatabaseContext(); + } catch (IOException e) { + throw new JabRefException("Failed to parse BibTeX content from Git", e); + } + } +} diff --git a/jablib/src/main/java/org/jabref/logic/git/io/GitFileReader.java b/jablib/src/main/java/org/jabref/logic/git/io/GitFileReader.java new file mode 100644 index 00000000000..f5474f01313 --- /dev/null +++ b/jablib/src/main/java/org/jabref/logic/git/io/GitFileReader.java @@ -0,0 +1,43 @@ +package org.jabref.logic.git.io; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; + +import org.jabref.logic.JabRefException; + +import org.eclipse.jgit.api.Git; +import org.eclipse.jgit.errors.IncorrectObjectTypeException; +import org.eclipse.jgit.errors.MissingObjectException; +import org.eclipse.jgit.lib.ObjectId; +import org.eclipse.jgit.lib.ObjectLoader; +import org.eclipse.jgit.lib.Repository; +import org.eclipse.jgit.revwalk.RevCommit; +import org.eclipse.jgit.revwalk.RevTree; +import org.eclipse.jgit.treewalk.TreeWalk; +import org.jspecify.annotations.NonNull; + +public class GitFileReader { + // Unit test is in the GitSyncServiceTest + public static String readFileFromCommit(Git git, RevCommit commit, @NonNull Path relativePath) throws JabRefException { + // ref: https://github.com/centic9/jgit-cookbook/blob/master/src/main/java/org/dstadler/jgit/api/ReadFileFromCommit.java + // 1. get commit-pointing tree + Repository repository = git.getRepository(); + RevTree commitTree = commit.getTree(); + + // 2. setup TreeWalk + to the target file + try (TreeWalk treeWalk = TreeWalk.forPath(repository, relativePath.toString(), commitTree)) { + if (treeWalk == null) { + throw new JabRefException("File '" + relativePath + "' not found in commit " + commit.getName()); + } + // 3. load blob object + ObjectId objectId = treeWalk.getObjectId(0); + ObjectLoader loader = repository.open(objectId); + return new String(loader.getBytes(), StandardCharsets.UTF_8); + } catch (MissingObjectException | IncorrectObjectTypeException e) { + throw new JabRefException("Git object missing or incorrect when reading file: " + relativePath, e); + } catch (IOException e) { + throw new JabRefException("I/O error while reading file from commit: " + relativePath, e); + } + } +} diff --git a/jablib/src/main/java/org/jabref/logic/git/io/GitFileWriter.java b/jablib/src/main/java/org/jabref/logic/git/io/GitFileWriter.java new file mode 100644 index 00000000000..4f0c8d2076b --- /dev/null +++ b/jablib/src/main/java/org/jabref/logic/git/io/GitFileWriter.java @@ -0,0 +1,40 @@ +package org.jabref.logic.git.io; + +import java.io.IOException; +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; + +import org.jabref.logic.exporter.AtomicFileWriter; +import org.jabref.logic.exporter.BibDatabaseWriter; +import org.jabref.logic.exporter.BibWriter; +import org.jabref.logic.exporter.SelfContainedSaveConfiguration; +import org.jabref.logic.importer.ImportFormatPreferences; +import org.jabref.model.database.BibDatabaseContext; +import org.jabref.model.entry.BibEntryTypesManager; + +public class GitFileWriter { + public static void write(Path file, BibDatabaseContext bibDatabaseContext, ImportFormatPreferences importPrefs) throws IOException { + SelfContainedSaveConfiguration saveConfiguration = new SelfContainedSaveConfiguration(); + Charset encoding = bibDatabaseContext.getMetaData().getEncoding().orElse(StandardCharsets.UTF_8); + + synchronized (bibDatabaseContext) { + try (AtomicFileWriter fileWriter = new AtomicFileWriter(file, encoding, saveConfiguration.shouldMakeBackup())) { + BibWriter bibWriter = new BibWriter(fileWriter, bibDatabaseContext.getDatabase().getNewLineSeparator()); + BibDatabaseWriter writer = new BibDatabaseWriter( + bibWriter, + saveConfiguration, + importPrefs.fieldPreferences(), + importPrefs.citationKeyPatternPreferences(), + new BibEntryTypesManager() + ); + writer.saveDatabase(bibDatabaseContext); + + if (fileWriter.hasEncodingProblems()) { + throw new IOException("Encoding problem detected when saving .bib file: " + + fileWriter.getEncodingProblems().toString()); + } + } + } + } +} diff --git a/jablib/src/main/java/org/jabref/logic/git/io/GitRevisionLocator.java b/jablib/src/main/java/org/jabref/logic/git/io/GitRevisionLocator.java new file mode 100644 index 00000000000..015d73dc05b --- /dev/null +++ b/jablib/src/main/java/org/jabref/logic/git/io/GitRevisionLocator.java @@ -0,0 +1,53 @@ +package org.jabref.logic.git.io; + +import java.io.IOException; + +import org.jabref.logic.JabRefException; + +import org.eclipse.jgit.api.Git; +import org.eclipse.jgit.api.errors.GitAPIException; +import org.eclipse.jgit.lib.ObjectId; +import org.eclipse.jgit.lib.Repository; +import org.eclipse.jgit.revwalk.RevCommit; +import org.eclipse.jgit.revwalk.RevWalk; +import org.eclipse.jgit.revwalk.filter.RevFilter; + +/** + * Find the base/local/remote three commits: + * base = merge-base of HEAD and origin/main + * local = HEAD + * remote = origin/main + */ +public class GitRevisionLocator { + private static final String HEAD = "HEAD"; + private static final String REMOTE = "refs/remotes/origin/main"; + + public RevisionTriple locateMergeCommits(Git git) throws GitAPIException, IOException, JabRefException { + Repository repo = git.getRepository(); + // assumes the remote branch is 'origin/main' + ObjectId headId = repo.resolve(HEAD); + // and uses the default remote tracking reference + // does not support multiple remotes or custom remote branch names so far + ObjectId remoteId = repo.resolve(REMOTE); + if (remoteId == null) { + throw new IllegalStateException("Remote branch missing origin/main."); + } + + try (RevWalk walk = new RevWalk(git.getRepository())) { + RevCommit local = walk.parseCommit(headId); + RevCommit remote = walk.parseCommit(remoteId); + RevCommit base = findMergeBase(repo, local, remote); + + return new RevisionTriple(base, local, remote); + } + } + + public static RevCommit findMergeBase(Repository repo, RevCommit a, RevCommit b) throws IOException { + try (RevWalk walk = new RevWalk(repo)) { + walk.setRevFilter(RevFilter.MERGE_BASE); + walk.markStart(a); + walk.markStart(b); + return walk.next(); + } + } +} diff --git a/jablib/src/main/java/org/jabref/logic/git/io/RevisionTriple.java b/jablib/src/main/java/org/jabref/logic/git/io/RevisionTriple.java new file mode 100644 index 00000000000..8b9d38fdcf1 --- /dev/null +++ b/jablib/src/main/java/org/jabref/logic/git/io/RevisionTriple.java @@ -0,0 +1,13 @@ +package org.jabref.logic.git.io; + +import org.eclipse.jgit.revwalk.RevCommit; + +/** + * Holds the three relevant commits involved in a semantic three-way merge, + * it is a helper value object used exclusively during merge resolution, not part of the domain model + * + * @param base the merge base (common ancestor of local and remote) + * @param local the current local branch tip + * @param remote the tip of the remote tracking branch (typically origin/main) + */ +public record RevisionTriple(RevCommit base, RevCommit local, RevCommit remote) { } diff --git a/jablib/src/main/java/org/jabref/logic/git/merge/GitMergeUtil.java b/jablib/src/main/java/org/jabref/logic/git/merge/GitMergeUtil.java new file mode 100644 index 00000000000..462dfc06a64 --- /dev/null +++ b/jablib/src/main/java/org/jabref/logic/git/merge/GitMergeUtil.java @@ -0,0 +1,51 @@ +package org.jabref.logic.git.merge; + +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; + +import org.jabref.model.database.BibDatabase; +import org.jabref.model.database.BibDatabaseContext; +import org.jabref.model.entry.BibEntry; + +public class GitMergeUtil { + /** + * Replace conflicting entries in the remote context with user-resolved versions. + * + * @param remote the original remote BibDatabaseContext + * @param resolvedEntries list of entries that the user has manually resolved via GUI + * @return a new BibDatabaseContext with resolved entries replacing original ones + */ + public static BibDatabaseContext replaceEntries(BibDatabaseContext remote, List resolvedEntries) { + // 1. make a copy of the remote database + BibDatabase newDatabase = new BibDatabase(); + // 2. build a map of resolved entries by citation key (assuming all resolved entries have keys) + Map resolvedMap = resolvedEntries.stream() + .filter(entry -> entry.getCitationKey().isPresent()) + .collect(Collectors.toMap( + entry -> entry.getCitationKey().get(), + Function.identity())); + + // 3. Iterate original remote entries + for (BibEntry entry : remote.getDatabase().getEntries()) { + String citationKey = entry.getCitationKey().orElse(null); + + if (citationKey != null && resolvedMap.containsKey(citationKey)) { + // Skip: this entry will be replaced + continue; + } + + // Clone the entry and add it to new DB + newDatabase.insertEntry((BibEntry) entry.clone()); + } + + // 4. Insert all resolved entries (cloned for safety) + for (BibEntry resolved : resolvedEntries) { + newDatabase.insertEntry((BibEntry) resolved.clone()); + } + + // 5. Construct a new BibDatabaseContext with this new database and same metadata + return new BibDatabaseContext(newDatabase, remote.getMetaData()); + } +} diff --git a/jablib/src/main/java/org/jabref/logic/git/merge/GitSemanticMergeExecutor.java b/jablib/src/main/java/org/jabref/logic/git/merge/GitSemanticMergeExecutor.java new file mode 100644 index 00000000000..6067cbc8353 --- /dev/null +++ b/jablib/src/main/java/org/jabref/logic/git/merge/GitSemanticMergeExecutor.java @@ -0,0 +1,22 @@ +package org.jabref.logic.git.merge; + +import java.io.IOException; +import java.nio.file.Path; + +import org.jabref.logic.git.model.MergeResult; +import org.jabref.model.database.BibDatabaseContext; + +public interface GitSemanticMergeExecutor { + + /** + * Applies semantic merge of remote into local, based on base version. + * Assumes conflicts have already been resolved (if any). + * + * @param base The common ancestor version + * @param local The current local version (to be updated) + * @param remote The incoming remote version (can be resolved or raw) + * @param bibFilePath The path to the target bib file (used for write-back) + * @return MergeResult object containing merge status + */ + MergeResult merge(BibDatabaseContext base, BibDatabaseContext local, BibDatabaseContext remote, Path bibFilePath) throws IOException; +} diff --git a/jablib/src/main/java/org/jabref/logic/git/merge/GitSemanticMergeExecutorImpl.java b/jablib/src/main/java/org/jabref/logic/git/merge/GitSemanticMergeExecutorImpl.java new file mode 100644 index 00000000000..6a6539bf6e0 --- /dev/null +++ b/jablib/src/main/java/org/jabref/logic/git/merge/GitSemanticMergeExecutorImpl.java @@ -0,0 +1,33 @@ +package org.jabref.logic.git.merge; + +import java.io.IOException; +import java.nio.file.Path; + +import org.jabref.logic.git.conflicts.SemanticConflictDetector; +import org.jabref.logic.git.io.GitFileWriter; +import org.jabref.logic.git.model.MergeResult; +import org.jabref.logic.importer.ImportFormatPreferences; +import org.jabref.model.database.BibDatabaseContext; + +public class GitSemanticMergeExecutorImpl implements GitSemanticMergeExecutor { + + private final ImportFormatPreferences importFormatPreferences; + + public GitSemanticMergeExecutorImpl(ImportFormatPreferences importFormatPreferences) { + this.importFormatPreferences = importFormatPreferences; + } + + @Override + public MergeResult merge(BibDatabaseContext base, BibDatabaseContext local, BibDatabaseContext remote, Path bibFilePath) throws IOException, IOException { + // 1. extract merge plan from base -> remote + MergePlan plan = SemanticConflictDetector.extractMergePlan(base, remote); + + // 2. apply remote changes to local + SemanticMerger.applyMergePlan(local, plan); + + // 3. write back merged content + GitFileWriter.write(bibFilePath, local, importFormatPreferences); + + return MergeResult.success(); + } +} diff --git a/jablib/src/main/java/org/jabref/logic/git/merge/MergePlan.java b/jablib/src/main/java/org/jabref/logic/git/merge/MergePlan.java new file mode 100644 index 00000000000..438b636f8d4 --- /dev/null +++ b/jablib/src/main/java/org/jabref/logic/git/merge/MergePlan.java @@ -0,0 +1,17 @@ +package org.jabref.logic.git.merge; + +import java.util.List; +import java.util.Map; + +import org.jabref.model.entry.BibEntry; +import org.jabref.model.entry.field.Field; + +/** + * A data structure representing the result of semantic diffing between base and remote entries. + * + * @param fieldPatches contain field-level modifications per citation key. citationKey -> field -> newValue (null = delete) + * @param newEntries entries present in remote but not in base/local + */ +public record MergePlan( + Map> fieldPatches, + List newEntries) { } diff --git a/jablib/src/main/java/org/jabref/logic/git/merge/SemanticMerger.java b/jablib/src/main/java/org/jabref/logic/git/merge/SemanticMerger.java new file mode 100644 index 00000000000..efc906391c7 --- /dev/null +++ b/jablib/src/main/java/org/jabref/logic/git/merge/SemanticMerger.java @@ -0,0 +1,63 @@ +package org.jabref.logic.git.merge; + +import java.util.Map; +import java.util.Optional; + +import org.jabref.logic.git.conflicts.SemanticConflictDetector; +import org.jabref.model.database.BibDatabaseContext; +import org.jabref.model.entry.BibEntry; +import org.jabref.model.entry.field.Field; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class SemanticMerger { + private static final Logger LOGGER = LoggerFactory.getLogger(SemanticMerger.class); + + /** + * Implementation-only merge logic: applies changes from remote (relative to base) to local. + * does not check for "modifications" or "conflicts" + * all decisions should be handled in advance by the {@link SemanticConflictDetector} + */ + public static void applyMergePlan(BibDatabaseContext local, MergePlan plan) { + applyPatchToDatabase(local, plan.fieldPatches()); + + for (BibEntry newEntry : plan.newEntries()) { + BibEntry clone = (BibEntry) newEntry.clone(); + local.getDatabase().insertEntry(clone); + LOGGER.debug("Inserted new entry '{}'", newEntry.getCitationKey().orElse("?")); + } + } + + public static void applyPatchToDatabase(BibDatabaseContext local, Map> patchMap) { + for (Map.Entry> entry : patchMap.entrySet()) { + String key = entry.getKey(); + Map fieldPatch = entry.getValue(); + Optional maybeLocalEntry = local.getDatabase().getEntryByCitationKey(key); + + if (maybeLocalEntry.isEmpty()) { + LOGGER.warn("Skip patch: local does not contain entry '{}'", key); + continue; + } + + BibEntry localEntry = maybeLocalEntry.get(); + applyFieldPatchToEntry(localEntry, fieldPatch); + } + } + + public static void applyFieldPatchToEntry(BibEntry localEntry, Map patch) { + for (Map.Entry diff : patch.entrySet()) { + Field field = diff.getKey(); + String newValue = diff.getValue(); + String oldValue = localEntry.getField(field).orElse(null); + + if (newValue == null) { + localEntry.clearField(field); + LOGGER.debug("Cleared field '{}' (was '{}')", field.getName(), oldValue); + } else { + localEntry.setField(field, newValue); + LOGGER.debug("Set field '{}' to '{}', replacing '{}'", field.getName(), newValue, oldValue); + } + } + } +} diff --git a/jablib/src/main/java/org/jabref/logic/git/model/MergeResult.java b/jablib/src/main/java/org/jabref/logic/git/model/MergeResult.java new file mode 100644 index 00000000000..ef2dd4b0391 --- /dev/null +++ b/jablib/src/main/java/org/jabref/logic/git/model/MergeResult.java @@ -0,0 +1,24 @@ +package org.jabref.logic.git.model; + +import java.util.List; + +import org.jabref.logic.bibtex.comparator.BibEntryDiff; + +public record MergeResult(boolean isSuccessful, List conflicts) { + private static boolean SUCCESS = true; + public static MergeResult withConflicts(List conflicts) { + return new MergeResult(!SUCCESS, conflicts); + } + + public static MergeResult success() { + return new MergeResult(SUCCESS, List.of()); + } + + public static MergeResult failure() { + return new MergeResult(false, List.of()); + } + + public boolean hasConflicts() { + return !conflicts.isEmpty(); + } +} diff --git a/jablib/src/main/java/org/jabref/logic/git/status/GitStatusChecker.java b/jablib/src/main/java/org/jabref/logic/git/status/GitStatusChecker.java new file mode 100644 index 00000000000..a80c2ecd49a --- /dev/null +++ b/jablib/src/main/java/org/jabref/logic/git/status/GitStatusChecker.java @@ -0,0 +1,90 @@ +package org.jabref.logic.git.status; + +import java.io.IOException; +import java.nio.file.Path; +import java.util.Optional; + +import org.jabref.logic.git.GitHandler; +import org.jabref.logic.git.io.GitRevisionLocator; + +import org.eclipse.jgit.api.Git; +import org.eclipse.jgit.api.Status; +import org.eclipse.jgit.api.errors.GitAPIException; +import org.eclipse.jgit.lib.ObjectId; +import org.eclipse.jgit.lib.Repository; +import org.eclipse.jgit.revwalk.RevCommit; +import org.eclipse.jgit.revwalk.RevWalk; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * This class is used to determine the status of a Git repository from any given path inside it. + * If no repository is found, it returns a {@link GitStatusSnapshot} with tracking = false. + * Otherwise, it returns a full snapshot including tracking status, sync status, and conflict state. + */ +public class GitStatusChecker { + private static final Logger LOGGER = LoggerFactory.getLogger(GitStatusChecker.class); + + public static GitStatusSnapshot checkStatus(Path anyPathInsideRepo) { + Optional maybeHandler = GitHandler.fromAnyPath(anyPathInsideRepo); + + if (maybeHandler.isEmpty()) { + return new GitStatusSnapshot(false, SyncStatus.UNTRACKED, false, Optional.empty()); + } + GitHandler handler = maybeHandler.get(); + + try (Git git = Git.open(handler.getRepositoryPathAsFile())) { + Repository repo = git.getRepository(); + Status status = git.status().call(); + boolean hasConflict = !status.getConflicting().isEmpty(); + + ObjectId localHead = repo.resolve("HEAD"); + ObjectId remoteHead = repo.resolve("refs/remotes/origin/main"); + SyncStatus syncStatus = determineSyncStatus(repo, localHead, remoteHead); + + return new GitStatusSnapshot( + true, + syncStatus, + hasConflict, + Optional.ofNullable(localHead).map(ObjectId::getName) + ); + } catch (IOException | GitAPIException e) { + LOGGER.warn("Failed to check Git status: {}", e.getMessage(), e); + return new GitStatusSnapshot( + true, + SyncStatus.UNKNOWN, + false, + Optional.empty() + ); + } + } + + private static SyncStatus determineSyncStatus(Repository repo, ObjectId localHead, ObjectId remoteHead) throws IOException { + if (localHead == null || remoteHead == null) { + return SyncStatus.UNKNOWN; + } + + if (localHead.equals(remoteHead)) { + return SyncStatus.UP_TO_DATE; + } + + try (RevWalk walk = new RevWalk(repo)) { + RevCommit localCommit = walk.parseCommit(localHead); + RevCommit remoteCommit = walk.parseCommit(remoteHead); + RevCommit mergeBase = GitRevisionLocator.findMergeBase(repo, localCommit, remoteCommit); + + boolean ahead = !localCommit.equals(mergeBase); + boolean behind = !remoteCommit.equals(mergeBase); + + if (ahead && behind) { + return SyncStatus.DIVERGED; + } else if (ahead) { + return SyncStatus.AHEAD; + } else if (behind) { + return SyncStatus.BEHIND; + } else { + return SyncStatus.UNKNOWN; + } + } + } +} diff --git a/jablib/src/main/java/org/jabref/logic/git/status/GitStatusSnapshot.java b/jablib/src/main/java/org/jabref/logic/git/status/GitStatusSnapshot.java new file mode 100644 index 00000000000..2b28d23ee47 --- /dev/null +++ b/jablib/src/main/java/org/jabref/logic/git/status/GitStatusSnapshot.java @@ -0,0 +1,9 @@ +package org.jabref.logic.git.status; + +import java.util.Optional; + +public record GitStatusSnapshot( + boolean tracking, + SyncStatus syncStatus, + boolean conflict, + Optional lastPulledCommit) { } diff --git a/jablib/src/main/java/org/jabref/logic/git/status/SyncStatus.java b/jablib/src/main/java/org/jabref/logic/git/status/SyncStatus.java new file mode 100644 index 00000000000..fbd3691bacb --- /dev/null +++ b/jablib/src/main/java/org/jabref/logic/git/status/SyncStatus.java @@ -0,0 +1,11 @@ +package org.jabref.logic.git.status; + +public enum SyncStatus { + UP_TO_DATE, // Local and remote are in sync + BEHIND, // Local is behind remote, pull needed + AHEAD, // Local is ahead of remote, push needed + DIVERGED, // Both local and remote have new commits; merge required + CONFLICT, // Merge conflict detected + UNTRACKED, // Not under Git control + UNKNOWN // Status couldn't be determined +} diff --git a/jablib/src/test/java/org/jabref/logic/git/GitHandlerTest.java b/jablib/src/test/java/org/jabref/logic/git/GitHandlerTest.java index fb3ff026bf1..cf3ec0fbdb0 100644 --- a/jablib/src/test/java/org/jabref/logic/git/GitHandlerTest.java +++ b/jablib/src/test/java/org/jabref/logic/git/GitHandlerTest.java @@ -1,29 +1,61 @@ package org.jabref.logic.git; import java.io.IOException; +import java.net.URISyntaxException; import java.nio.file.Files; import java.nio.file.Path; import java.util.Iterator; +import java.util.Optional; import org.eclipse.jgit.api.Git; import org.eclipse.jgit.api.errors.GitAPIException; import org.eclipse.jgit.lib.AnyObjectId; import org.eclipse.jgit.lib.Constants; import org.eclipse.jgit.revwalk.RevCommit; +import org.eclipse.jgit.transport.RefSpec; +import org.eclipse.jgit.transport.URIish; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; class GitHandlerTest { @TempDir Path repositoryPath; + @TempDir + Path remoteRepoPath; + @TempDir + Path clonePath; private GitHandler gitHandler; @BeforeEach - void setUpGitHandler() { + void setUpGitHandler() throws IOException, GitAPIException, URISyntaxException { gitHandler = new GitHandler(repositoryPath); + + Git remoteGit = Git.init() + .setBare(true) + .setDirectory(remoteRepoPath.toFile()) + .call(); + Path testFile = repositoryPath.resolve("initial.txt"); + Files.writeString(testFile, "init"); + + gitHandler.createCommitOnCurrentBranch("Initial commit", false); + + try (Git localGit = Git.open(repositoryPath.toFile())) { + localGit.remoteAdd() + .setName("origin") + .setUri(new URIish(remoteRepoPath.toUri().toString())) + .call(); + + localGit.push() + .setRemote("origin") + .setRefSpecs(new RefSpec("refs/heads/main:refs/heads/main")) + .call(); + } + + Files.writeString(remoteRepoPath.resolve("HEAD"), "ref: refs/heads/main"); } @Test @@ -55,4 +87,36 @@ void createCommitOnCurrentBranch() throws IOException, GitAPIException { void getCurrentlyCheckedOutBranch() throws IOException { assertEquals("main", gitHandler.getCurrentlyCheckedOutBranch()); } + + @Test + void fetchOnCurrentBranch() throws IOException, GitAPIException, URISyntaxException { + try (Git cloneGit = Git.cloneRepository() + .setURI(remoteRepoPath.toUri().toString()) + .setDirectory(clonePath.toFile()) + .call()) { + Files.writeString(clonePath.resolve("another.txt"), "world"); + cloneGit.add().addFilepattern("another.txt").call(); + cloneGit.commit().setMessage("Second commit").call(); + cloneGit.push().call(); + } + + gitHandler.fetchOnCurrentBranch(); + + try (Git git = Git.open(repositoryPath.toFile())) { + assertTrue(git.getRepository().getRefDatabase().hasRefs()); + assertTrue(git.getRepository().exactRef("refs/remotes/origin/main") != null); + } + } + + @Test + void fromAnyPathFindsGitRootFromNestedPath() throws IOException { + Path nested = repositoryPath.resolve("src/org/jabref"); + Files.createDirectories(nested); + + Optional handlerOpt = GitHandler.fromAnyPath(nested); + + assertTrue(handlerOpt.isPresent(), "Expected GitHandler to be created"); + assertEquals(repositoryPath.toRealPath(), handlerOpt.get().repositoryPath.toRealPath(), + "Expected repositoryPath to match Git root"); + } } diff --git a/jablib/src/test/java/org/jabref/logic/git/GitSyncServiceTest.java b/jablib/src/test/java/org/jabref/logic/git/GitSyncServiceTest.java new file mode 100644 index 00000000000..7d927dec951 --- /dev/null +++ b/jablib/src/test/java/org/jabref/logic/git/GitSyncServiceTest.java @@ -0,0 +1,307 @@ +package org.jabref.logic.git; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Optional; + +import org.jabref.logic.git.conflicts.GitConflictResolverStrategy; +import org.jabref.logic.git.conflicts.ThreeWayEntryConflict; +import org.jabref.logic.git.io.GitFileReader; +import org.jabref.logic.git.merge.GitMergeUtil; +import org.jabref.logic.git.model.MergeResult; +import org.jabref.logic.importer.ImportFormatPreferences; +import org.jabref.model.database.BibDatabaseContext; +import org.jabref.model.entry.BibEntry; +import org.jabref.model.entry.field.StandardField; + +import org.eclipse.jgit.api.Git; +import org.eclipse.jgit.lib.PersonIdent; +import org.eclipse.jgit.revwalk.RevCommit; +import org.eclipse.jgit.transport.RefSpec; +import org.eclipse.jgit.transport.URIish; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.mockito.Answers; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class GitSyncServiceTest { + private Path library; + private Path remoteDir; + private Path aliceDir; + private Path bobDir; + private Git aliceGit; + private Git bobGit; + private ImportFormatPreferences importFormatPreferences; + private GitConflictResolverStrategy gitConflictResolverStrategy; + + // These are setup by aliceBobSetting + private RevCommit baseCommit; + private RevCommit aliceCommit; + private RevCommit bobCommit; + + private final PersonIdent alice = new PersonIdent("Alice", "alice@example.org"); + private final PersonIdent bob = new PersonIdent("Bob", "bob@example.org"); + private final String initialContent = """ + @article{a, + author = {don't know the author}, + doi = {xya}, + } + + @article{b, + author = {don't know the author}, + doi = {xyz}, + } + """; + + // Alice modifies a + private final String aliceUpdatedContent = """ + @article{a, + author = {author-a}, + doi = {xya}, + } + + @article{b, + author = {don't know the author}, + doi = {xyz}, + } + """; + + // Bob reorders a and b + private final String bobUpdatedContent = """ + @article{b, + author = {author-b}, + doi = {xyz}, + } + + @article{a, + author = {don't know the author}, + doi = {xya}, + } + """; + + /** + * Creates a commit graph with a base commit, one modification by Alice and one modification by Bob + * 1. Alice commit initial → push to remote + * 2. Bob clone remote -> update b → push + * 3. Alice update a → pull + */ + @BeforeEach + void aliceBobSimple(@TempDir Path tempDir) throws Exception { + importFormatPreferences = mock(ImportFormatPreferences.class, Answers.RETURNS_DEEP_STUBS); + when(importFormatPreferences.bibEntryPreferences().getKeywordSeparator()).thenReturn(','); + gitConflictResolverStrategy = mock(GitConflictResolverStrategy.class); + + // create fake remote repo + remoteDir = tempDir.resolve("remote.git"); + Git remoteGit = Git.init() + .setBare(true) + .setInitialBranch("main") + .setDirectory(remoteDir.toFile()) + .call(); + remoteGit.close(); + + // Alice init local repository + aliceDir = tempDir.resolve("alice"); + aliceGit = Git.init() + .setInitialBranch("main") + .setDirectory(aliceDir.toFile()) + .call(); + + this.library = aliceDir.resolve("library.bib"); + + // Initial commit + baseCommit = writeAndCommit(initialContent, "Initial commit", alice, library, aliceGit); + // Add remote and push to create refs/heads/main in remote + aliceGit.remoteAdd() + .setName("origin") + .setUri(new URIish(remoteDir.toUri().toString())) + .call(); + + aliceGit.push() + .setRemote("origin") + .setRefSpecs(new RefSpec("refs/heads/main:refs/heads/main")) + .call(); + + // Bob clone remote + bobDir = tempDir.resolve("bob"); + bobGit = Git.cloneRepository() + .setURI(remoteDir.toUri().toString()) + .setDirectory(bobDir.toFile()) + .setBranchesToClone(List.of("refs/heads/main")) + .setBranch("main") + .call(); + + Path bobLibrary = bobDir.resolve("library.bib"); + bobCommit = writeAndCommit(bobUpdatedContent, "Exchange a with b", bob, bobLibrary, bobGit); + bobGit.push() + .setRemote("origin") + .setRefSpecs(new RefSpec("refs/heads/main:refs/heads/main")) + .call(); + + // back to Alice's branch, fetch remote + aliceCommit = writeAndCommit(aliceUpdatedContent, "Fix author of a", alice, library, aliceGit); + aliceGit.fetch().setRemote("origin").call(); + + // Debug hint: Show the created git graph on the command line + // git log --graph --oneline --decorate --all --reflog + } + + @Test + void pullTriggersSemanticMergeWhenNoConflicts() throws Exception { + GitHandler gitHandler = new GitHandler(library.getParent()); + GitSyncService syncService = new GitSyncService(importFormatPreferences, gitHandler, gitConflictResolverStrategy); + MergeResult result = syncService.fetchAndMerge(library); + + assertTrue(result.isSuccessful()); + String merged = Files.readString(library); + + String expected = """ + @article{a, + author = {author-a}, + doi = {xya}, + } + + @article{b, + author = {author-b}, + doi = {xyz}, + } + """; + + assertEquals(normalize(expected), normalize(merged)); + } + + @Test + void pushTriggersMergeAndPushWhenNoConflicts() throws Exception { + GitHandler gitHandler = new GitHandler(library.getParent()); + GitSyncService syncService = new GitSyncService(importFormatPreferences, gitHandler, gitConflictResolverStrategy); + syncService.push(library); + + String pushedContent = GitFileReader.readFileFromCommit(aliceGit, aliceGit.log().setMaxCount(1).call().iterator().next(), Path.of("library.bib")); + String expected = """ + @article{a, + author = {author-a}, + doi = {xya}, + } + + @article{b, + author = {author-b}, + doi = {xyz}, + } + """; + + assertEquals(normalize(expected), normalize(pushedContent)); + } + + @Test + void mergeConflictOnSameFieldTriggersDialogAndUsesUserResolution(@TempDir Path tempDir) throws Exception { + // Bob adds entry c + Path bobLibrary = bobDir.resolve("library.bib"); + String bobEntry = """ + @article{b, + author = {author-b}, + doi = {xyz}, + } + @article{a, + author = {author-a}, + doi = {xya}, + } + @article{c, + author = {bob-c}, + title = {Title C}, + } + """; + writeAndCommit(bobEntry, "Bob adds article-c", bob, bobLibrary, bobGit); + bobGit.push().setRemote("origin").call(); + // Alice adds conflicting version of c + String aliceEntry = """ + @article{b, + author = {author-b}, + doi = {xyz}, + } + @article{a, + author = {author-a}, + doi = {xya}, + } + @article{c, + author = {alice-c}, + title = {Title C}, + } + """; + writeAndCommit(aliceEntry, "Alice adds conflicting article-c", alice, library, aliceGit); + aliceGit.fetch().setRemote("origin").call(); + + // Setup mock conflict resolver + GitConflictResolverStrategy resolver = mock(GitConflictResolverStrategy.class); + when(resolver.resolveConflicts(anyList(), any())).thenAnswer(invocation -> { + List conflicts = invocation.getArgument(0); + BibDatabaseContext remote = invocation.getArgument(1); + + ThreeWayEntryConflict conflict = ((List) invocation.getArgument(0)).getFirst(); + // In this test, both Alice and Bob independently added a new entry 'c', so the base is null. + // We simulate conflict resolution by choosing the remote version and modifying the author field. + BibEntry resolved = (BibEntry) conflict.remote().clone(); + resolved.setField(StandardField.AUTHOR, "alice-c + bob-c"); + + BibDatabaseContext merged = GitMergeUtil.replaceEntries(remote, List.of(resolved)); + return Optional.of(merged); + }); + + GitHandler handler = new GitHandler(aliceDir); + GitSyncService service = new GitSyncService(importFormatPreferences, handler, resolver); + MergeResult result = service.fetchAndMerge(library); + + assertTrue(result.isSuccessful()); + String content = Files.readString(library); + assertTrue(content.contains("alice-c + bob-c")); + verify(resolver).resolveConflicts(anyList(), any()); + } + + @Test + void readFromCommits() throws Exception { + String base = GitFileReader.readFileFromCommit(aliceGit, baseCommit, Path.of("library.bib")); + String local = GitFileReader.readFileFromCommit(aliceGit, aliceCommit, Path.of("library.bib")); + String remote = GitFileReader.readFileFromCommit(aliceGit, bobCommit, Path.of("library.bib")); + + assertEquals(initialContent, base); + assertEquals(aliceUpdatedContent, local); + assertEquals(bobUpdatedContent, remote); + } + + @AfterEach + void cleanup() { + if (aliceGit != null) { + aliceGit.close(); + } + if (bobGit != null) { + bobGit.close(); + } + } + + private RevCommit writeAndCommit(String content, String message, PersonIdent author, Path library, Git git) throws Exception { + Files.writeString(library, content, StandardCharsets.UTF_8); + String relativePath = git.getRepository().getWorkTree().toPath().relativize(library).toString(); + git.add().addFilepattern(relativePath).call(); + return git.commit() + .setAuthor(author) + .setMessage(message) + .call(); + } + + private String normalize(String s) { + return s.trim() + .replaceAll("@[aA]rticle", "@article") + .replaceAll("\\s+", "") + .toLowerCase(); + } +} diff --git a/jablib/src/test/java/org/jabref/logic/git/merge/GitMergeUtilTest.java b/jablib/src/test/java/org/jabref/logic/git/merge/GitMergeUtilTest.java new file mode 100644 index 00000000000..f97aecb00ae --- /dev/null +++ b/jablib/src/test/java/org/jabref/logic/git/merge/GitMergeUtilTest.java @@ -0,0 +1,62 @@ +package org.jabref.logic.git.merge; + +import java.util.List; + +import org.jabref.model.database.BibDatabase; +import org.jabref.model.database.BibDatabaseContext; +import org.jabref.model.entry.BibEntry; +import org.jabref.model.entry.field.StandardField; +import org.jabref.model.entry.types.StandardEntryType; +import org.jabref.model.metadata.MetaData; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class GitMergeUtilTest { + @Test + void replaceEntriesReplacesMatchingByCitationKey() { + BibEntry entryA = new BibEntry(StandardEntryType.Article) + .withCitationKey("keyA") + .withField(StandardField.AUTHOR, "original author A"); + + BibEntry entryB = new BibEntry(StandardEntryType.Book) + .withCitationKey("keyB") + .withField(StandardField.AUTHOR, "original author B"); + + BibDatabase originalDatabase = new BibDatabase(); + originalDatabase.insertEntry(entryA); + originalDatabase.insertEntry(entryB); + BibDatabaseContext remoteContext = new BibDatabaseContext(originalDatabase, new MetaData()); + + BibEntry resolvedA = new BibEntry(StandardEntryType.Article) + .withCitationKey("keyA") + .withField(StandardField.AUTHOR, "resolved author A"); + + BibDatabaseContext result = GitMergeUtil.replaceEntries(remoteContext, List.of(resolvedA)); + + List finalEntries = result.getDatabase().getEntries(); + BibEntry resultA = finalEntries.stream().filter(e -> "keyA".equals(e.getCitationKey().orElse(""))).findFirst().orElseThrow(); + BibEntry resultB = finalEntries.stream().filter(e -> "keyB".equals(e.getCitationKey().orElse(""))).findFirst().orElseThrow(); + + assertEquals("resolved author A", resultA.getField(StandardField.AUTHOR).orElse("")); + assertEquals("original author B", resultB.getField(StandardField.AUTHOR).orElse("")); + } + + @Test + void replaceEntriesIgnoresResolvedWithoutCitationKey() { + BibEntry original = new BibEntry(StandardEntryType.Article) + .withCitationKey("key1") + .withField(StandardField.TITLE, "Original Title"); + + BibDatabaseContext remote = new BibDatabaseContext(); + remote.getDatabase().insertEntry(original); + + // Resolved entry without citation key (invalid) + BibEntry resolved = new BibEntry(StandardEntryType.Article) + .withField(StandardField.TITLE, "New Title"); + + BibDatabaseContext result = GitMergeUtil.replaceEntries(remote, List.of(resolved)); + assertEquals("Original Title", result.getDatabase().getEntries().getFirst().getField(StandardField.TITLE).orElse("")); + } +} diff --git a/jablib/src/test/java/org/jabref/logic/git/merge/GitSemanticMergeExecutorTest.java b/jablib/src/test/java/org/jabref/logic/git/merge/GitSemanticMergeExecutorTest.java new file mode 100644 index 00000000000..13ed348013d --- /dev/null +++ b/jablib/src/test/java/org/jabref/logic/git/merge/GitSemanticMergeExecutorTest.java @@ -0,0 +1,65 @@ +package org.jabref.logic.git.merge; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +import javafx.collections.FXCollections; + +import org.jabref.logic.git.model.MergeResult; +import org.jabref.logic.importer.ImportFormatPreferences; +import org.jabref.model.database.BibDatabaseContext; +import org.jabref.model.entry.BibEntry; +import org.jabref.model.entry.field.StandardField; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Answers; + +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class GitSemanticMergeExecutorTest { + + private BibDatabaseContext base; + private BibDatabaseContext local; + private BibDatabaseContext remote; + private ImportFormatPreferences preferences; + private GitSemanticMergeExecutor executor; + private Path tempFile; + + @BeforeEach + public void setup() throws IOException { + base = new BibDatabaseContext(); + local = new BibDatabaseContext(); + remote = new BibDatabaseContext(); + + BibEntry baseEntry = new BibEntry().withCitationKey("Smith2020").withField(StandardField.TITLE, "Old Title"); + BibEntry localEntry = (BibEntry) baseEntry.clone(); + BibEntry remoteEntry = (BibEntry) baseEntry.clone(); + remoteEntry.setField(StandardField.TITLE, "New Title"); + + base.getDatabase().insertEntry(baseEntry); + local.getDatabase().insertEntry(localEntry); + remote.getDatabase().insertEntry(remoteEntry); + + preferences = mock(ImportFormatPreferences.class, Answers.RETURNS_DEEP_STUBS); + when(preferences.fieldPreferences().getNonWrappableFields()) + .thenReturn(FXCollections.emptyObservableList()); + + executor = new GitSemanticMergeExecutorImpl(preferences); + + tempFile = Files.createTempFile("merged", ".bib"); + tempFile.toFile().deleteOnExit(); + } + + @Test + public void successfulMergeAndWrite() throws IOException { + MergeResult result = executor.merge(base, local, remote, tempFile); + + assertTrue(result.isSuccessful()); + String content = Files.readString(tempFile); + assertTrue(content.contains("New Title")); + } +} diff --git a/jablib/src/test/java/org/jabref/logic/git/status/GitStatusCheckerTest.java b/jablib/src/test/java/org/jabref/logic/git/status/GitStatusCheckerTest.java new file mode 100644 index 00000000000..2c1e75a6e47 --- /dev/null +++ b/jablib/src/test/java/org/jabref/logic/git/status/GitStatusCheckerTest.java @@ -0,0 +1,173 @@ +package org.jabref.logic.git.status; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +import org.eclipse.jgit.api.Git; +import org.eclipse.jgit.lib.PersonIdent; +import org.eclipse.jgit.revwalk.RevCommit; +import org.eclipse.jgit.transport.RefSpec; +import org.eclipse.jgit.transport.URIish; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class GitStatusCheckerTest { + private Path localLibrary; + private Git localGit; + private Git remoteGit; + + private final PersonIdent author = new PersonIdent("Tester", "tester@example.org"); + + private final String baseContent = """ + @article{a, + author = {initial-author}, + doi = {xya}, + } + + @article{b, + author = {initial-author}, + doi = {xyz}, + } + """; + + private final String remoteUpdatedContent = """ + @article{a, + author = {initial-author}, + doi = {xya}, + } + + @article{b, + author = {remote-update}, + doi = {xyz}, + } + """; + + private final String localUpdatedContent = """ + @article{a, + author = {local-update}, + doi = {xya}, + } + + @article{b, + author = {initial-author}, + doi = {xyz}, + } + """; + + @BeforeEach + void setup(@TempDir Path tempDir) throws Exception { + Path remoteDir = tempDir.resolve("remote.git"); + remoteGit = Git.init().setBare(true).setDirectory(remoteDir.toFile()).call(); + + Path seedDir = tempDir.resolve("seed"); + Git seedGit = Git.init() + .setInitialBranch("main") + .setDirectory(seedDir.toFile()) + .call(); + Path seedFile = seedDir.resolve("library.bib"); + Files.writeString(seedFile, baseContent, StandardCharsets.UTF_8); + + seedGit.add().addFilepattern("library.bib").call(); + seedGit.commit().setAuthor(author).setMessage("Initial commit").call(); + + seedGit.remoteAdd() + .setName("origin") + .setUri(new URIish(remoteDir.toUri().toString())) + .call(); + seedGit.push() + .setRemote("origin") + .setRefSpecs(new RefSpec("refs/heads/main:refs/heads/main")) + .call(); + Files.writeString(remoteDir.resolve("HEAD"), "ref: refs/heads/main"); + + Path localDir = tempDir.resolve("local"); + localGit = Git.cloneRepository() + .setURI(remoteDir.toUri().toString()) + .setDirectory(localDir.toFile()) + .setBranch("main") + .call(); + + this.localLibrary = localDir.resolve("library.bib"); + } + + @Test + void untrackedStatusWhenNotGitRepo(@TempDir Path tempDir) { + Path nonRepoPath = tempDir.resolve("somefile.bib"); + GitStatusSnapshot snapshot = GitStatusChecker.checkStatus(nonRepoPath); + assertFalse(snapshot.tracking()); + assertEquals(SyncStatus.UNTRACKED, snapshot.syncStatus()); + } + + @Test + void upToDateStatusAfterInitialSync() { + GitStatusSnapshot snapshot = GitStatusChecker.checkStatus(localLibrary); + assertTrue(snapshot.tracking()); + assertEquals(SyncStatus.UP_TO_DATE, snapshot.syncStatus()); + } + + @Test + void behindStatusWhenRemoteHasNewCommit(@TempDir Path tempDir) throws Exception { + Path remoteWork = tempDir.resolve("remoteWork"); + Git remoteClone = Git.cloneRepository() + .setURI(remoteGit.getRepository().getDirectory().toURI().toString()) + .setDirectory(remoteWork.toFile()) + .setBranchesToClone(List.of("refs/heads/main")) + .setBranch("main") + .call(); + Path remoteFile = remoteWork.resolve("library.bib"); + commitFile(remoteClone, remoteUpdatedContent, "Remote update"); + remoteClone.push() + .setRemote("origin") + .setRefSpecs(new RefSpec("refs/heads/main:refs/heads/main")) + .call(); + + localGit.fetch().setRemote("origin").call(); + GitStatusSnapshot snapshot = GitStatusChecker.checkStatus(localLibrary); + assertEquals(SyncStatus.BEHIND, snapshot.syncStatus()); + } + + @Test + void aheadStatusWhenLocalHasNewCommit() throws Exception { + commitFile(localGit, localUpdatedContent, "Local update"); + GitStatusSnapshot snapshot = GitStatusChecker.checkStatus(localLibrary); + assertEquals(SyncStatus.AHEAD, snapshot.syncStatus()); + } + + @Test + void divergedStatusWhenBothSidesHaveCommits(@TempDir Path tempDir) throws Exception { + commitFile(localGit, localUpdatedContent, "Local update"); + + Path remoteWork = tempDir.resolve("remoteWork"); + Git remoteClone = Git.cloneRepository() + .setURI(remoteGit.getRepository().getDirectory().toURI().toString()) + .setDirectory(remoteWork.toFile()) + .setBranchesToClone(List.of("refs/heads/main")) + .setBranch("main") + .call(); + Path remoteFile = remoteWork.resolve("library.bib"); + commitFile(remoteClone, remoteUpdatedContent, "Remote update"); + remoteClone.push() + .setRemote("origin") + .setRefSpecs(new RefSpec("refs/heads/main:refs/heads/main")) + .call(); + + localGit.fetch().setRemote("origin").call(); + GitStatusSnapshot snapshot = GitStatusChecker.checkStatus(localLibrary); + assertEquals(SyncStatus.DIVERGED, snapshot.syncStatus()); + } + + private RevCommit commitFile(Git git, String content, String message) throws Exception { + Path file = git.getRepository().getWorkTree().toPath().resolve("library.bib"); + Files.writeString(file, content, StandardCharsets.UTF_8); + String relativePath = git.getRepository().getWorkTree().toPath().relativize(file).toString(); + git.add().addFilepattern(relativePath).call(); + return git.commit().setAuthor(author).setMessage(message).call(); + } +} diff --git a/jablib/src/test/java/org/jabref/logic/git/util/GitBibParserTest.java b/jablib/src/test/java/org/jabref/logic/git/util/GitBibParserTest.java new file mode 100644 index 00000000000..cb9434527af --- /dev/null +++ b/jablib/src/test/java/org/jabref/logic/git/util/GitBibParserTest.java @@ -0,0 +1,79 @@ +package org.jabref.logic.git.util; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Optional; + +import org.jabref.logic.git.io.GitBibParser; +import org.jabref.logic.git.io.GitFileReader; +import org.jabref.logic.importer.ImportFormatPreferences; +import org.jabref.model.database.BibDatabaseContext; +import org.jabref.model.entry.BibEntry; +import org.jabref.model.entry.field.FieldFactory; + +import org.eclipse.jgit.api.Git; +import org.eclipse.jgit.lib.PersonIdent; +import org.eclipse.jgit.revwalk.RevCommit; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.mockito.Answers; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class GitBibParserTest { + private Git git; + private Path library; + private RevCommit commit; + + private final PersonIdent alice = new PersonIdent("Alice", "alice@example.org"); + private final String bibContent = """ + @article{test2025, + author = {Alice}, + title = {Test Title}, + year = {2025} + } + """; + + private ImportFormatPreferences importFormatPreferences; + + @BeforeEach + void setup(@TempDir Path tempDir) throws Exception { + importFormatPreferences = mock(ImportFormatPreferences.class, Answers.RETURNS_DEEP_STUBS); + when(importFormatPreferences.bibEntryPreferences().getKeywordSeparator()).thenReturn(','); + + git = Git.init() + .setDirectory(tempDir.toFile()) + .setInitialBranch("main") + .call(); + + library = tempDir.resolve("library.bib"); + commit = writeAndCommit(bibContent, "Initial commit", alice, library, git); + } + + @Test + void parsesBibContentFromCommit() throws Exception { + String rawBib = GitFileReader.readFileFromCommit(git, commit, Path.of("library.bib")); + + BibDatabaseContext context = GitBibParser.parseBibFromGit(rawBib, importFormatPreferences); + + List entries = context.getEntries(); + assertEquals(1, entries.size()); + + BibEntry entry = entries.getFirst(); + assertEquals(Optional.of("Alice"), entry.getField(FieldFactory.parseField("author"))); + assertEquals(Optional.of("Test Title"), entry.getField(FieldFactory.parseField("title"))); + assertEquals(Optional.of("2025"), entry.getField(FieldFactory.parseField("year"))); + assertEquals(Optional.of("test2025"), entry.getCitationKey()); + } + + private RevCommit writeAndCommit(String content, String message, PersonIdent author, Path library, Git git) throws Exception { + Files.writeString(library, content, StandardCharsets.UTF_8); + git.add().addFilepattern(library.getFileName().toString()).call(); + return git.commit().setAuthor(author).setMessage(message).call(); + } +} diff --git a/jablib/src/test/java/org/jabref/logic/git/util/GitFileWriterTest.java b/jablib/src/test/java/org/jabref/logic/git/util/GitFileWriterTest.java new file mode 100644 index 00000000000..2cd9bd0924c --- /dev/null +++ b/jablib/src/test/java/org/jabref/logic/git/util/GitFileWriterTest.java @@ -0,0 +1,53 @@ +package org.jabref.logic.git.util; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +import org.jabref.logic.git.io.GitBibParser; +import org.jabref.logic.git.io.GitFileWriter; +import org.jabref.logic.importer.ImportFormatPreferences; +import org.jabref.model.database.BibDatabaseContext; +import org.jabref.model.entry.BibEntry; +import org.jabref.model.entry.field.StandardField; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Answers; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class GitFileWriterTest { + private ImportFormatPreferences importFormatPreferences; + @BeforeEach + void setUp() { + importFormatPreferences = mock(ImportFormatPreferences.class, Answers.RETURNS_DEEP_STUBS); + when(importFormatPreferences.bibEntryPreferences().getKeywordSeparator()).thenReturn(','); + } + + @Test + void writeThenReadBack() throws Exception { + BibDatabaseContext inputDatabaseContext = GitBibParser.parseBibFromGit( + """ + @article{a, + author = {Alice}, + title = {Test} + } + """, importFormatPreferences); + + Path tempFile = Files.createTempFile("tempgitwriter", ".bib"); + + GitFileWriter.write(tempFile, inputDatabaseContext, importFormatPreferences); + + BibDatabaseContext outputCtx = GitBibParser.parseBibFromGit(Files.readString(tempFile), importFormatPreferences); + + List inputEntries = inputDatabaseContext.getDatabase().getEntries(); + List outputEntries = outputCtx.getDatabase().getEntries(); + + assertEquals(inputEntries.size(), outputEntries.size()); + assertEquals(inputEntries.getFirst().getCitationKey(), outputEntries.getFirst().getCitationKey()); + assertEquals(inputEntries.getFirst().getField(StandardField.AUTHOR), outputEntries.getFirst().getField(StandardField.AUTHOR)); + } +} diff --git a/jablib/src/test/java/org/jabref/logic/git/util/GitRevisionLocatorTest.java b/jablib/src/test/java/org/jabref/logic/git/util/GitRevisionLocatorTest.java new file mode 100644 index 00000000000..a5e5d2d2b4c --- /dev/null +++ b/jablib/src/test/java/org/jabref/logic/git/util/GitRevisionLocatorTest.java @@ -0,0 +1,53 @@ +package org.jabref.logic.git.util; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; + +import org.jabref.logic.git.io.GitRevisionLocator; +import org.jabref.logic.git.io.RevisionTriple; + +import org.eclipse.jgit.api.Git; +import org.eclipse.jgit.revwalk.RevCommit; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class GitRevisionLocatorTest { + @Test + void locateMergeCommits(@TempDir Path tempDir) throws Exception { + Path bibFile = tempDir.resolve("library.bib"); + Git git = Git.init().setDirectory(tempDir.toFile()).setInitialBranch("main").call(); + + // create base commit + Files.writeString(bibFile, "@article{a, author = {x}}", StandardCharsets.UTF_8); + git.add().addFilepattern("library.bib").call(); + RevCommit base = git.commit().setMessage("base").call(); + + // create local (HEAD) + Files.writeString(bibFile, "@article{a, author = {local}}", StandardCharsets.UTF_8); + git.add().addFilepattern("library.bib").call(); + RevCommit local = git.commit().setMessage("local").call(); + + // create remote branch and commit + git.checkout().setName("remote").setCreateBranch(true).setStartPoint(base).call(); + Files.writeString(bibFile, "@article{a, author = {remote}}", StandardCharsets.UTF_8); + git.add().addFilepattern("library.bib").call(); + RevCommit remote = git.commit().setMessage("remote").call(); + + // restore HEAD to local + git.checkout().setName("main").call(); + + // simulate fake remote ref + git.getRepository().updateRef("refs/remotes/origin/main").link("refs/heads/remote"); + + // test locator + GitRevisionLocator locator = new GitRevisionLocator(); + RevisionTriple triple = locator.locateMergeCommits(git); + + assertEquals(base.getId(), triple.base().getId()); + assertEquals(local.getId(), triple.local().getId()); + assertEquals(remote.getId(), triple.remote().getId()); + } +} diff --git a/jablib/src/test/java/org/jabref/logic/git/util/SemanticConflictDetectorTest.java b/jablib/src/test/java/org/jabref/logic/git/util/SemanticConflictDetectorTest.java new file mode 100644 index 00000000000..ec1c3c79639 --- /dev/null +++ b/jablib/src/test/java/org/jabref/logic/git/util/SemanticConflictDetectorTest.java @@ -0,0 +1,519 @@ +package org.jabref.logic.git.util; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import java.util.stream.Stream; + +import org.jabref.logic.git.conflicts.SemanticConflictDetector; +import org.jabref.logic.git.conflicts.ThreeWayEntryConflict; +import org.jabref.logic.git.io.GitBibParser; +import org.jabref.logic.git.io.GitFileReader; +import org.jabref.logic.git.merge.MergePlan; +import org.jabref.logic.importer.ImportFormatPreferences; +import org.jabref.model.database.BibDatabaseContext; +import org.jabref.model.entry.field.Field; +import org.jabref.model.entry.field.StandardField; + +import org.eclipse.jgit.api.Git; +import org.eclipse.jgit.lib.PersonIdent; +import org.eclipse.jgit.revwalk.RevCommit; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.mockito.Answers; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class SemanticConflictDetectorTest { + private Git git; + private Path library; + private final PersonIdent alice = new PersonIdent("Alice", "alice@example.org"); + private final PersonIdent bob = new PersonIdent("Bob", "bob@example.org"); + + private ImportFormatPreferences importFormatPreferences; + + @BeforeEach + void setup(@TempDir Path tempDir) throws Exception { + importFormatPreferences = mock(ImportFormatPreferences.class, Answers.RETURNS_DEEP_STUBS); + when(importFormatPreferences.bibEntryPreferences().getKeywordSeparator()).thenReturn(','); + + git = Git.init() + .setDirectory(tempDir.toFile()) + .setInitialBranch("main") + .call(); + + library = tempDir.resolve("library.bib"); + } + + @ParameterizedTest(name = "{0}") + @MethodSource("provideConflictCases") + void semanticConflicts(String description, String base, String local, String remote, boolean expectConflict) throws Exception { + RevCommit baseCommit = writeAndCommit(base, "base", alice); + RevCommit localCommit = writeAndCommit(local, "local", alice); + RevCommit remoteCommit = writeAndCommit(remote, "remote", bob); + + BibDatabaseContext baseDatabaseContext = parse(baseCommit); + BibDatabaseContext localDatabaseContext = parse(localCommit); + BibDatabaseContext remoteDatabaseContext = parse(remoteCommit); + + List diffs = SemanticConflictDetector.detectConflicts(baseDatabaseContext, localDatabaseContext, remoteDatabaseContext); + + if (expectConflict) { + assertEquals(1, diffs.size(), "Expected a conflict but found none"); + } else { + assertTrue(diffs.isEmpty(), "Expected no conflict but found some"); + } + } + + private BibDatabaseContext parse(RevCommit commit) throws Exception { + String content = GitFileReader.readFileFromCommit(git, commit, Path.of("library.bib")); + return GitBibParser.parseBibFromGit(content, importFormatPreferences); + } + + private RevCommit writeAndCommit(String content, String message, PersonIdent author, Path file, Git git) throws Exception { + Files.writeString(file, content, StandardCharsets.UTF_8); + git.add().addFilepattern(file.getFileName().toString()).call(); + return git.commit().setAuthor(author).setMessage(message).call(); + } + + private RevCommit writeAndCommit(String content, String message, PersonIdent author) throws Exception { + return writeAndCommit(content, message, author, library, git); + } + + static Stream provideConflictCases() { + return Stream.of( + Arguments.of("T1 - remote changed a field, local unchanged", + """ + @article{a, + author = {lala}, + doi = {xya}, + } + """, + """ + @article{a, + author = {lala}, + doi = {xya}, + } + """, + """ + @article{a, + author = {bob}, + doi = {xya}, + } + """, + false + ), + Arguments.of("T2 - local changed a field, remote unchanged", + """ + @article{a, + author = {lala}, + doi = {xya}, + } + """, + """ + @article{a, + author = {alice}, + doi = {xya}, + } + """, + """ + @article{a, + author = {lala}, + doi = {xya}, + } + """, + false + ), + Arguments.of("T3 - both changed to same value", + """ + @article{a, + author = {lala}, + doi = {xya}, + } + """, + """ + @article{a, + author = {bob}, + doi = {xya}, + } + """, + """ + @article{a, + author = {bob}, + doi = {xya}, + } + """, + false + ), + Arguments.of("T4 - both changed to different values", + """ + @article{a, + author = {lala}, + doi = {xya}, + } + """, + """ + @article{a, + author = {alice}, + doi = {xya}, + } + """, + """ + @article{a, + author = {bob}, + doi = {xya}, + } + """, + true + ), + Arguments.of("T5 - local deleted field, remote changed it", + """ + @article{a, + author = {lala}, + doi = {xya}, + } + """, + """ + @article{a, + } + """, + """ + @article{a, + author = {bob}, + doi = {xya}, + } + """, + true + ), + Arguments.of("T6 - local changed, remote deleted", + """ + @article{a, + author = {lala}, + doi = {xya}, + } + """, + """ + @article{a, + author = {alice}, + doi = {xya}, + } + """, + """ + @article{a, + } + """, + true + ), + Arguments.of("T7 - remote deleted, local unchanged", + """ + @article{a, + author = {lala}, + doi = {xya}, + } + """, + """ + @article{a, + author = {lala}, + doi = {xya}, + } + """, + """ + @article{a, + } + """, + false + ), + Arguments.of("T8 - local changed field A, remote changed field B", + """ + @article{a, + author = {lala}, + doi = {xya}, + } + """, + """ + @article{a, + author = {alice}, + doi = {xya}, + } + """, + """ + @article{a, + author = {lala}, + doi = {xyz}, + } + """, + false + ), + Arguments.of("T9 - field order changed only", + """ + @article{a, + author = {lala}, + doi = {xya}, + } + """, + """ + @article{a, + doi = {xya}, + author = {lala}, + } + """, + """ + @article{a, + author = {lala}, + doi = {xya}, + } + """, + false + ), + Arguments.of("T10 - local changed entry a, remote changed entry b", + """ + @article{a, + author = {lala}, + doi = {xya}, + } + + @article{b, + author = {lala}, + doi = {xyz}, + } + """, + """ + @article{a, + author = {author-a}, + doi = {xya}, + } + @article{b, + author = {lala}, + doi = {xyz}, + } + """, + """ + @article{b, + author = {author-b}, + doi = {xyz}, + } + + @article{a, + author = {lala}, + doi = {xya}, + } + """, + false + ), + Arguments.of("T11 - remote added field, local unchanged", + """ + @article{a, + author = {lala}, + doi = {xya}, + } + """, + """ + @article{a, + author = {lala}, + doi = {xya}, + } + """, + """ + @article{a, + author = {lala}, + doi = {xya}, + year = {2025}, + } + """, + false + ), + Arguments.of("T12 - both added same field with different values", + """ + @article{a, + author = {lala}, + doi = {xya}, + } + """, + """ + @article{a, + author = {lala}, + doi = {xya}, + year = {2023}, + } + """, + """ + @article{a, + author = {lala}, + doi = {xya}, + year = {2025}, + } + """, + true + ), + Arguments.of("T13 - local added field, remote unchanged", + """ + @article{a, + author = {lala}, + doi = {xya}, + } + """, + """ + @article{a, + author = {lala}, + doi = {newfield}, + } + """, + """ + @article{a, + author = {lala}, + doi = {xya}, + } + """, + false + ), + Arguments.of("T14 - both added same field with same value", + """ + @article{a, + author = {lala}, + doi = {xya}, + } + """, + """ + @article{a, + author = {lala}, + doi = {value}, + } + """, + """ + @article{a, + author = {lala}, + doi = {value}, + } + """, + false + ), + Arguments.of("T15 - both added same field with different values", + """ + @article{a, + author = {lala}, + doi = {xya}, + } + """, + """ + @article{a, + author = {lala}, + doi = {value1}, + } + """, + """ + @article{a, + author = {lala}, + doi = {value2}, + } + """, + true + ), + Arguments.of("T16 - both sides added entry a with different values", + "", + """ + @article{a, + author = {alice}, + doi = {xya}, + } + """, + """ + @article{a, + author = {bob}, + doi = {xya}, + } + """, + true + ), + Arguments.of("T17 - both added same content", + "", + """ + @article{a, + author = {same}, + doi = {123}, + } + """, + """ + @article{a, + author = {same}, + doi = {123}, + } + """, + false + ) + ); + } + + @Test + void extractMergePlanT10OnlyRemoteChangedEntryB() throws Exception { + String base = """ + @article{a, + author = {lala}, + doi = {xya}, + } + @article{b, + author = {lala}, + doi = {xyz}, + } + """; + String remote = """ + @article{b, + author = {author-b}, + doi = {xyz}, + } + @article{a, + author = {lala}, + doi = {xya}, + } + """; + + RevCommit baseCommit = writeAndCommit(base, "base", alice); + RevCommit remoteCommit = writeAndCommit(remote, "remote", bob); + BibDatabaseContext baseDatabaseContext = parse(baseCommit); + BibDatabaseContext remoteDatabaseContext = parse(remoteCommit); + + MergePlan plan = SemanticConflictDetector.extractMergePlan(baseDatabaseContext, remoteDatabaseContext); + + assertEquals(1, plan.fieldPatches().size()); + assertTrue(plan.fieldPatches().containsKey("b")); + + Map patch = plan.fieldPatches().get("b"); + assertEquals("author-b", patch.get(StandardField.AUTHOR)); + } + + @Test + void extractMergePlanT11RemoteAddsField() throws Exception { + String base = """ + @article{a, + author = {lala}, + doi = {xya}, + } + """; + String remote = """ + @article{a, + author = {lala}, + doi = {xya}, + year = {2025}, + } + """; + + RevCommit baseCommit = writeAndCommit(base, "base", alice); + RevCommit remoteCommit = writeAndCommit(remote, "remote", bob); + BibDatabaseContext baseDatabaseContext = parse(baseCommit); + BibDatabaseContext remoteDatabaseContext = parse(remoteCommit); + + MergePlan plan = SemanticConflictDetector.extractMergePlan(baseDatabaseContext, remoteDatabaseContext); + + assertEquals(1, plan.fieldPatches().size()); + Map patch = plan.fieldPatches().get("a"); + assertEquals("2025", patch.get(StandardField.YEAR)); + } +} diff --git a/jablib/src/test/java/org/jabref/logic/git/util/SemanticMergerTest.java b/jablib/src/test/java/org/jabref/logic/git/util/SemanticMergerTest.java new file mode 100644 index 00000000000..dad6a29f16e --- /dev/null +++ b/jablib/src/test/java/org/jabref/logic/git/util/SemanticMergerTest.java @@ -0,0 +1,114 @@ +package org.jabref.logic.git.util; + +import java.util.stream.Stream; + +import org.jabref.logic.git.conflicts.SemanticConflictDetector; +import org.jabref.logic.git.io.GitBibParser; +import org.jabref.logic.git.merge.MergePlan; +import org.jabref.logic.git.merge.SemanticMerger; +import org.jabref.logic.importer.ImportFormatPreferences; +import org.jabref.model.database.BibDatabaseContext; +import org.jabref.model.entry.BibEntry; +import org.jabref.model.entry.field.StandardField; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.mockito.Answers; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class SemanticMergerTest { + private ImportFormatPreferences importFormatPreferences; + + @BeforeEach + void setup() { + importFormatPreferences = mock(ImportFormatPreferences.class, Answers.RETURNS_DEEP_STUBS); + when(importFormatPreferences.bibEntryPreferences().getKeywordSeparator()).thenReturn(','); + } + + @ParameterizedTest(name = "Database patch: {0}") + @MethodSource("provideDatabasePatchCases") + void patchDatabase(String description, String base, String local, String remote, String expectedAuthor) throws Exception { + BibDatabaseContext baseDatabaseContext = GitBibParser.parseBibFromGit(base, importFormatPreferences); + BibDatabaseContext localDatabaseContext = GitBibParser.parseBibFromGit(local, importFormatPreferences); + BibDatabaseContext remoteDatabaseContext = GitBibParser.parseBibFromGit(remote, importFormatPreferences); + + MergePlan plan = SemanticConflictDetector.extractMergePlan(baseDatabaseContext, remoteDatabaseContext); + SemanticMerger.applyMergePlan(localDatabaseContext, plan); + + BibEntry patched = localDatabaseContext.getDatabase().getEntryByCitationKey("a").orElseThrow(); + assertEquals(expectedAuthor, patched.getField(StandardField.AUTHOR).orElse(null)); + } + + static Stream provideDatabasePatchCases() { + return Stream.of( + Arguments.of("T1 - remote changed a field, local unchanged", + """ + @article{a, + author = {lala}, + doi = {xya}, + } + """, + """ + @article{a, + author = {lala}, + doi = {xya}, + } + """, + """ + @article{a, + author = {bob}, + doi = {xya}, + } + """, + "bob" + ), + Arguments.of("T2 - local changed a field, remote unchanged", + """ + @article{a, + author = {lala}, + doi = {xya}, + } + """, + """ + @article{a, + author = {alice}, + doi = {xya}, + } + """, + """ + @article{a, + author = {lala}, + doi = {xya}, + } + """, + "alice" + ), + Arguments.of("T3 - both changed to same value", + """ + @article{a, + author = {lala}, + doi = {xya}, + } + """, + """ + @article{a, + author = {bob}, + doi = {xya}, + } + """, + """ + @article{a, + author = {bob}, + doi = {xya}, + } + """, + "bob" + ) + ); + } +}