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..aa624c8cf85 --- /dev/null +++ b/docs/code-howtos/git.md @@ -0,0 +1,105 @@ +# Git + +## Why Semantic Merge? + +In JabRef, we aim to minimize user interruptions when collaborating on the same `.bib` library file using Git. To achieve this, we go beyond Git’s default line-based syntactic merging and implement our own semantic merge logic that understands the structure of BibTeX entries. + +This means: + +* Even if Git detects conflicting lines, +* JabRef is able to recognize that both sides are editing the same BibTeX entry, +* And determine—at the field level—whether there is an actual semantic conflict. + +## Merge Example + +The following example illustrates a case where Git detects a conflict, but JabRef is able to resolve it automatically. + +### Base Version + +```bibtex +@article{a, + author = {don't know the author}, + doi = {xya}, +} + +@article{b, + author = {don't know the author}, + doi = {xyz}, +} +``` + +### Bob's Side + +Bob reorders the entries and updates the author field of entry b: + +```bibtex +@article{b, + author = {author-b}, + doi = {xyz}, +} + +@article{a, + author = {don't know the author}, + doi = {xya}, +} +``` + +### Alice's Side + +Alice modifies the author field of entry a: + +```bibtex +@article{a, + author = {author-a}, + doi = {xya}, +} + +@article{b, + author = {don't know the author}, + doi = {xyz}, +} +``` + +### Merge Outcome + +When Alice runs git pull, Git sees that both branches have modified overlapping lines (due to reordering and content changes) and reports a syntactic conflict. + +However, JabRef is able to analyze the entries and determine that: + +* Entry a was modified only by Alice. +* Entry b was modified only by Bob. +* There is no conflict at the field level. +* The order of entries in the file does not affect BibTeX semantics. + +Therefore, JabRef performs an automatic merge without requiring manual conflict resolution. + +## Related Test Cases + +The semantic conflict detection and merge resolution logic is covered by: + +* `org.jabref.logic.git.util.SemanticMergerTest#patchDatabase` +* `org.jabref.logic.git.util.SemanticConflictDetectorTest#semanticConflicts`. + +## Conflict Scenarios + +The following table describes when semantic merge in JabRef should consider a situation as conflict or not during a three-way merge. + +| ID | Base | Local Change | Remote Change | Result | +|------|----------------------------|------------------------------------|------------------------------------|--------| +| T1 | Field present | (unchanged) | Field modified | No conflict. The local version remained unchanged, so the remote change can be safely applied. | +| T2 | Field present | Field modified | (unchanged) | No conflict. The remote version did not touch the field, so the local change is preserved. | +| T3 | Field present | Field changed to same value | Field changed to same value | No conflict. Although both sides changed the field, the result is identical—therefore, no conflict. | +| T4 | Field present | Field changed to A | Field changed to B | Conflict. This is a true semantic conflict that requires resolution. | +| T5 | Field present | Field deleted | Field modified | Conflict. One side deleted the field while the other updated it—this is contradictory. | +| T6 | Field present | Field modified | Field deleted | Conflict. Similar to T5, one side deletes, the other edits—this is a conflict. | +| T7 | Field present | (unchanged) | Field deleted | No conflict. Local did not modify anything, so remote deletion is accepted. | +| T8 | Entry with fields A and B | Field A modified | Field B modified | No conflict. Changes are on separate fields, so they can be merged safely. | +| T9 | Entry with fields A and B | Field order changed | Field order changed differently | No conflict. Field order is not semantically meaningful, so no conflict is detected. | +| T10 | Entries A and B | Entry A modified | Entry B modified | No conflict. Modifications are on different entries, which are always safe to merge. | +| T11 | Entry with existing fields | (unchanged) | New field added | No conflict. Remote addition can be applied without issues. | +| T12 | Entry with existing fields | New field added with value A | New field added with value B | Conflict. One side added while the other side modified—there is a semantic conflict. | +| T13 | Entry with existing fields | New field added | (unchanged) | No conflict. Safe to preserve the local addition. | +| T14 | Entry with existing fields | New field added with value A | New field added with value A | No conflict. Even though both sides added it, the value is the same—no need for resolution. | +| T15 | Entry with existing fields | New field added with value A | New field added with value B | Conflict. The same field is introduced with different values, which creates a conflict. | +| T16 | (entry not present) | New entry with author A | New entry with author B | Conflict. Both sides created a new entry with the same citation key, but the fields differ. | +| T17 | (entry not present) | New entry with identical fields | New entry with identical fields | 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..3a47ca04ea1 --- /dev/null +++ b/jabgui/src/main/java/org/jabref/gui/git/GitConflictResolverDialog.java @@ -0,0 +1,44 @@ +package org.jabref.gui.git; + +import java.util.Optional; + +import org.jabref.gui.DialogService; +import org.jabref.gui.mergeentries.threewaymerge.MergeEntriesDialog; +import org.jabref.gui.mergeentries.threewaymerge.ShowDiffConfig; +import org.jabref.gui.mergeentries.threewaymerge.diffhighlighter.DiffHighlighter; +import org.jabref.gui.mergeentries.threewaymerge.toolbar.ThreeWayMergeToolbar; +import org.jabref.gui.preferences.GuiPreferences; +import org.jabref.logic.git.conflicts.ThreeWayEntryConflict; +import org.jabref.logic.l10n.Localization; +import org.jabref.model.entry.BibEntry; + +/// A wrapper around {@link MergeEntriesDialog} for Git feature +/// +/// 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(); + + MergeEntriesDialog dialog = new MergeEntriesDialog(local, remote, preferences); + dialog.setLeftHeaderText(Localization.lang("Local")); + dialog.setRightHeaderText(Localization.lang("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..5087d4d26c6 --- /dev/null +++ b/jabgui/src/main/java/org/jabref/gui/git/GitPullAction.java @@ -0,0 +1,116 @@ +package org.jabref.gui.git; + +import java.io.IOException; +import java.nio.file.Path; +import java.util.Optional; + +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.merge.GitSemanticMergeExecutor; +import org.jabref.logic.git.merge.GitSemanticMergeExecutorImpl; +import org.jabref.logic.l10n.Localization; +import org.jabref.logic.util.BackgroundTask; +import org.jabref.logic.util.TaskExecutor; +import org.jabref.model.database.BibDatabaseContext; + +import org.eclipse.jgit.api.errors.GitAPIException; + +public class GitPullAction extends SimpleCommand { + + private final DialogService dialogService; + private final StateManager stateManager; + private final GuiPreferences guiPreferences; + private final TaskExecutor taskExecutor; + + public GitPullAction(DialogService dialogService, + StateManager stateManager, + GuiPreferences guiPreferences, + TaskExecutor taskExecutor) { + this.dialogService = dialogService; + this.stateManager = stateManager; + this.guiPreferences = guiPreferences; + this.taskExecutor = taskExecutor; + } + + @Override + public void execute() { + Optional activeDatabaseOpt = stateManager.getActiveDatabase(); + if (activeDatabaseOpt.isEmpty()) { + dialogService.showErrorDialogAndWait( + Localization.lang("No library open"), + Localization.lang("Please open a library before pulling.") + ); + return; + } + + BibDatabaseContext activeDatabase = activeDatabaseOpt.get(); + Optional bibFilePathOpt = activeDatabase.getDatabasePath(); + if (bibFilePathOpt.isEmpty()) { + dialogService.showErrorDialogAndWait( + Localization.lang("No library file path"), + Localization.lang("Cannot pull from Git: No file is associated with this library.") + ); + return; + } + + Path bibFilePath = bibFilePathOpt.get(); + GitHandler handler = new GitHandler(bibFilePath.getParent()); + GitConflictResolverDialog dialog = new GitConflictResolverDialog(dialogService, guiPreferences); + GitConflictResolverStrategy resolver = new GuiGitConflictResolverStrategy(dialog); + GitSemanticMergeExecutor mergeExecutor = new GitSemanticMergeExecutorImpl(guiPreferences.getImportFormatPreferences()); + + GitSyncService syncService = new GitSyncService(guiPreferences.getImportFormatPreferences(), handler, resolver, mergeExecutor); + GitStatusViewModel statusViewModel = new GitStatusViewModel(stateManager, bibFilePath); + GitPullViewModel viewModel = new GitPullViewModel(syncService, statusViewModel); + + BackgroundTask + .wrap(() -> viewModel.pull()) + .onSuccess(result -> { + if (result.isSuccessful()) { + dialogService.showInformationDialogAndWait( + Localization.lang("Git Pull"), + Localization.lang("Successfully merged and updated.") + ); + } else { + dialogService.showWarningDialogAndWait( + Localization.lang("Git Pull"), + Localization.lang("Merge completed with conflicts.") + ); + } + }) + .onFailure(ex -> { + if (ex instanceof JabRefException e) { + dialogService.showErrorDialogAndWait( + Localization.lang("Git Pull Failed"), + e.getLocalizedMessage(), + e + ); + } else if (ex instanceof GitAPIException e) { + dialogService.showErrorDialogAndWait( + Localization.lang("Git Pull Failed"), + Localization.lang("An unexpected Git error occurred: %0", e.getLocalizedMessage()), + e + ); + } else if (ex instanceof IOException e) { + dialogService.showErrorDialogAndWait( + Localization.lang("Git Pull Failed"), + Localization.lang("I/O error: %0", e.getLocalizedMessage()), + e + ); + } else { + dialogService.showErrorDialogAndWait( + Localization.lang("Git Pull Failed"), + Localization.lang("Unexpected error: %0", ex.getLocalizedMessage()), + ex + ); + } + }) + .executeWith(taskExecutor); + } +} 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..5d76cf266a2 --- /dev/null +++ b/jabgui/src/main/java/org/jabref/gui/git/GitPullViewModel.java @@ -0,0 +1,44 @@ +package org.jabref.gui.git; + +import java.io.IOException; +import java.nio.file.Path; +import java.util.Optional; + +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.jabref.logic.l10n.Localization; +import org.jabref.model.database.BibDatabaseContext; + +import org.eclipse.jgit.api.errors.GitAPIException; + +public class GitPullViewModel extends AbstractViewModel { + private final GitSyncService syncService; + private final GitStatusViewModel gitStatusViewModel; + + public GitPullViewModel(GitSyncService syncService, GitStatusViewModel gitStatusViewModel) { + this.syncService = syncService; + this.gitStatusViewModel = gitStatusViewModel; + } + + public MergeResult pull() throws IOException, GitAPIException, JabRefException { + Optional databaseContextOpt = gitStatusViewModel.getDatabaseContext(); + if (databaseContextOpt.isEmpty()) { + throw new JabRefException(Localization.lang("Cannot pull: No active BibDatabaseContext.")); + } + + BibDatabaseContext localBibDatabaseContext = databaseContextOpt.get(); + Path bibFilePath = localBibDatabaseContext.getDatabasePath().orElseThrow(() -> + new JabRefException(Localization.lang("Cannot pull: .bib file path missing in BibDatabaseContext.")) + ); + + MergeResult result = syncService.fetchAndMerge(localBibDatabaseContext, bibFilePath); + + if (result.isSuccessful()) { + gitStatusViewModel.updateStatusFromContext(localBibDatabaseContext); + } + + 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..89a97026b72 --- /dev/null +++ b/jabgui/src/main/java/org/jabref/gui/git/GitStatusViewModel.java @@ -0,0 +1,162 @@ +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.gui.StateManager; +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; +import org.jabref.model.database.BibDatabaseContext; + +import com.tobiasdiez.easybind.EasyBind; +import org.jspecify.annotations.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/// 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., {@code UP_TO_DATE}, {@code DIVERGED}, etc.)
  • +///
+public class GitStatusViewModel extends AbstractViewModel { + private static final Logger LOGGER = LoggerFactory.getLogger(GitStatusViewModel.class); + private final StateManager stateManager; + private final ObjectProperty databaseContext = new SimpleObjectProperty<>(); + private final ObjectProperty syncStatus = new SimpleObjectProperty<>(SyncStatus.UNTRACKED); + private final BooleanProperty isTracking = new SimpleBooleanProperty(false); + private final BooleanProperty conflictDetected = new SimpleBooleanProperty(false); + // "" denotes that no commit was pulled + private final StringProperty lastPulledCommit = new SimpleStringProperty(""); + private @Nullable GitHandler activeHandler = null; + + public GitStatusViewModel(StateManager stateManager, Path bibFilePath) { + this.stateManager = stateManager; + EasyBind.subscribe(stateManager.activeDatabaseProperty(), newDb -> { + if (newDb != null && newDb.isPresent() && newDb.get().getDatabasePath().isPresent()) { + BibDatabaseContext databaseContext1 = newDb.get(); + databaseContext.set(databaseContext1); + updateStatusFromContext(databaseContext1); + } else { + LOGGER.debug("No active database with path; resetting Git status."); + reset(); + } + }); + + stateManager.getActiveDatabase().ifPresent(presentContext -> { + databaseContext.set(presentContext); + updateStatusFromContext(presentContext); + }); + } + + protected void updateStatusFromContext(BibDatabaseContext context) { + Optional databasePathOpt = context.getDatabasePath(); + if (databasePathOpt.isEmpty()) { + LOGGER.debug("No .bib file path available in database context; resetting Git status."); + reset(); + return; + } + + Path path = databasePathOpt.get(); + + Optional gitHandlerOpt = GitHandler.fromAnyPath(path); + if (gitHandlerOpt.isEmpty()) { + LOGGER.debug("No Git repository found for path {}; resetting Git status.", path); + reset(); + return; + } + this.activeHandler = gitHandlerOpt.get(); + + GitStatusSnapshot snapshot = GitStatusChecker.checkStatus(path); + 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() { + activeHandler = null; + setSyncStatus(SyncStatus.UNTRACKED); + setTracking(false); + setConflictDetected(false); + setLastPulledCommit(""); + } + + public Optional getDatabaseContext() { + return Optional.ofNullable(databaseContext.get()); + } + + public Optional getCurrentBibFile() { + return getDatabaseContext() + .flatMap(BibDatabaseContext::getDatabasePath); + } + + 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); + } +} diff --git a/jabgui/src/main/java/org/jabref/gui/git/GuiGitConflictResolverStrategy.java b/jabgui/src/main/java/org/jabref/gui/git/GuiGitConflictResolverStrategy.java new file mode 100644 index 00000000000..46ed97339e3 --- /dev/null +++ b/jabgui/src/main/java/org/jabref/gui/git/GuiGitConflictResolverStrategy.java @@ -0,0 +1,35 @@ +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.model.entry.BibEntry; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class GuiGitConflictResolverStrategy implements GitConflictResolverStrategy { + private final static Logger LOGGER = LoggerFactory.getLogger(GuiGitConflictResolverStrategy.class); + private final GitConflictResolverDialog dialog; + + public GuiGitConflictResolverStrategy(GitConflictResolverDialog dialog) { + this.dialog = dialog; + } + + @Override + public List resolveConflicts(List conflicts) { + List resolved = new ArrayList<>(); + for (ThreeWayEntryConflict conflict : conflicts) { + Optional entryOpt = dialog.resolveConflict(conflict); + if (entryOpt.isEmpty()) { + LOGGER.debug("User cancelled conflict resolution for entry {}", conflict.local().getCitationKey().orElse("")); + return List.of(); + } + resolved.add(entryOpt.get()); + } + return resolved; + } +} diff --git a/jablib/src/main/java/module-info.java b/jablib/src/main/java/module-info.java index 239439ef356..ef9f3d96406 100644 --- a/jablib/src/main/java/module-info.java +++ b/jablib/src/main/java/module-info.java @@ -103,9 +103,14 @@ exports org.jabref.logic.shared.event; exports org.jabref.logic.citation; exports org.jabref.logic.crawler; - exports org.jabref.logic.git; exports org.jabref.logic.pseudonymization; exports org.jabref.logic.citation.repository; + exports org.jabref.logic.git; + exports org.jabref.logic.git.conflicts; + exports org.jabref.logic.git.io; + exports org.jabref.logic.git.merge; + exports org.jabref.logic.git.model; + exports org.jabref.logic.git.status; exports org.jabref.logic.command; 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..7830822b935 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.error("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..fbef280fc3b --- /dev/null +++ b/jablib/src/main/java/org/jabref/logic/git/GitSyncService.java @@ -0,0 +1,216 @@ +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.GitFileReader; +import org.jabref.logic.git.io.GitRevisionLocator; +import org.jabref.logic.git.io.RevisionTriple; +import org.jabref.logic.git.merge.GitMergeUtil; +import org.jabref.logic.git.merge.GitSemanticMergeExecutor; +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.jabref.model.entry.BibEntry; + +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; + private final GitSemanticMergeExecutor mergeExecutor; + + public GitSyncService(ImportFormatPreferences importFormatPreferences, GitHandler gitHandler, GitConflictResolverStrategy gitConflictResolverStrategy, GitSemanticMergeExecutor mergeExecutor) { + this.importFormatPreferences = importFormatPreferences; + this.gitHandler = gitHandler; + this.gitConflictResolverStrategy = gitConflictResolverStrategy; + this.mergeExecutor = mergeExecutor; + } + + public MergeResult fetchAndMerge(BibDatabaseContext localDatabaseContext, Path bibFilePath) throws GitAPIException, IOException, JabRefException { + Optional gitHandlerOpt = GitHandler.fromAnyPath(bibFilePath); + if (gitHandlerOpt.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.uncommittedChanges()) { + LOGGER.warn("Pull aborted: Local changes have not been committed."); + 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.remote(), localDatabaseContext, bibFilePath); + + // 4. Auto-commit merge result if successful + // TODO: Allow user customization of auto-merge commit message (e.g. conventional commits) + if (result.isSuccessful()) { + gitHandler.createCommitOnCurrentBranch("Auto-merged by JabRef", !AMEND); + } + + return result; + } + } + + public MergeResult performSemanticMerge(Git git, + Optional baseCommitOpt, + RevCommit remoteCommit, + BibDatabaseContext localDatabaseContext, + 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 + BibDatabaseContext base; + if (baseCommitOpt.isPresent()) { + Optional baseContent = GitFileReader.readFileFromCommit(git, baseCommitOpt.get(), relativePath); + base = baseContent.isEmpty() ? BibDatabaseContext.empty() : BibDatabaseContext.of(baseContent.get(), importFormatPreferences); + } else { + base = new BibDatabaseContext(); + } + + Optional remoteContent = GitFileReader.readFileFromCommit(git, remoteCommit, relativePath); + BibDatabaseContext remote = remoteContent.isEmpty() ? BibDatabaseContext.empty() : BibDatabaseContext.of(remoteContent.get(), importFormatPreferences); + BibDatabaseContext local = localDatabaseContext; + + // 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 + List resolved = gitConflictResolverStrategy.resolveConflicts(conflicts); + if (resolved.isEmpty()) { + LOGGER.warn("Merge aborted: Conflict resolution was canceled or denied."); + return MergeResult.failure(); + } + effectiveRemote = GitMergeUtil.replaceEntries(remote, resolved); + } + + // 4. Apply resolved remote (either original or conflict-resolved) to local + MergeResult result = mergeExecutor.merge(base, local, effectiveRemote, bibFilePath); + + return result; + } + + public void push(BibDatabaseContext localDatabaseContext, 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; + } + + if (status.uncommittedChanges()) { + LOGGER.warn("Pull aborted: Local changes have not been committed."); + 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.remote(), localDatabaseContext, 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/CliGitConflictResolverStrategy.java b/jablib/src/main/java/org/jabref/logic/git/conflicts/CliGitConflictResolverStrategy.java new file mode 100644 index 00000000000..7fd36e27126 --- /dev/null +++ b/jablib/src/main/java/org/jabref/logic/git/conflicts/CliGitConflictResolverStrategy.java @@ -0,0 +1,15 @@ +package org.jabref.logic.git.conflicts; + +import java.util.List; + +import org.jabref.model.entry.BibEntry; + +/// No-op implementation of GitConflictResolverStrategy for CLI use. +/// +/// TODO: Implement CLI conflict resolution or integrate external merge tool. +public class CliGitConflictResolverStrategy implements GitConflictResolverStrategy { + @Override + public List resolveConflicts(List conflicts) { + return List.of(); + } +} 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..01d5135155d --- /dev/null +++ b/jablib/src/main/java/org/jabref/logic/git/conflicts/GitConflictResolverStrategy.java @@ -0,0 +1,23 @@ +package org.jabref.logic.git.conflicts; + +import java.util.List; + +import org.jabref.model.entry.BibEntry; + +/// Strategy interface for resolving semantic entry-level conflicts during Git merges. +/// +/// Implementations decide how to resolve {@link ThreeWayEntryConflict}s, such as via GUI or CLI. +/// +/// Used by {@link GitSyncService} to handle semantic conflicts after Git merge. +/// +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 + * @return the modified BibDatabaseContext containing resolved entries, + * or empty if user canceled merge or CLI refuses to merge. + */ + List resolveConflicts(List conflicts); +} 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..1ffd468e11e --- /dev/null +++ b/jablib/src/main/java/org/jabref/logic/git/conflicts/SemanticConflictDetector.java @@ -0,0 +1,162 @@ +package org.jabref.logic.git.conflicts; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.function.Function; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +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; + +/// Detects semantic merge conflicts between base, local, and remote. +/// +/// Strategy: +/// Instead of computing full diffs from base to local/remote, we simulate a Git-style merge +/// by applying the diff between base and remote onto local (`result := local + remoteDiff`). +/// +/// Caveats: +/// - Only entries with the same citation key are considered matching. +/// - Entries without citation keys are currently ignored. +/// - TODO: Improve handling of such entries. +/// See: `BibDatabaseDiffTest#compareOfTwoEntriesWithSameContentAndMixedLineEndingsReportsNoDifferences` +/// - Changing a citation key is not supported and is treated as deletion + addition. +public class SemanticConflictDetector { + public static List detectConflicts(BibDatabaseContext base, BibDatabaseContext local, BibDatabaseContext remote) { + // 1. get diffs between base and remote + List remoteDiffs = BibDatabaseDiff.compare(base, remote).getEntryDifferences(); + + // 2. map citation key to entry for local/remote diffs + Map baseEntries = getCitationKeyToEntryMap(base); + Map localEntries = getCitationKeyToEntryMap(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(); + + // Case 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)); + } + // Case 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; + } + + private static Map getCitationKeyToEntryMap(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) { + return Stream.of(base, local, remote) + .flatMap(entry -> entry.getFields().stream()) + .distinct() + .anyMatch(field -> { + 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); + + return localChanged && remoteChanged && !Objects.equals(localVal, remoteVal); + }); + } + + /** + * 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 = getCitationKeyToEntryMap(base); + Map remoteMap = getCitationKeyToEntryMap(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) { + 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. + * + * @param base base version + * @param remote remote version + * @return A map from field to new value + */ + private static Map computeFieldPatch(BibEntry base, BibEntry remote) { + Map patch = new LinkedHashMap<>(); + + Stream.concat(base.getFields().stream(), remote.getFields().stream()) + .distinct() + .forEach(field -> { + 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..191495a506e --- /dev/null +++ b/jablib/src/main/java/org/jabref/logic/git/conflicts/ThreeWayEntryConflict.java @@ -0,0 +1,21 @@ +package org.jabref.logic.git.conflicts; + +import org.jabref.model.entry.BibEntry; + +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +/// Represents a semantic conflict between base, local, and remote versions of a {@link BibEntry}. +/// This is similar in structure to {@link RevisionTriple}, but uses nullable entries to model deletion. +/// +/// Constraint: At least one of {@code local} or {@code remote} must be non-null. +@NullMarked +public record ThreeWayEntryConflict( + @Nullable BibEntry base, + @Nullable BibEntry local, + @Nullable BibEntry remote +) { + public ThreeWayEntryConflict { + assert !(local == null && remote == null) : "Both local and remote are null: conflict must involve at least one side."; + } +} 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..3606cd0ad9d --- /dev/null +++ b/jablib/src/main/java/org/jabref/logic/git/io/GitFileReader.java @@ -0,0 +1,44 @@ +package org.jabref.logic.git.io; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; +import java.util.Optional; + +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.NullMarked; + +@NullMarked +public class GitFileReader { + public static Optional readFileFromCommit(Git git, RevCommit commit, 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) { + return Optional.empty(); + } + // 3. load blob object + ObjectId objectId = treeWalk.getObjectId(0); + ObjectLoader loader = repository.open(objectId); + return Optional.of(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..357011034cd --- /dev/null +++ b/jablib/src/main/java/org/jabref/logic/git/io/GitFileWriter.java @@ -0,0 +1,45 @@ +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 java.util.concurrent.ConcurrentHashMap; + +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 { + private static final ConcurrentHashMap FILELOCKS = new ConcurrentHashMap<>(); + + public static void write(Path file, BibDatabaseContext bibDatabaseContext, ImportFormatPreferences importPrefs) throws IOException { + Object lock = FILELOCKS.computeIfAbsent(file.toAbsolutePath().normalize(), key -> new Object()); + + SelfContainedSaveConfiguration saveConfiguration = new SelfContainedSaveConfiguration(); + Charset encoding = bibDatabaseContext.getMetaData().getEncoding().orElse(StandardCharsets.UTF_8); + + synchronized (lock) { + 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..b9fb715dc46 --- /dev/null +++ b/jablib/src/main/java/org/jabref/logic/git/io/GitRevisionLocator.java @@ -0,0 +1,52 @@ +package org.jabref.logic.git.io; + +import java.io.IOException; +import java.util.Optional; + +import org.jabref.logic.JabRefException; + +import org.eclipse.jgit.api.Git; +import org.eclipse.jgit.api.errors.GitAPIException; +import org.eclipse.jgit.lib.BranchConfig; +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; + +/// Locates the three key commits required for a semantic merge: +/// - base: the common ancestor of local (HEAD) and remote (origin/main) +/// - local: the current working commit (HEAD) +/// - remote: the latest commit on origin/main +public class GitRevisionLocator { + + public RevisionTriple locateMergeCommits(Git git) throws GitAPIException, IOException, JabRefException { + Repository repo = git.getRepository(); + + ObjectId headId = repo.resolve("HEAD"); + assert headId != null : "Local HEAD commit is missing."; + + String trackingBranch = new BranchConfig(repo.getConfig(), repo.getBranch()).getTrackingBranch(); + ObjectId remoteId = trackingBranch != null ? repo.resolve(trackingBranch) : null; + assert remoteId != null : "Remote tracking branch is missing."; + + try (RevWalk walk = new RevWalk(git.getRepository())) { + RevCommit local = walk.parseCommit(headId); + RevCommit remote = walk.parseCommit(remoteId); + RevCommit base = findMergeBase(repo, local, remote); + + assert base != null : "Could not determine merge base between local and remote."; + + return new RevisionTriple(Optional.ofNullable(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..973601aa5e0 --- /dev/null +++ b/jablib/src/main/java/org/jabref/logic/git/io/RevisionTriple.java @@ -0,0 +1,23 @@ +package org.jabref.logic.git.io; + +import java.util.Optional; + +import org.eclipse.jgit.revwalk.RevCommit; +import org.jspecify.annotations.NullMarked; + +/** + * 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) + */ +@NullMarked +public record RevisionTriple( + Optional base, + RevCommit local, + RevCommit remote) { + public RevisionTriple { + } +} 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..e10c4d0c0a1 --- /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(new BibEntry(entry)); + } + + // 4. Insert all resolved entries (cloned for safety) + for (BibEntry resolved : resolvedEntries) { + newDatabase.insertEntry(new BibEntry(resolved)); + } + + // 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..4eef9adef5e --- /dev/null +++ b/jablib/src/main/java/org/jabref/logic/git/merge/GitSemanticMergeExecutor.java @@ -0,0 +1,29 @@ +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; + +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +@NullMarked +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(@Nullable 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..4959bd6cfde --- /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 { + // 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..940e8ed632a --- /dev/null +++ b/jablib/src/main/java/org/jabref/logic/git/merge/SemanticMerger.java @@ -0,0 +1,73 @@ +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 = new BibEntry(newEntry); + + clone.getCitationKey().ifPresent(citationKey -> + local.getDatabase().getEntryByCitationKey(citationKey).ifPresent(existing -> { + local.getDatabase().removeEntry(existing); + LOGGER.debug("Removed existing entry '{}' before re-inserting", citationKey); + }) + ); + + local.getDatabase().insertEntry(clone); + LOGGER.debug("Inserted (or replaced) entry '{}', fields={}, marked as changed", + clone.getCitationKey().orElse("?"), + clone.getFieldMap()); + } + } + + public static void applyPatchToDatabase(BibDatabaseContext local, Map> patchMap) { + for (Map.Entry> entry : patchMap.entrySet()) { + String key = entry.getKey(); + Map fieldPatch = entry.getValue(); + Optional localEntryOpt = local.getDatabase().getEntryByCitationKey(key); + + if (localEntryOpt.isEmpty()) { + LOGGER.warn("Skip patch: local does not contain entry '{}'", key); + continue; + } + + BibEntry localEntry = localEntryOpt.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..543db8e9507 --- /dev/null +++ b/jablib/src/main/java/org/jabref/logic/git/model/MergeResult.java @@ -0,0 +1,23 @@ +package org.jabref.logic.git.model; + +import java.util.List; + +import org.jabref.logic.bibtex.comparator.BibEntryDiff; + +public record MergeResult(boolean isSuccessful, List conflicts) { + public static MergeResult withConflicts(List conflicts) { + return new MergeResult(false, conflicts); + } + + public static MergeResult success() { + return new MergeResult(true, 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..97e1c118a19 --- /dev/null +++ b/jablib/src/main/java/org/jabref/logic/git/status/GitStatusChecker.java @@ -0,0 +1,104 @@ +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.BranchConfig; +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 gitHandlerOpt = GitHandler.fromAnyPath(anyPathInsideRepo); + + if (gitHandlerOpt.isEmpty()) { + return new GitStatusSnapshot( + !GitStatusSnapshot.TRACKING, + SyncStatus.UNTRACKED, + !GitStatusSnapshot.CONFLICT, + !GitStatusSnapshot.UNCOMMITTED, + Optional.empty() + ); + } + GitHandler handler = gitHandlerOpt.get(); + + try (Git git = Git.open(handler.getRepositoryPathAsFile())) { + Repository repo = git.getRepository(); + Status status = git.status().call(); + boolean hasConflict = !status.getConflicting().isEmpty(); + boolean hasUncommittedChanges = !status.isClean(); + + ObjectId localHead = repo.resolve("HEAD"); + String trackingBranch = new BranchConfig(repo.getConfig(), repo.getBranch()).getTrackingBranch(); + ObjectId remoteHead = trackingBranch != null ? repo.resolve(trackingBranch) : null; + + SyncStatus syncStatus = determineSyncStatus(repo, localHead, remoteHead); + + return new GitStatusSnapshot( + GitStatusSnapshot.TRACKING, + syncStatus, + hasConflict, + hasUncommittedChanges, + Optional.ofNullable(localHead).map(ObjectId::getName) + ); + } catch (IOException | GitAPIException e) { + LOGGER.warn("Failed to check Git status", e); + return new GitStatusSnapshot( + GitStatusSnapshot.TRACKING, + SyncStatus.UNKNOWN, + !GitStatusSnapshot.CONFLICT, + !GitStatusSnapshot.UNCOMMITTED, + Optional.empty() + ); + } + } + + private static SyncStatus determineSyncStatus(Repository repo, ObjectId localHead, ObjectId remoteHead) throws IOException { + if (localHead == null || remoteHead == null) { + LOGGER.debug("localHead or 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 { + LOGGER.debug("Could not determine git sync status. All commits differ or mergeBase is null."); + 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..61aa29a5529 --- /dev/null +++ b/jablib/src/main/java/org/jabref/logic/git/status/GitStatusSnapshot.java @@ -0,0 +1,14 @@ +package org.jabref.logic.git.status; + +import java.util.Optional; + +public record GitStatusSnapshot( + boolean tracking, + SyncStatus syncStatus, + boolean conflict, + boolean uncommittedChanges, + Optional lastPulledCommit) { + public static final boolean TRACKING = true; + public static final boolean CONFLICT = true; + public static final boolean UNCOMMITTED = true; +} 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..c70580eb27e --- /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, + BEHIND, + AHEAD, + DIVERGED, + CONFLICT, + UNTRACKED, + UNKNOWN +} diff --git a/jablib/src/main/java/org/jabref/model/database/BibDatabaseContext.java b/jablib/src/main/java/org/jabref/model/database/BibDatabaseContext.java index f6ba4c3e6a2..fb0a6cc14c3 100644 --- a/jablib/src/main/java/org/jabref/model/database/BibDatabaseContext.java +++ b/jablib/src/main/java/org/jabref/model/database/BibDatabaseContext.java @@ -1,5 +1,7 @@ package org.jabref.model.database; +import java.io.IOException; +import java.io.Reader; import java.nio.file.Files; import java.nio.file.Path; import java.util.ArrayList; @@ -11,8 +13,12 @@ import org.jabref.architecture.AllowedToUseLogic; import org.jabref.logic.FilePreferences; +import org.jabref.logic.JabRefException; import org.jabref.logic.crawler.Crawler; import org.jabref.logic.crawler.StudyRepository; +import org.jabref.logic.importer.ImportFormatPreferences; +import org.jabref.logic.importer.ParserResult; +import org.jabref.logic.importer.fileformat.BibtexParser; import org.jabref.logic.shared.DatabaseLocation; import org.jabref.logic.shared.DatabaseSynchronizer; import org.jabref.logic.util.CoarseChangeFilter; @@ -276,6 +282,21 @@ public Path getFulltextIndexPath() { return indexPath; } + public static BibDatabaseContext of(String bibContent, ImportFormatPreferences importFormatPreferences) throws JabRefException { + BibtexParser parser = new BibtexParser(importFormatPreferences); + try { + Reader reader = Reader.of(bibContent); + ParserResult result = parser.parse(reader); + return result.getDatabaseContext(); + } catch (IOException e) { + throw new JabRefException("Failed to parse BibTeX content", e); + } + } + + public static BibDatabaseContext empty() { + return new BibDatabaseContext(new BibDatabase(), new MetaData()); + } + @Override public String toString() { return "BibDatabaseContext{" + diff --git a/jablib/src/main/resources/l10n/JabRef_en.properties b/jablib/src/main/resources/l10n/JabRef_en.properties index 140f75a1b87..636877ce5e9 100644 --- a/jablib/src/main/resources/l10n/JabRef_en.properties +++ b/jablib/src/main/resources/l10n/JabRef_en.properties @@ -3022,3 +3022,19 @@ Saved\ %0.=Saved %0. Open\ all\ linked\ files=Open all linked files Cancel\ file\ opening=Cancel file opening + +An\ unexpected\ Git\ error\ occurred\:\ %0=An unexpected Git error occurred: %0 +Cannot\ pull\ from\ Git\:\ No\ file\ is\ associated\ with\ this\ library.=Cannot pull from Git: No file is associated with this library. +Cannot\ pull\:\ .bib\ file\ path\ missing\ in\ BibDatabaseContext.=Cannot pull: .bib file path missing in BibDatabaseContext. +Cannot\ pull\:\ No\ active\ BibDatabaseContext.=Cannot pull: No active BibDatabaseContext. +Git\ Pull=Git Pull +Git\ Pull\ Failed=Git Pull Failed +I/O\ error\:\ %0=I/O error: %0 +Local=Local +Merge\ completed\ with\ conflicts.=Merge completed with conflicts. +No\ library\ file\ path=No library file path +No\ library\ open=No library open +Please\ open\ a\ library\ before\ pulling.=Please open a library before pulling. +Remote=Remote +Successfully\ merged\ and\ updated.=Successfully merged and updated. +Unexpected\ error\:\ %0=Unexpected error: %0 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..98dfccd7b38 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,68 @@ 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.CreateBranchCommand; 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()) + .setInitialBranch("main") + .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(); + + localGit.branchCreate() + .setName("main") + .setUpstreamMode(CreateBranchCommand.SetupUpstreamMode.SET_UPSTREAM) + .setStartPoint("origin/main") + .setForce(true) + .call(); + } } @Test @@ -55,4 +94,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..9db9c5dd8c2 --- /dev/null +++ b/jablib/src/test/java/org/jabref/logic/git/GitSyncServiceTest.java @@ -0,0 +1,351 @@ +package org.jabref.logic.git; + +import java.io.IOException; +import java.io.Reader; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +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.GitSemanticMergeExecutor; +import org.jabref.logic.git.merge.GitSemanticMergeExecutorImpl; +import org.jabref.logic.git.model.MergeResult; +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.entry.BibEntry; +import org.jabref.model.entry.field.StandardField; + +import org.eclipse.jgit.api.Git; +import org.eclipse.jgit.lib.ConfigConstants; +import org.eclipse.jgit.lib.Constants; +import org.eclipse.jgit.lib.PersonIdent; +import org.eclipse.jgit.lib.StoredConfig; +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.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 Git remoteGit; + private ImportFormatPreferences importFormatPreferences; + private GitConflictResolverStrategy gitConflictResolverStrategy; + private GitSemanticMergeExecutor mergeExecutor; + private BibDatabaseContext context; + + // 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"); + + // TODO: This is a text-based E2E test to verify Git-level behavior. + // In the future, consider replacing with structured BibEntry construction + // to improve semantic robustness and avoid syntax errors. + 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); + mergeExecutor = new GitSemanticMergeExecutorImpl(importFormatPreferences); + + // create fake remote repo + remoteDir = tempDir.resolve("remote.git"); + remoteGit = Git.init() + .setBare(true) + .setInitialBranch("main") + .setDirectory(remoteDir.toFile()) + .call(); + + // 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(); + + configureTracking(aliceGit, "main", "origin"); + + // 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(); + + String actualContent = Files.readString(library); + ParserResult parsed = new BibtexParser(importFormatPreferences).parse(Reader.of(actualContent)); + context = new BibDatabaseContext(parsed.getDatabase(), parsed.getMetaData()); + context.setDatabasePath(library); + + // Debug hint: Show the created git graph on the command line + // git log --graph --oneline --decorate --all --reflog + } + + @AfterEach + void cleanup() { + if (aliceGit != null) { + aliceGit.close(); + } + if (bobGit != null) { + bobGit.close(); + } + if (remoteGit != null) { + remoteGit.close(); + } + } + + @Test + void pullTriggersSemanticMergeWhenNoConflicts() throws Exception { + GitHandler gitHandler = new GitHandler(library.getParent()); + GitSyncService syncService = new GitSyncService(importFormatPreferences, gitHandler, gitConflictResolverStrategy, mergeExecutor); + MergeResult result = syncService.fetchAndMerge(context, 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, mergeExecutor); + syncService.push(context, library); + + String pushedContent = GitFileReader + .readFileFromCommit(aliceGit, aliceGit.log().setMaxCount(1).call().iterator().next(), Path.of("library.bib")) + .orElseThrow(() -> new IllegalStateException("Expected file 'library.bib' not found in commit")); + 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 { + Path bobLibrary = bobDir.resolve("library.bib"); + String bobEntry = """ + @article{b, + author = {author-b}, + doi = {xyz}, + } + + @article{a, + author = {don't know the author}, + doi = {xya}, + } + + @article{c, + author = {bob-c}, + title = {Title C}, + } + """; + writeAndCommit(bobEntry, "Bob adds article-c", bob, bobLibrary, bobGit); + bobGit.push().setRemote("origin").call(); + String aliceEntry = """ + @article{a, + author = {author-a}, + doi = {xya}, + } + + @article{b, + author = {don't know the author}, + doi = {xyz}, + } + + @article{c, + author = {alice-c}, + title = {Title C}, + } + """; + writeAndCommit(aliceEntry, "Alice adds conflicting article-c", alice, library, aliceGit); + aliceGit.fetch().setRemote("origin").call(); + + String actualContent = Files.readString(library); + ParserResult parsed = new BibtexParser(importFormatPreferences).parse(Reader.of(actualContent)); + context = new BibDatabaseContext(parsed.getDatabase(), parsed.getMetaData()); + context.setDatabasePath(library); + + // Setup mock conflict resolver + GitConflictResolverStrategy resolver = mock(GitConflictResolverStrategy.class); + when(resolver.resolveConflicts(anyList())).thenAnswer(invocation -> { + List conflicts = invocation.getArgument(0); + ThreeWayEntryConflict conflict = conflicts.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 = new BibEntry(conflict.remote()); + resolved.setField(StandardField.AUTHOR, "alice-c + bob-c"); + return List.of(resolved); + }); + + GitHandler handler = new GitHandler(aliceDir); + GitSyncService service = new GitSyncService(importFormatPreferences, handler, resolver, mergeExecutor); + MergeResult result = service.fetchAndMerge(context, library); + + assertTrue(result.isSuccessful()); + String content = Files.readString(library); + assertTrue(content.contains("alice-c + bob-c")); + verify(resolver).resolveConflicts(anyList()); + } + + @Test + void readFromCommits() throws Exception { + String base = GitFileReader + .readFileFromCommit(aliceGit, baseCommit, Path.of("library.bib")) + .orElseThrow(() -> new IllegalStateException("Base version of library.bib not found")); + + String local = GitFileReader + .readFileFromCommit(aliceGit, aliceCommit, Path.of("library.bib")) + .orElseThrow(() -> new IllegalStateException("Local version of library.bib not found")); + + String remote = GitFileReader + .readFileFromCommit(aliceGit, bobCommit, Path.of("library.bib")) + .orElseThrow(() -> new IllegalStateException("Remote version of library.bib not found")); + + assertEquals(initialContent, base); + assertEquals(aliceUpdatedContent, local); + assertEquals(bobUpdatedContent, remote); + } + + 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(); + } + + private static void configureTracking(Git git, String branch, String remote) throws IOException { + StoredConfig config = git.getRepository().getConfig(); + config.setString(ConfigConstants.CONFIG_BRANCH_SECTION, branch, ConfigConstants.CONFIG_KEY_REMOTE, remote); + config.setString(ConfigConstants.CONFIG_BRANCH_SECTION, branch, ConfigConstants.CONFIG_KEY_MERGE, Constants.R_HEADS + branch); + config.save(); + } +} 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..9b03e5f5154 --- /dev/null +++ b/jablib/src/test/java/org/jabref/logic/git/merge/GitSemanticMergeExecutorTest.java @@ -0,0 +1,78 @@ +package org.jabref.logic.git.merge; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +import javafx.collections.FXCollections; + +import org.jabref.logic.JabRefException; +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.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.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; + @TempDir + private Path tempDir; + + @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 = new BibEntry(baseEntry); + BibEntry remoteEntry = new BibEntry(baseEntry); + 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 = tempDir.resolve("merged.bib"); + } + + @Test + public void successfulMergeAndWrite() throws IOException, JabRefException { + MergeResult result = executor.merge(base, local, remote, tempFile); + + assertTrue(result.isSuccessful()); + + String mergedContent = Files.readString(tempFile); + BibDatabaseContext mergedContext = BibDatabaseContext.of(mergedContent, preferences); + + BibEntry expected = new BibEntry() + .withCitationKey("Smith2020") + .withField(StandardField.TITLE, "New Title"); + + assertEquals(List.of(expected), mergedContext.getDatabase().getEntries()); + } +} 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..fdd01538ebc --- /dev/null +++ b/jablib/src/test/java/org/jabref/logic/git/status/GitStatusCheckerTest.java @@ -0,0 +1,195 @@ +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.CreateBranchCommand; +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 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 Git seedGit; + + 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"); + 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(); + localGit.branchCreate() + .setName("main") + .setUpstreamMode(CreateBranchCommand.SetupUpstreamMode.SET_UPSTREAM) + .setStartPoint("origin/main") + .setForce(true) + .call(); + + this.localLibrary = localDir.resolve("library.bib"); + } + + @AfterEach + void tearDown() { + if (seedGit != null) { + seedGit.close(); + } + if (localGit != null) { + localGit.close(); + } + if (remoteGit != null) { + remoteGit.close(); + } + } + + @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/GitFileWriterTest.java b/jablib/src/test/java/org/jabref/logic/git/util/GitFileWriterTest.java new file mode 100644 index 00000000000..02d162988ce --- /dev/null +++ b/jablib/src/test/java/org/jabref/logic/git/util/GitFileWriterTest.java @@ -0,0 +1,50 @@ +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.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.jabref.model.entry.types.StandardEntryType; + +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 = BibDatabaseContext.of(""" + @article{a, + author = {Alice}, + title = {Test}, + } + """, importFormatPreferences); + + Path tempFile = Files.createTempFile("tempgitwriter", ".bib"); + GitFileWriter.write(tempFile, inputDatabaseContext, importFormatPreferences); + + String written = Files.readString(tempFile); + BibDatabaseContext parsedContext = BibDatabaseContext.of(written, importFormatPreferences); + BibEntry expected = new BibEntry(StandardEntryType.Article) + .withCitationKey("a") + .withField(StandardField.AUTHOR, "Alice") + .withField(StandardField.TITLE, "Test"); + assertEquals(List.of(expected), parsedContext.getDatabase().getEntries()); + } +} 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..b2765428493 --- /dev/null +++ b/jablib/src/test/java/org/jabref/logic/git/util/GitRevisionLocatorTest.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 org.jabref.logic.git.io.GitRevisionLocator; +import org.jabref.logic.git.io.RevisionTriple; + +import org.eclipse.jgit.api.Git; +import org.eclipse.jgit.lib.ConfigConstants; +import org.eclipse.jgit.lib.Constants; +import org.eclipse.jgit.lib.StoredConfig; +import org.eclipse.jgit.revwalk.RevCommit; +import org.eclipse.jgit.transport.URIish; +import org.junit.jupiter.api.AfterEach; +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 GitRevisionLocatorTest { + private Git git; + + @AfterEach + void cleanup() { + if (git != null) { + git.close(); + } + } + + @Test + void locateMergeCommits(@TempDir Path tempDir) throws Exception { + Path bibFile = tempDir.resolve("library.bib"); + 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(); + + git.remoteAdd() + .setName("origin") + .setUri(new URIish(tempDir.toUri().toString())) + .call(); + git.getRepository().updateRef("refs/remotes/origin/main").link("refs/heads/remote"); + + StoredConfig config = git.getRepository().getConfig(); + config.setString(ConfigConstants.CONFIG_BRANCH_SECTION, "main", ConfigConstants.CONFIG_KEY_REMOTE, "origin"); + config.setString(ConfigConstants.CONFIG_BRANCH_SECTION, "main", ConfigConstants.CONFIG_KEY_MERGE, Constants.R_HEADS + "main"); + config.save(); + + git.checkout().setName("main").call(); + + // test locator + GitRevisionLocator locator = new GitRevisionLocator(); + RevisionTriple triple = locator.locateMergeCommits(git); + + assertTrue(triple.base().isPresent()); + assertEquals(base.getId(), triple.base().get().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..31da078d777 --- /dev/null +++ b/jablib/src/test/java/org/jabref/logic/git/util/SemanticConflictDetectorTest.java @@ -0,0 +1,527 @@ +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.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.AfterEach; +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"); + } + + @AfterEach + void cleanup() { + if (git != null) { + git.close(); + } + } + + @ParameterizedTest + @MethodSource + 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")).orElse(""); + return BibDatabaseContext.of(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); + } + + // See docs/code-howtos/git.md for testing patterns + static Stream semanticConflicts() { + return Stream.of( + Arguments.of("T1 - remote changed a field, local unchanged", + """ + @article{a, + author = {Test Author}, + doi = {xya}, + } + """, + """ + @article{a, + author = {Test Author}, + doi = {xya}, + } + """, + """ + @article{a, + author = {bob}, + doi = {xya}, + } + """, + false + ), + Arguments.of("T2 - local changed a field, remote unchanged", + """ + @article{a, + author = {Test Author}, + doi = {xya}, + } + """, + """ + @article{a, + author = {alice}, + doi = {xya}, + } + """, + """ + @article{a, + author = {Test Author}, + doi = {xya}, + } + """, + false + ), + Arguments.of("T3 - both changed to same value", + """ + @article{a, + author = {Test Author}, + 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 = {Test Author}, + 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 = {Test Author}, + doi = {xya}, + } + """, + """ + @article{a, + } + """, + """ + @article{a, + author = {bob}, + doi = {xya}, + } + """, + true + ), + Arguments.of("T6 - local changed, remote deleted", + """ + @article{a, + author = {Test Author}, + doi = {xya}, + } + """, + """ + @article{a, + author = {alice}, + doi = {xya}, + } + """, + """ + @article{a, + } + """, + true + ), + Arguments.of("T7 - remote deleted, local unchanged", + """ + @article{a, + author = {Test Author}, + doi = {xya}, + } + """, + """ + @article{a, + author = {Test Author}, + doi = {xya}, + } + """, + """ + @article{a, + } + """, + false + ), + Arguments.of("T8 - local changed field A, remote changed field B", + """ + @article{a, + author = {Test Author}, + doi = {xya}, + } + """, + """ + @article{a, + author = {alice}, + doi = {xya}, + } + """, + """ + @article{a, + author = {Test Author}, + doi = {xyz}, + } + """, + false + ), + Arguments.of("T9 - field order changed only", + """ + @article{a, + author = {Test Author}, + doi = {xya}, + } + """, + """ + @article{a, + doi = {xya}, + author = {Test Author}, + } + """, + """ + @article{a, + author = {Test Author}, + doi = {xya}, + } + """, + false + ), + Arguments.of("T10 - local changed entry a, remote changed entry b", + """ + @article{a, + author = {Test Author}, + doi = {xya}, + } + + @article{b, + author = {Test Author}, + doi = {xyz}, + } + """, + """ + @article{a, + author = {author-a}, + doi = {xya}, + } + @article{b, + author = {Test Author}, + doi = {xyz}, + } + """, + """ + @article{b, + author = {author-b}, + doi = {xyz}, + } + + @article{a, + author = {Test Author}, + doi = {xya}, + } + """, + false + ), + Arguments.of("T11 - remote added field, local unchanged", + """ + @article{a, + author = {Test Author}, + doi = {xya}, + } + """, + """ + @article{a, + author = {Test Author}, + doi = {xya}, + } + """, + """ + @article{a, + author = {Test Author}, + doi = {xya}, + year = {2025}, + } + """, + false + ), + Arguments.of("T12 - both added same field with different values", + """ + @article{a, + author = {Test Author}, + doi = {xya}, + } + """, + """ + @article{a, + author = {Test Author}, + doi = {xya}, + year = {2023}, + } + """, + """ + @article{a, + author = {Test Author}, + doi = {xya}, + year = {2025}, + } + """, + true + ), + Arguments.of("T13 - local added field, remote unchanged", + """ + @article{a, + author = {Test Author}, + doi = {xya}, + } + """, + """ + @article{a, + author = {Test Author}, + doi = {newfield}, + } + """, + """ + @article{a, + author = {Test Author}, + doi = {xya}, + } + """, + false + ), + Arguments.of("T14 - both added same field with same value", + """ + @article{a, + author = {Test Author}, + doi = {xya}, + } + """, + """ + @article{a, + author = {Test Author}, + doi = {value}, + } + """, + """ + @article{a, + author = {Test Author}, + doi = {value}, + } + """, + false + ), + Arguments.of("T15 - both added same field with different values", + """ + @article{a, + author = {Test Author}, + doi = {xya}, + } + """, + """ + @article{a, + author = {Test Author}, + doi = {value1}, + } + """, + """ + @article{a, + author = {Test Author}, + 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 = {Test Author}, + doi = {xya}, + } + @article{b, + author = {Test Author}, + doi = {xyz}, + } + """; + String remote = """ + @article{b, + author = {author-b}, + doi = {xyz}, + } + @article{a, + author = {Test Author}, + 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 = {Test Author}, + doi = {xya}, + } + """; + String remote = """ + @article{a, + author = {Test Author}, + 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..4d611d2760c --- /dev/null +++ b/jablib/src/test/java/org/jabref/logic/git/util/SemanticMergerTest.java @@ -0,0 +1,140 @@ +package org.jabref.logic.git.util; + +import java.util.Optional; +import java.util.stream.Stream; + +import org.jabref.logic.git.conflicts.SemanticConflictDetector; +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.junit.jupiter.api.Assertions.assertTrue; +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 + @MethodSource + void patchDatabase(String description, String base, String local, String remote, String expectedAuthor) throws Exception { + BibDatabaseContext baseDatabaseContext = BibDatabaseContext.of(base, importFormatPreferences); + BibDatabaseContext localDatabaseContext = BibDatabaseContext.of(local, importFormatPreferences); + BibDatabaseContext remoteDatabaseContext = BibDatabaseContext.of(remote, importFormatPreferences); + + MergePlan plan = SemanticConflictDetector.extractMergePlan(baseDatabaseContext, remoteDatabaseContext); + SemanticMerger.applyMergePlan(localDatabaseContext, plan); + + BibEntry patched = localDatabaseContext.getDatabase().getEntryByCitationKey("a").orElseThrow(); + if (expectedAuthor == null) { + assertTrue(patched.getField(StandardField.AUTHOR).isEmpty()); + } else { + assertEquals(Optional.of(expectedAuthor), patched.getField(StandardField.AUTHOR)); + } + } + + // These test cases are based on documented scenarios from docs/code-howtos/git.md. + static Stream patchDatabase() { + return Stream.of( + Arguments.of("T1 - remote changed a field, local unchanged", + """ + @article{a, + author = {TestAuthor}, + doi = {ExampleDoi} + } + """, + """ + @article{a, + author = {TestAuthor}, + doi = {ExampleDoi} + } + """, + """ + @article{a, + author = {bob}, + doi = {ExampleDoi} + } + """, + "bob" + ), + Arguments.of("T2 - local changed a field, remote unchanged", + """ + @article{a, + author = {TestAuthor}, + doi = {ExampleDoi} + } + """, + """ + @article{a, + author = {alice}, + doi = {ExampleDoi} + } + """, + """ + @article{a, + author = {TestAuthor}, + doi = {ExampleDoi} + } + """, + "alice" + ), + Arguments.of("T3 - both changed to same value", + """ + @article{a, + author = {TestAuthor}, + doi = {ExampleDoi} + } + """, + """ + @article{a, + author = {bob}, + doi = {ExampleDoi} + } + """, + """ + @article{a, + author = {bob}, + doi = {ExampleDoi} + } + """, + "bob" + ), + Arguments.of("T4 - field removed in remote, unchanged in local", + """ + @article{a, + author = {TestAuthor}, + doi = {ExampleDoi}, + } + """, + """ + @article{a, + author = {TestAuthor}, + doi = {ExampleDoi}, + } + """, + """ + @article{a, + doi = {ExampleDoi}, + } + """, + null + ) + ); + } +} diff --git a/jablib/src/test/java/org/jabref/model/database/BibDatabaseContextTest.java b/jablib/src/test/java/org/jabref/model/database/BibDatabaseContextTest.java index 791594add38..cde76ca9bed 100644 --- a/jablib/src/test/java/org/jabref/model/database/BibDatabaseContextTest.java +++ b/jablib/src/test/java/org/jabref/model/database/BibDatabaseContextTest.java @@ -4,9 +4,12 @@ import java.util.List; import org.jabref.logic.FilePreferences; +import org.jabref.logic.importer.ImportFormatPreferences; import org.jabref.logic.util.Directories; import org.jabref.model.entry.BibEntry; +import org.jabref.model.entry.field.StandardField; import org.jabref.model.entry.types.IEEETranEntryType; +import org.jabref.model.entry.types.StandardEntryType; import org.jabref.model.metadata.MetaData; import org.junit.jupiter.api.BeforeEach; @@ -14,6 +17,8 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.RETURNS_DEEP_STUBS; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; @@ -22,10 +27,13 @@ class BibDatabaseContextTest { private Path currentWorkingDir; private FilePreferences fileDirPrefs; + private ImportFormatPreferences importPrefs; @BeforeEach void setUp() { fileDirPrefs = mock(FilePreferences.class); + importPrefs = mock(ImportFormatPreferences.class, RETURNS_DEEP_STUBS); + currentWorkingDir = Path.of(System.getProperty("user.dir")); when(fileDirPrefs.shouldStoreFilesRelativeToBibFile()).thenReturn(true); } @@ -156,4 +164,33 @@ void getFullTextIndexPathWhenPathIsNotNull() { String actualPathStart = actualPath.toString().substring(0, fulltextIndexBaseDirectory.length()); assertEquals(fulltextIndexBaseDirectory, actualPathStart); } + + @Test + void ofParsesValidBibtexStringCorrectly() throws Exception { + String bibContent = """ + @article{a, + author = {Alice}, + title = {Test Title}, + year = {2023} + } + """; + + BibDatabaseContext context = BibDatabaseContext.of(bibContent, importPrefs); + BibEntry expected = new BibEntry(StandardEntryType.Article) + .withCitationKey("a") + .withField(StandardField.AUTHOR, "Alice") + .withField(StandardField.TITLE, "Test Title") + .withField(StandardField.YEAR, "2023"); + + assertEquals(List.of(expected), context.getDatabase().getEntries()); + } + + @Test + void emptyReturnsContextWithEmptyDatabaseAndMetadata() { + BibDatabaseContext context = BibDatabaseContext.empty(); + + assertNotNull(context); + assertTrue(context.getDatabase().getEntries().isEmpty()); + assertNotNull(context.getMetaData()); + } }