From d2dc011da04a5b533f73e1bca21cdaef672543f0 Mon Sep 17 00:00:00 2001 From: Wanling Fu Date: Wed, 6 Aug 2025 11:53:04 +0100 Subject: [PATCH 01/10] refactor GitHandlerRegistry Decouple GitHandler creation via GitHandlerRegistry --- .../org/jabref/gui/git/GitPullAction.java | 8 +- .../jabref/gui/git/GitStatusViewModel.java | 2 +- jablib/src/main/java/module-info.java | 1 + .../java/org/jabref/logic/git/GitHandler.java | 15 ++-- .../org/jabref/logic/git/GitSyncService.java | 28 ++++--- .../logic/git/status/GitStatusChecker.java | 32 ++++---- .../logic/git/util/GitHandlerRegistry.java | 32 ++++++++ .../jabref/logic/git/GitSyncServiceTest.java | 12 +-- .../git/status/GitStatusCheckerTest.java | 20 ++++- .../git/util/GitHandlerRegistryTest.java | 74 +++++++++++++++++++ 10 files changed, 182 insertions(+), 42 deletions(-) create mode 100644 jablib/src/main/java/org/jabref/logic/git/util/GitHandlerRegistry.java create mode 100644 jablib/src/test/java/org/jabref/logic/git/util/GitHandlerRegistryTest.java diff --git a/jabgui/src/main/java/org/jabref/gui/git/GitPullAction.java b/jabgui/src/main/java/org/jabref/gui/git/GitPullAction.java index 5087d4d26c6..8cc48df17b8 100644 --- a/jabgui/src/main/java/org/jabref/gui/git/GitPullAction.java +++ b/jabgui/src/main/java/org/jabref/gui/git/GitPullAction.java @@ -14,6 +14,7 @@ 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.git.util.GitHandlerRegistry; import org.jabref.logic.l10n.Localization; import org.jabref.logic.util.BackgroundTask; import org.jabref.logic.util.TaskExecutor; @@ -27,15 +28,18 @@ public class GitPullAction extends SimpleCommand { private final StateManager stateManager; private final GuiPreferences guiPreferences; private final TaskExecutor taskExecutor; + private final GitHandlerRegistry handlerRegistry; public GitPullAction(DialogService dialogService, StateManager stateManager, GuiPreferences guiPreferences, - TaskExecutor taskExecutor) { + TaskExecutor taskExecutor, + GitHandlerRegistry handlerRegistry) { this.dialogService = dialogService; this.stateManager = stateManager; this.guiPreferences = guiPreferences; this.taskExecutor = taskExecutor; + this.handlerRegistry = handlerRegistry; } @Override @@ -65,7 +69,7 @@ public void execute() { GitConflictResolverStrategy resolver = new GuiGitConflictResolverStrategy(dialog); GitSemanticMergeExecutor mergeExecutor = new GitSemanticMergeExecutorImpl(guiPreferences.getImportFormatPreferences()); - GitSyncService syncService = new GitSyncService(guiPreferences.getImportFormatPreferences(), handler, resolver, mergeExecutor); + GitSyncService syncService = new GitSyncService(guiPreferences.getImportFormatPreferences(), handlerRegistry, resolver, mergeExecutor); GitStatusViewModel statusViewModel = new GitStatusViewModel(stateManager, bibFilePath); GitPullViewModel viewModel = new GitPullViewModel(syncService, statusViewModel); diff --git a/jabgui/src/main/java/org/jabref/gui/git/GitStatusViewModel.java b/jabgui/src/main/java/org/jabref/gui/git/GitStatusViewModel.java index 89a97026b72..20486d19e2f 100644 --- a/jabgui/src/main/java/org/jabref/gui/git/GitStatusViewModel.java +++ b/jabgui/src/main/java/org/jabref/gui/git/GitStatusViewModel.java @@ -80,7 +80,7 @@ protected void updateStatusFromContext(BibDatabaseContext context) { } this.activeHandler = gitHandlerOpt.get(); - GitStatusSnapshot snapshot = GitStatusChecker.checkStatus(path); + GitStatusSnapshot snapshot = GitStatusChecker.checkStatus(activeHandler); setTracking(snapshot.tracking()); setSyncStatus(snapshot.syncStatus()); setConflictDetected(snapshot.conflict()); diff --git a/jablib/src/main/java/module-info.java b/jablib/src/main/java/module-info.java index ef9f3d96406..5dc8b7d031c 100644 --- a/jablib/src/main/java/module-info.java +++ b/jablib/src/main/java/module-info.java @@ -112,6 +112,7 @@ exports org.jabref.logic.git.model; exports org.jabref.logic.git.status; exports org.jabref.logic.command; + exports org.jabref.logic.git.util; 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 6b6c87f1ca6..83914317c4d 100644 --- a/jablib/src/main/java/org/jabref/logic/git/GitHandler.java +++ b/jablib/src/main/java/org/jabref/logic/git/GitHandler.java @@ -217,22 +217,27 @@ public void fetchOnCurrentBranch() throws IOException { /** * 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. + *

+ * If a directory containing a .git folder is found, return that path. * - * @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 + * @param anyPathInsideRepo the file or directory path that is assumed to be located inside a Git repository + * @return an optional containing the path to the Git repository root if found */ - public static Optional fromAnyPath(Path anyPathInsideRepo) { + public static Optional findRepositoryRoot(Path anyPathInsideRepo) { Path current = anyPathInsideRepo.toAbsolutePath(); while (current != null) { if (Files.exists(current.resolve(".git"))) { - return Optional.of(new GitHandler(current)); + return Optional.of(current); } current = current.getParent(); } return Optional.empty(); } + public static Optional fromAnyPath(Path anyPathInsideRepo) { + return findRepositoryRoot(anyPathInsideRepo).map(GitHandler::new); + } + public File getRepositoryPathAsFile() { return 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 index fbef280fc3b..4943bcf2669 100644 --- a/jablib/src/main/java/org/jabref/logic/git/GitSyncService.java +++ b/jablib/src/main/java/org/jabref/logic/git/GitSyncService.java @@ -18,6 +18,7 @@ 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.git.util.GitHandlerRegistry; import org.jabref.logic.importer.ImportFormatPreferences; import org.jabref.model.database.BibDatabaseContext; import org.jabref.model.entry.BibEntry; @@ -39,25 +40,26 @@ public class GitSyncService { private static final boolean AMEND = true; private final ImportFormatPreferences importFormatPreferences; - private final GitHandler gitHandler; + private final GitHandlerRegistry gitHandlerRegistry; private final GitConflictResolverStrategy gitConflictResolverStrategy; private final GitSemanticMergeExecutor mergeExecutor; - public GitSyncService(ImportFormatPreferences importFormatPreferences, GitHandler gitHandler, GitConflictResolverStrategy gitConflictResolverStrategy, GitSemanticMergeExecutor mergeExecutor) { + public GitSyncService(ImportFormatPreferences importFormatPreferences, GitHandlerRegistry gitHandlerRegistry, GitConflictResolverStrategy gitConflictResolverStrategy, GitSemanticMergeExecutor mergeExecutor) { this.importFormatPreferences = importFormatPreferences; - this.gitHandler = gitHandler; + this.gitHandlerRegistry = gitHandlerRegistry; 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."); + Optional repoRoot = GitHandler.findRepositoryRoot(bibFilePath); + if (repoRoot.isEmpty()) { + LOGGER.warn("Path is not inside a Git repository"); return MergeResult.failure(); } + GitHandler gitHandler = gitHandlerRegistry.get(repoRoot.get()); - GitStatusSnapshot status = GitStatusChecker.checkStatus(bibFilePath); + GitStatusSnapshot status = GitStatusChecker.checkStatus(gitHandler); if (!status.tracking()) { LOGGER.warn("Pull aborted: The file is not under Git version control."); @@ -151,10 +153,18 @@ public MergeResult performSemanticMerge(Git git, } public void push(BibDatabaseContext localDatabaseContext, Path bibFilePath) throws GitAPIException, IOException, JabRefException { - GitStatusSnapshot status = GitStatusChecker.checkStatus(bibFilePath); + Optional repoRoot = GitHandler.findRepositoryRoot(bibFilePath); + + if (repoRoot.isEmpty()) { + LOGGER.warn("Path is not inside a Git repository"); + return; + } + GitHandler gitHandler = gitHandlerRegistry.get(repoRoot.get()); + + GitStatusSnapshot status = GitStatusChecker.checkStatus(gitHandler); if (!status.tracking()) { - LOGGER.warn("Push aborted: file is not tracked by Git"); + LOGGER.warn("Push aborted: File is not tracked by Git"); return; } 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 index 97e1c118a19..d19419814c4 100644 --- a/jablib/src/main/java/org/jabref/logic/git/status/GitStatusChecker.java +++ b/jablib/src/main/java/org/jabref/logic/git/status/GitStatusChecker.java @@ -26,21 +26,8 @@ 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())) { + public static GitStatusSnapshot checkStatus(GitHandler gitHandler) { + try (Git git = Git.open(gitHandler.getRepositoryPathAsFile())) { Repository repo = git.getRepository(); Status status = git.status().call(); boolean hasConflict = !status.getConflicting().isEmpty(); @@ -71,6 +58,21 @@ public static GitStatusSnapshot checkStatus(Path anyPathInsideRepo) { } } + public static GitStatusSnapshot checkStatus(Path anyPathInsideRepo) { + Optional handlerOpt = GitHandler.fromAnyPath(anyPathInsideRepo); + if (handlerOpt.isEmpty()) { + return new GitStatusSnapshot( + !GitStatusSnapshot.TRACKING, + SyncStatus.UNTRACKED, + !GitStatusSnapshot.CONFLICT, + !GitStatusSnapshot.UNCOMMITTED, + Optional.empty() + ); + } + + return checkStatus(handlerOpt.get()); + } + private static SyncStatus determineSyncStatus(Repository repo, ObjectId localHead, ObjectId remoteHead) throws IOException { if (localHead == null || remoteHead == null) { LOGGER.debug("localHead or remoteHead null"); diff --git a/jablib/src/main/java/org/jabref/logic/git/util/GitHandlerRegistry.java b/jablib/src/main/java/org/jabref/logic/git/util/GitHandlerRegistry.java new file mode 100644 index 00000000000..2a131272738 --- /dev/null +++ b/jablib/src/main/java/org/jabref/logic/git/util/GitHandlerRegistry.java @@ -0,0 +1,32 @@ +package org.jabref.logic.git.util; + +import java.nio.file.Path; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; + +import org.jabref.logic.git.GitHandler; + +/** + * A registry that manages {@link GitHandler} instances per Git repository. + *

+ * Ensures that each Git repository (identified by its root path) has a single {@code GitHandler} instance shared across the application. + *

+ * Usage: + * - {@link #get(Path)} — for known repository root paths (must contain a .git folder). + * - {@link #fromAnyPath(Path)} — for arbitrary paths inside a Git repo; will locate the repo root first. + */ +public class GitHandlerRegistry { + + private final Map handlerCache = new ConcurrentHashMap<>(); + + public GitHandler get(Path repoPath) { + Path normalized = repoPath.toAbsolutePath().normalize(); + return handlerCache.computeIfAbsent(normalized, GitHandler::new); + } + + public Optional fromAnyPath(Path anyPathInsideRepo) { + return GitHandler.findRepositoryRoot(anyPathInsideRepo) + .map(this::get); + } +} diff --git a/jablib/src/test/java/org/jabref/logic/git/GitSyncServiceTest.java b/jablib/src/test/java/org/jabref/logic/git/GitSyncServiceTest.java index 729c5af1c26..5cc0296518a 100644 --- a/jablib/src/test/java/org/jabref/logic/git/GitSyncServiceTest.java +++ b/jablib/src/test/java/org/jabref/logic/git/GitSyncServiceTest.java @@ -13,6 +13,7 @@ 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.git.util.GitHandlerRegistry; import org.jabref.logic.importer.ImportFormatPreferences; import org.jabref.logic.importer.ParserResult; import org.jabref.logic.importer.fileformat.BibtexParser; @@ -60,6 +61,7 @@ class GitSyncServiceTest { private GitConflictResolverStrategy gitConflictResolverStrategy; private GitSemanticMergeExecutor mergeExecutor; private BibDatabaseContext context; + private GitHandlerRegistry gitHandlerRegistry; // These are setup by aliceBobSetting private RevCommit baseCommit; @@ -123,6 +125,7 @@ void aliceBobSimple(@TempDir Path tempDir) throws Exception { gitConflictResolverStrategy = mock(GitConflictResolverStrategy.class); mergeExecutor = new GitSemanticMergeExecutorImpl(importFormatPreferences); + gitHandlerRegistry = new GitHandlerRegistry(); // create fake remote repo remoteDir = tempDir.resolve("remote.git"); @@ -205,8 +208,7 @@ void cleanup() { @Test void pullTriggersSemanticMergeWhenNoConflicts() throws Exception { - GitHandler gitHandler = new GitHandler(library.getParent()); - GitSyncService syncService = new GitSyncService(importFormatPreferences, gitHandler, gitConflictResolverStrategy, mergeExecutor); + GitSyncService syncService = new GitSyncService(importFormatPreferences, gitHandlerRegistry, gitConflictResolverStrategy, mergeExecutor); MergeResult result = syncService.fetchAndMerge(context, library); assertTrue(result.isSuccessful()); @@ -229,8 +231,7 @@ void pullTriggersSemanticMergeWhenNoConflicts() throws Exception { @Test void pushTriggersMergeAndPushWhenNoConflicts() throws Exception { - GitHandler gitHandler = new GitHandler(library.getParent()); - GitSyncService syncService = new GitSyncService(importFormatPreferences, gitHandler, gitConflictResolverStrategy, mergeExecutor); + GitSyncService syncService = new GitSyncService(importFormatPreferences, gitHandlerRegistry, gitConflictResolverStrategy, mergeExecutor); syncService.push(context, library); String pushedContent = GitFileReader @@ -308,8 +309,7 @@ void mergeConflictOnSameFieldTriggersDialogAndUsesUserResolution() throws Except return List.of(resolved); }); - GitHandler handler = new GitHandler(aliceDir); - GitSyncService service = new GitSyncService(importFormatPreferences, handler, resolver, mergeExecutor); + GitSyncService service = new GitSyncService(importFormatPreferences, gitHandlerRegistry, resolver, mergeExecutor); MergeResult result = service.fetchAndMerge(context, library); assertTrue(result.isSuccessful()); 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 index bacc6d56579..5160280f59f 100644 --- a/jablib/src/test/java/org/jabref/logic/git/status/GitStatusCheckerTest.java +++ b/jablib/src/test/java/org/jabref/logic/git/status/GitStatusCheckerTest.java @@ -5,6 +5,9 @@ import java.nio.file.Path; import java.util.List; +import org.jabref.logic.git.GitHandler; +import org.jabref.logic.git.util.GitHandlerRegistry; + import org.eclipse.jgit.api.CreateBranchCommand; import org.eclipse.jgit.api.Git; import org.eclipse.jgit.internal.storage.file.WindowCache; @@ -28,6 +31,7 @@ class GitStatusCheckerTest { private Git localGit; private Git remoteGit; private Git seedGit; + private GitHandlerRegistry gitHandlerRegistry; private final PersonIdent author = new PersonIdent("Tester", "tester@example.org"); @@ -69,6 +73,7 @@ class GitStatusCheckerTest { @BeforeEach void setup(@TempDir Path tempDir) throws Exception { + gitHandlerRegistry = new GitHandlerRegistry(); Path remoteDir = tempDir.resolve("remote.git"); remoteGit = Git.init().setBare(true).setDirectory(remoteDir.toFile()).call(); @@ -132,13 +137,16 @@ void tearDown() { 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); + GitHandler gitHandler = gitHandlerRegistry.get(localLibrary.getParent()); + GitStatusSnapshot snapshot = GitStatusChecker.checkStatus(gitHandler); + assertTrue(snapshot.tracking()); assertEquals(SyncStatus.UP_TO_DATE, snapshot.syncStatus()); } @@ -159,14 +167,17 @@ void behindStatusWhenRemoteHasNewCommit(@TempDir Path tempDir) throws Exception .call(); } localGit.fetch().setRemote("origin").call(); - GitStatusSnapshot snapshot = GitStatusChecker.checkStatus(localLibrary); + GitHandler gitHandler = gitHandlerRegistry.get(localLibrary.getParent()); + GitStatusSnapshot snapshot = GitStatusChecker.checkStatus(gitHandler); + assertEquals(SyncStatus.BEHIND, snapshot.syncStatus()); } @Test void aheadStatusWhenLocalHasNewCommit() throws Exception { commitFile(localGit, localUpdatedContent, "Local update"); - GitStatusSnapshot snapshot = GitStatusChecker.checkStatus(localLibrary); + GitHandler gitHandler = gitHandlerRegistry.get(localLibrary.getParent()); + GitStatusSnapshot snapshot = GitStatusChecker.checkStatus(gitHandler); assertEquals(SyncStatus.AHEAD, snapshot.syncStatus()); } @@ -188,7 +199,8 @@ void divergedStatusWhenBothSidesHaveCommits(@TempDir Path tempDir) throws Except .call(); } localGit.fetch().setRemote("origin").call(); - GitStatusSnapshot snapshot = GitStatusChecker.checkStatus(localLibrary); + GitHandler gitHandler = gitHandlerRegistry.get(localLibrary.getParent()); + GitStatusSnapshot snapshot = GitStatusChecker.checkStatus(gitHandler); assertEquals(SyncStatus.DIVERGED, snapshot.syncStatus()); } diff --git a/jablib/src/test/java/org/jabref/logic/git/util/GitHandlerRegistryTest.java b/jablib/src/test/java/org/jabref/logic/git/util/GitHandlerRegistryTest.java new file mode 100644 index 00000000000..d7d8eb254a6 --- /dev/null +++ b/jablib/src/test/java/org/jabref/logic/git/util/GitHandlerRegistryTest.java @@ -0,0 +1,74 @@ +package org.jabref.logic.git.util; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Optional; + +import org.jabref.logic.git.GitHandler; + +import org.eclipse.jgit.api.Git; +import org.eclipse.jgit.internal.storage.file.WindowCache; +import org.eclipse.jgit.lib.RepositoryCache; +import org.eclipse.jgit.storage.file.WindowCacheConfig; +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.assertFalse; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class GitHandlerRegistryTest { + @TempDir + Path tempDir; + + private GitHandlerRegistry registry; + private Git testGit; + + @BeforeEach + void setUp() { + registry = new GitHandlerRegistry(); + } + + @AfterEach + void tearDown() { + if (testGit != null) { + testGit.close(); + } + + RepositoryCache.clear(); + WindowCache.reconfigure(new WindowCacheConfig()); + } + + @Test + void returnsSameHandlerForSameRepoPath() throws Exception { + testGit = Git.init().setDirectory(tempDir.toFile()).call(); + Path repoPath = tempDir.toAbsolutePath().normalize(); + + GitHandler handler1 = registry.get(repoPath); + GitHandler handler2 = registry.get(repoPath); + + assertSame(handler1, handler2); + } + + @Test + void returnsEmptyForNonGitPath() { + Path nonGitPath = tempDir.resolve("non-git-folder"); + assertFalse(registry.fromAnyPath(nonGitPath).isPresent()); + } + + @Test + void resolvesHandlerFromNestedPath() throws Exception { + testGit = Git.init().setDirectory(tempDir.toFile()).call(); + + Path subDir = Files.createDirectory(tempDir.resolve("nested")); + Optional handlerOpt = registry.fromAnyPath(subDir); + + assertTrue(handlerOpt.isPresent(), "Should resolve handler from subdirectory inside repo"); + + GitHandler handler1 = registry.get(tempDir); + GitHandler handler2 = handlerOpt.get(); + assertSame(handler1, handler2, "Should return same cached handler"); + } +} From 19d098b9339c580d054d2b667a4ded52acde5922 Mon Sep 17 00:00:00 2001 From: Wanling Fu Date: Wed, 6 Aug 2025 11:54:43 +0100 Subject: [PATCH 02/10] refactor SemanticConflictDetector Update SemanticConflictDetector --- docs/code-howtos/git.md | 83 +- .../conflicts/SemanticConflictDetector.java | 241 ++++-- .../util/SemanticConflictDetectorTest.java | 715 ++++++++++++++---- 3 files changed, 827 insertions(+), 212 deletions(-) diff --git a/docs/code-howtos/git.md b/docs/code-howtos/git.md index aa624c8cf85..a937c5b0355 100644 --- a/docs/code-howtos/git.md +++ b/docs/code-howtos/git.md @@ -84,22 +84,67 @@ The semantic conflict detection and merge resolution logic is covered by: 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. | +### Entry-level Conflict Cases (E-series) + +Each side (Base, Local, Remote) can take one of the following values: + +* `–`: entry does not exist (null) +* `S`: same as base +* `M`: modified (fields changed) + +> Note: Citation key is used as the entry identifier. Renaming a citation key is currently treated as deletion + addition and not supported as a standalone diff. + +| TestID | Base | Local | Remote | Description | Common Scenario | Conflict | +| ------ | ------------- | ----------------- | ----------------- | -------------------------------------------------------------- | ------------------------------------------------------------------------------ | -------- | +| E01 | – | – | – | All null | Entry absent on all sides | No | +| E02 | – | – | M | Remote added entry | Accept remote addition | No | +| E03 | – | M | – | Local added entry | Keep local addition | No | +| E04 | – | M | M | Both added entry with same citation key | If content is identical: no conflict; else: compare fields | Depends | +| E05 | S | – | – | Both deleted | Safe deletion | No | +| E06 | S | – | S | Local deleted, remote unchanged | Respect local deletion | No | +| E07 | S | – | M | Local deleted, remote modified | One side deleted, one side changed | Yes | +| E08 | S | S | – | Remote deleted, local unchanged | Accept remote deletion as no conflict | No | +| E09 | S | S | S | All sides equal | No changes | No | +| E10 | S | S | M | Remote modified, local unchanged | Accept remote changes | No | +| E11 | S | M | – | Remote deleted, local modified | One side deleted, one side changed | Yes | +| E12 | S | M | S | Local modified, remote unchanged | Accept local changes | No | +| E13 | S | M | M | Both sides modified | If changes are equal or to different fields: no conflict; else: compare fields | Depends | +| E14a | `@article{a}` | `@article{b}` | unchanged | Local renamed citation key | Treated as deletion + addition | Yes | +| E14b | `@article{a}` | unchanged | `@article{b}` | Remote renamed citation key | Treated as deletion + addition | Yes | +| E14c | `@article{a}` | `@article{b}` | `@article{c}` | Both renamed to different keys | Treated as deletion + addition | Yes | +| E15 | – | `@article{a,...}` | `@article{a,...}` | Both added entry with same citation key, but different content | Duplicate citation key from both sides | Yes | + +--- + +### Field-level Conflict Cases (F-series) + +Each individual field (such as title, author, etc.) may have one of the following statuses relative to the base version: + +* Unchanged: The field value is exactly the same as in the base version. +* Changed: The field value is different from the base version. +* Deleted: The field existed in the base version but is now missing (i.e., null). +* Added: The field did not exist in the base version but was added in the local or remote version. + +| TestID | Base | Local | Remote | Description | Conflict | +| ------ | ----------------------------- | ------------------- | ------------------- | ------------------------------------------ |----------| +| F01 | U | U | U | All equal | No | +| F02 | U | U | C | Remote changed | No | +| F03 | U | C | U | Local changed | No | +| F04 | U | C | C (=) | Both changed to same value | No | +| F05 | U | C | C (≠) | Both changed same field, different values | Yes | +| F06 | U | D | U | Local deleted | No | +| F07 | U | U | D | Remote deleted | No | +| F08 | U | D | D | Both deleted | No | +| F09 | U | C | D | Local changed, remote deleted | Yes | +| F10 | U | D | C | Local deleted, remote changed | Yes | +| F11 | – | – | – | Field missing on all sides | No | +| F12 | – | A | – | Local added field | No | +| F13 | – | – | A | Remote added field | No | +| F14 | – | A | A (=) | Both added same field with same value | No | +| F15 | – | A | A (≠) | Both added same field with different values | Yes | +| F16 | U | C | D | Changed in local, deleted in remote | Yes | +| F17 | U | D | C | Deleted in local, changed in remote | Yes | +| F18 | – | A | C | No base, both sides added different values | Yes | +| F19 | `{title=Hello, author=Alice}` | reordered | unchanged | Field order changed only | No | +| F20 | `@article{a}` | `@inproceedings{a}` | unchanged | Entry type changed in local | No | +| F21 | `@article{a}` | `@book{a}` | `@inproceedings{a}` | Both changed entry type differently | Yes | 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 index 1ffd468e11e..bb00e63686d 100644 --- a/jablib/src/main/java/org/jabref/logic/git/conflicts/SemanticConflictDetector.java +++ b/jablib/src/main/java/org/jabref/logic/git/conflicts/SemanticConflictDetector.java @@ -6,6 +6,7 @@ import java.util.Map; import java.util.Objects; import java.util.Optional; +import java.util.Set; import java.util.function.Function; import java.util.stream.Collectors; import java.util.stream.Stream; @@ -17,6 +18,8 @@ import org.jabref.model.entry.BibEntry; import org.jabref.model.entry.field.Field; +import static com.google.common.collect.Sets.union; + /// Detects semantic merge conflicts between base, local, and remote. /// /// Strategy: @@ -26,53 +29,204 @@ /// 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(); + // 1. get diffs between base, local and remote + BibDatabaseDiff localDiff = BibDatabaseDiff.compare(base, local); + BibDatabaseDiff remoteDiff = BibDatabaseDiff.compare(base, remote); - // 2. map citation key to entry for local/remote diffs - Map baseEntries = getCitationKeyToEntryMap(base); - Map localEntries = getCitationKeyToEntryMap(local); + // 2. Union of all citationKeys that were changed (on either side) + Map baseMap = getCitationKeyToEntryMap(base); + Map localMap = getCitationKeyToEntryMap(local); + Map remoteMap = getCitationKeyToEntryMap(remote); + + // 3. Build a map from citationKey -> BibEntryDiff for both local and remote diffs + Map localDiffMap = indexByCitationKey(localDiff.getEntryDifferences()); + Map remoteDiffMap = indexByCitationKey(remoteDiff.getEntryDifferences()); + Set allKeys = union(localDiffMap.keySet(), remoteDiffMap.keySet()); 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; + // 4. Build full 3-way entry maps (key -> entry) from each database + for (String key : allKeys) { + BibEntry baseEntry = baseMap.get(key); + BibEntry localEntry = resolveEntry(key, localDiffMap.get(key), localMap); + BibEntry remoteEntry = resolveEntry(key, remoteDiffMap.get(key), remoteMap); + + // 5. If this triplet results in a conflict, collect it + detectEntryConflict(baseEntry, localEntry, remoteEntry).ifPresent(conflicts::add); + } + + return conflicts; + } + + /** + * Detect entry-level conflicts among base, local, and remote versions of an entry. + *

+ * + * @param base the entry in the common ancestor + * @param local the entry in the local version + * @param remote the entry in the remote version + * @return optional conflict (if detected) + */ + private static Optional detectEntryConflict(BibEntry base, + BibEntry local, + BibEntry remote) { + // Case 0: all null → skip + if (base == null && local == null && remote == null) { + return Optional.empty(); + } + + // Case 2: Both local and remote added same citation key -> compare their fields + if (base == null && local != null && remote != null) { + if (hasConflictingFields(new BibEntry(), local, remote)) { + return Optional.of(new ThreeWayEntryConflict(null, local, remote)); + } else { + return Optional.empty(); } + } + + // Case 2: base exists, local deleted, remote unchanged -> accept local deletion + if (base != null && local == null && Objects.equals(base.getFieldMap(), remote.getFieldMap())) { + return Optional.empty(); + } - String citationKey = keyOpt.get(); - BibEntry baseEntry = baseEntries.get(citationKey); - BibEntry localEntry = localEntries.get(citationKey); - BibEntry remoteEntry = remoteDiff.newEntry(); + // Case 3: base exists, local unchanged, remote deleted or modified + if (base != null && local != null && remote != null) { + boolean localUnchanged = Objects.equals(base.getFieldMap(), local.getFieldMap()); + boolean remoteUnchanged = Objects.equals(base.getFieldMap(), remote.getFieldMap()); - // 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)); + boolean localChanged = !localUnchanged; + boolean remoteChanged = !remoteUnchanged; + + // Case: only one side changed -> no conflict + if (localChanged ^ remoteChanged) { + return Optional.empty(); + } + + // Case: both sides changed -> check for conflicting fields + if (localChanged && remoteChanged) { + if (hasConflictingFields(base, local, remote)) { + return Optional.of(new ThreeWayEntryConflict(base, local, remote)); } } } - return conflicts; + + // Case 4: base exists, one side deleted, other modified -> conflict + if (base != null) { + boolean localDeleted = (local == null); + boolean remoteDeleted = (remote == null); + + boolean localChanged = !localDeleted && !Objects.equals(base.getFieldMap(), local.getFieldMap()); + boolean remoteChanged = !remoteDeleted && !Objects.equals(base.getFieldMap(), remote.getFieldMap()); + + if ((localChanged && remoteDeleted) || (remoteChanged && localDeleted)) { + return Optional.of(new ThreeWayEntryConflict(base, local, remote)); + } + } + + // Case 5: base exists, both sides modified the entry -> check field-level diff + if (base != null && local != null && remote != null) { + if (hasConflictingFields(base, local, remote)) { + return Optional.of(new ThreeWayEntryConflict(base, local, remote)); + } + } + + return Optional.empty(); + } + + private static boolean hasConflictingFields(BibEntry base, BibEntry local, BibEntry remote) { + if (entryTypeChangedDifferently(base, local, remote)) { + return true; + } + + Set allFields = Stream.of(base, local, remote) + .flatMap(entry -> entry.getFields().stream()) + .collect(Collectors.toSet()); + + for (Field field : allFields) { + String baseVal = base.getField(field).orElse(null); + String localVal = local.getField(field).orElse(null); + String remoteVal = remote.getField(field).orElse(null); + + // Case 1: Both local and remote modified the same field from base, and the values differ + if (modifiedOnBothSidesWithDisagreement(baseVal, localVal, remoteVal)) { + return true; + } + + // Case 2: One side deleted the field, the other side modified it + if (oneSideDeletedOneSideModified(baseVal, localVal, remoteVal)) { + return true; + } + + // Case 3: Both sides added the field with different values + if (addedOnBothSidesWithDisagreement(baseVal, localVal, remoteVal)) { + return true; + } + } + + return false; + } + + private static boolean entryTypeChangedDifferently(BibEntry base, BibEntry local, BibEntry remote) { + if (base == null || local == null || remote == null) { + return false; + } + + boolean localChanged = !Objects.equals(base.getType(), local.getType()); + boolean remoteChanged = !Objects.equals(base.getType(), remote.getType()); + + return localChanged && remoteChanged && !Objects.equals(local.getType(), remote.getType()); + } + + private static boolean modifiedOnBothSidesWithDisagreement(String baseVal, String localVal, String remoteVal) { + return notEqual(baseVal, localVal) && notEqual(baseVal, remoteVal) && notEqual(localVal, remoteVal); + } + + private static boolean oneSideDeletedOneSideModified(String baseVal, String localVal, String remoteVal) { + if (localVal == null && remoteVal == null) { + return false; + } + + return (baseVal != null) + && ((localVal == null && notEqual(baseVal, remoteVal)) + || (remoteVal == null && notEqual(baseVal, localVal))); + } + + private static boolean addedOnBothSidesWithDisagreement(String baseVal, String localVal, String remoteVal) { + return baseVal == null && localVal != null && remoteVal != null && notEqual(localVal, remoteVal); + } + + private static boolean notEqual(String a, String b) { + return !Objects.equals(a, b); + } + + /** + * Converts a List of BibEntryDiff into a Map where the key is the citation key, + * and the value is the corresponding BibEntryDiff. + *

+ * Notes: + * - Only entries with a citation key are included (entries without a key cannot be uniquely identified during merge). + * - Entries that represent additions (base == null) or deletions (new == null) are also included. + * - If multiple BibEntryDiffs share the same citation key (rare), the latter one will overwrite the former. + *

+ * + * @param entryDiffs A list of entry diffs produced by BibDatabaseDiff + * @return A map from citation key to corresponding BibEntryDiff + */ + private static Map indexByCitationKey(List entryDiffs) { + Map result = new LinkedHashMap<>(); + + for (BibEntryDiff diff : entryDiffs) { + Optional citationKey = Optional.ofNullable(diff.newEntry()) + .flatMap(BibEntry::getCitationKey) + .or(() -> Optional.ofNullable(diff.originalEntry()) + .flatMap(BibEntry::getCitationKey)); + citationKey.ifPresent(key -> result.put(key, diff)); + } + + return result; } private static Map getCitationKeyToEntryMap(BibDatabaseContext context) { @@ -86,20 +240,11 @@ private static Map getCitationKeyToEntryMap(BibDatabaseContext )); } - 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); - }); + private static BibEntry resolveEntry(String key, BibEntryDiff diff, Map fullMap) { + if (diff == null) { + return fullMap.get(key); + } + return diff.newEntry(); } /** 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 index 31da078d777..ef42db0c191 100644 --- a/jablib/src/test/java/org/jabref/logic/git/util/SemanticConflictDetectorTest.java +++ b/jablib/src/test/java/org/jabref/logic/git/util/SemanticConflictDetectorTest.java @@ -99,369 +99,759 @@ private RevCommit writeAndCommit(String content, String message, PersonIdent aut // See docs/code-howtos/git.md for testing patterns static Stream semanticConflicts() { return Stream.of( - Arguments.of("T1 - remote changed a field, local unchanged", + Arguments.of("E01 - entry a does not exist anywhere", + "", + "", + "", + false), + + Arguments.of("E02 - entry a added remotely only", + "", + "", """ - @article{a, - author = {Test Author}, - doi = {xya}, - } + @article{a, + author = {remote}, + title = {A}, + } + """, + false), + + Arguments.of("E03 - entry a added locally only", + "", + """ + @article{a, + author = {local}, + title = {A}, + } + """, + "", + false), + + Arguments.of("E04 - entry a added on both sides with different content", + "", + """ + @article{a, + author = {local}, + title = {A}, + } + """, + """ + @article{a, + author = {remote}, + title = {A}, + } """, + true), + + Arguments.of("E04a - both sides added entry a with identical content", + "", """ @article{a, - author = {Test Author}, - doi = {xya}, + author = {same}, + title = {A}, } """, """ @article{a, - author = {bob}, - doi = {xya}, + author = {same}, + title = {A}, } """, false ), - Arguments.of("T2 - local changed a field, remote unchanged", + + Arguments.of("E04b - both added entry a but changed different fields", + "", """ @article{a, - author = {Test Author}, - doi = {xya}, + author = {local}, } """, """ @article{a, - author = {alice}, - doi = {xya}, + journal = {Remote Journal}, } """, + false + ), + + Arguments.of("E04c - both added entry a with conflicting field values", + "", """ @article{a, - author = {Test Author}, - doi = {xya}, + author = {local}, + title = {A}, } """, - false + """ + @article{a, + author = {remote}, + title = {A}, + } + """, + true ), - Arguments.of("T3 - both changed to same value", + + Arguments.of("E05 - entry a was deleted by both", """ @article{a, - author = {Test Author}, - doi = {xya}, + author = {base}, + title = {A}, } """, + "", + "", + false), + + Arguments.of("E06 - local deleted entry a, remote kept it unchanged", """ @article{a, - author = {bob}, - doi = {xya}, + author = {base}, + title = {A}, } """, + "", """ @article{a, - author = {bob}, - doi = {xya}, + author = {base}, + title = {A}, } """, - false - ), - Arguments.of("T4 - both changed to different values", + false), + + Arguments.of("E07 - local deleted entry a, remote modified it", """ @article{a, - author = {Test Author}, - doi = {xya}, + author = {base}, + title = {A}, } """, + "", """ @article{a, - author = {alice}, - doi = {xya}, + author = {remote}, + title = {A}, } """, + true), + + Arguments.of("E08 - remote deleted entry a, local kept it unchanged", """ @article{a, - author = {bob}, - doi = {xya}, + author = {base}, + title = {A}, } """, - true - ), - Arguments.of("T5 - local deleted field, remote changed it", """ @article{a, - author = {Test Author}, - doi = {xya}, + author = {base}, + title = {A}, } """, + "", + false), + + Arguments.of("E09 - entry a unchanged in all three", + """ + @article{a, + author = {base}, + title = {A}, + }""", """ @article{a, + author = {base}, + title = {A}, } """, """ @article{a, - author = {bob}, - doi = {xya}, + author = {base}, + title = {A}, } """, - true + false), + + Arguments.of("E10a - remote modified a different field, local unchanged", + """ + @article{a, + author = {base}, + title = {A}, + } + """, + """ + @article{a, + author = {base}, + title = {A}, + } + """, + """ + @article{a, + author = {base}, + year = {2025}, + } + """, + false ), - Arguments.of("T6 - local changed, remote deleted", + + Arguments.of("E10b - remote modified the same field, local unchanged", """ - @article{a, - author = {Test Author}, - doi = {xya}, - } + @article{a, + author = {base}, + title = {A}, + } + """, + """ + @article{a, + author = {base}, + title = {A}, + } + """, + """ + @article{a, + author = {remote}, + title = {A}, + } """, + false + ), + + Arguments.of("E11 - remote deleted entry a, local modified it", """ @article{a, - author = {alice}, - doi = {xya}, + author = {base}, + title = {A}, } """, """ @article{a, + author = {local}, + title = {A}, } """, - true - ), - Arguments.of("T7 - remote deleted, local unchanged", + "", + true), + + Arguments.of("E12 - local modified entry a, remote unchanged", """ @article{a, - author = {Test Author}, - doi = {xya}, + author = {base}, + title = {A}, } """, """ @article{a, - author = {Test Author}, - doi = {xya}, + author = {local}, + title = {A}, } """, """ @article{a, + author = {base}, + title = {A}, } """, - false - ), - Arguments.of("T8 - local changed field A, remote changed field B", + false), + + Arguments.of("E13a - both modified entry a but changed different fields", """ @article{a, - author = {Test Author}, - doi = {xya}, + author = {base}, + title = {A}, } """, """ @article{a, - author = {alice}, - doi = {xya}, + author = {local}, + title = {A}, } """, """ @article{a, - author = {Test Author}, - doi = {xyz}, + author = {base}, + title = {B}, } """, false ), - Arguments.of("T9 - field order changed only", + + Arguments.of("E13b - both changed same field to same value", """ @article{a, - author = {Test Author}, - doi = {xya}, + author = {base}, } """, """ @article{a, - doi = {xya}, - author = {Test Author}, + author = {common}, } """, """ @article{a, - author = {Test Author}, - doi = {xya}, + author = {common}, } """, false ), - Arguments.of("T10 - local changed entry a, remote changed entry b", + + Arguments.of("E13c - both changed same field differently", """ @article{a, - author = {Test Author}, - doi = {xya}, - } - - @article{b, - author = {Test Author}, - doi = {xyz}, + author = {base}, } """, """ @article{a, - author = {author-a}, - doi = {xya}, + author = {local}, } - @article{b, - author = {Test Author}, - doi = {xyz}, + """, + """ + @article{a, + author = {remote}, + } + """, + true + ), + Arguments.of("E14a - citationKey changed in local", + """ + @article{a, + author = {base}, } """, """ @article{b, - author = {author-b}, - doi = {xyz}, + author = {base}, } - + """, + """ @article{a, - author = {Test Author}, - doi = {xya}, + author = {base}, } """, false ), - Arguments.of("T11 - remote added field, local unchanged", + Arguments.of("E14b - citationKey changed in remote", """ @article{a, - author = {Test Author}, - doi = {xya}, + author = {base}, } """, """ @article{a, - author = {Test Author}, - doi = {xya}, + author = {base}, } """, """ - @article{a, - author = {Test Author}, - doi = {xya}, - year = {2025}, + @article{b, + author = {base}, } """, false ), - Arguments.of("T12 - both added same field with different values", + Arguments.of("E14c - citationKey renamed differently in local and remote", """ @article{a, - author = {Test Author}, - doi = {xya}, + author = {base}, + } + """, + """ + @article{b, + author = {base}, + } + """, + """ + @article{c, + author = {base}, } """, + false + ), + Arguments.of("E15 - both added same citationKey with different content", + """ + """, """ @article{a, - author = {Test Author}, - doi = {xya}, - year = {2023}, + title = {local}, } """, """ @article{a, - author = {Test Author}, - doi = {xya}, - year = {2025}, + title = {remote}, } """, true ), - Arguments.of("T13 - local added field, remote unchanged", + Arguments.of("F01 - identical field value on all sides", + """ + @article{a, + author = {same}, + } + """, + """ + @article{a, + author = {same}, + } + """, + """ + @article{a, + author = {same}, + } + """, + false + ), + Arguments.of("F02 - remote changed, local same as base", + """ + @article{a, + author = {base}, + } + """, + """ + @article{a, + author = {base}, + } + """, + """ + @article{a, + author = {remote}, + } + """, + false + ), + Arguments.of("F03 - local changed, remote same as base", + """ + @article{a, + author = {base}, + } + """, + """ + @article{a, + author = {local}, + } + """, + """ + @article{a, + author = {base}, + } + """, + false + ), + Arguments.of("F04 - both changed to same value", + """ + @article{a, + author = {base}, + } + """, + """ + @article{a, + author = {common}, + } + """, + """ + @article{a, + author = {common}, + } + """, + false + ), + Arguments.of("F05 - both changed same field differently", + """ + @article{a, + author = {base}, + } + """, + """ + @article{a, + author = {local}, + } + """, + """ + @article{a, + author = {remote}, + } + """, + true + ), + Arguments.of("F06 - Local deleted, remote unchanged", """ @article{a, - author = {Test Author}, - doi = {xya}, - } + author = {base} + } + """, + """ + @article{a, + } """, """ @article{a, - author = {Test Author}, - doi = {newfield}, - } + author = {base} + } """, + false + ), + Arguments.of("F07 - Remote deleted, local unchanged", """ @article{a, - author = {Test Author}, - doi = {xya}, - } + author = {base} + } + """, + """ + @article{a, + author = {base} + } + """, + """ + @article{a, + } """, false ), - Arguments.of("T14 - both added same field with same value", + Arguments.of("F08 - Both deleted", + """ + @article{a, + author = {base} + } + """, + """ + @article{a, + } + """, + """ + @article{a, + } + """, + false + ), + Arguments.of("F09 - Local changed, remote deleted", + """ + @article{a, + author = {base} + } + """, + """ + @article{a, + author = {local} + } + """, + """ + @article{a, + }""", + true + ), + + Arguments.of("F10 - Local deleted, remote changed", + """ + @article{a, + author = {base} + } + """, + """ + @article{a, + } + """, + """ + @article{a, + author = {remote} + } + """, + true + ), + + Arguments.of("F11 - All missing", """ @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", + + Arguments.of("F12 - Local added", + """ + @article{a, + } + """, """ @article{a, - author = {Test Author}, - doi = {xya}, + author = {local} + } + """, + """ + @article{a, + } + """, + false + ), + + Arguments.of("F13 - Remote added", + """ + @article{a, + } + """, + """ + @article{a, + } + """, + """ + @article{a, + author = {remote} + } + """, + false + ), + + Arguments.of("F14 - Both added same value", + """ + @article{a, + } + """, + """ + @article{a, + author = {same} + } + """, + """ + @article{a, + author = {same} + } + """, + false + ), + + Arguments.of("F15 - Both added different values", + """ + @article{a, + } + """, + """ + @article{a, + author = {local} + } + """, + """ + @article{a, + author = {remote} + } + """, + true + ), + + Arguments.of("F16 - Local modified, remote deleted (conflict)", + """ + @article{a, + author = {base} + } + """, + """ + @article{a, + author = {local} + } + """, + """ + @article{a, + }""", + true + ), + + Arguments.of("F17 - Local deleted and remote modified (conflict)", + """ + @article{a, + author = {base} } """, """ @article{a, - author = {Test Author}, - doi = {value1}, } """, """ @article{a, - author = {Test Author}, - doi = {value2}, + author = {remote} } """, true ), - Arguments.of("T16 - both sides added entry a with different values", - "", + + Arguments.of("F18 - No base, both added different", + """ + @article{a, + }""", """ @article{a, - author = {alice}, - doi = {xya}, + author = {local} } """, """ @article{a, - author = {bob}, - doi = {xya}, + author = {remote} } """, true ), - Arguments.of("T17 - both added same content", - "", + Arguments.of("F19 - field order changed, same content", """ @article{a, - author = {same}, - doi = {123}, + title = {Hello}, + author = {Alice}, } """, """ @article{a, - author = {same}, - doi = {123}, + author = {Alice}, + title = {Hello}, + } + """, + """ + @article{a, + title = {Hello}, + author = {Alice}, + } + """, + false + ), + Arguments.of("F20 - entryType changed in local", + """ + @article{a, + author = {base}, + } + """, + """ + @book{a, + author = {base}, + } + """, + """ + @article{a, + author = {base}, } """, false + ), + Arguments.of("F21 - entryType changed differently on both sides", + """ + + """, + """ + @book{a, + author = {base}, + } + """, + """ + @inproceedings{a, + author = {base}, + } + """, + true ) ); } @Test - void extractMergePlanT10OnlyRemoteChangedEntryB() throws Exception { + void extractMergePlanOnlyRemoteChangedEntryB() throws Exception { String base = """ @article{a, author = {Test Author}, @@ -498,7 +888,7 @@ void extractMergePlanT10OnlyRemoteChangedEntryB() throws Exception { } @Test - void extractMergePlanT11RemoteAddsField() throws Exception { + void extractMergePlanRemoteAddsField() throws Exception { String base = """ @article{a, author = {Test Author}, @@ -524,4 +914,39 @@ void extractMergePlanT11RemoteAddsField() throws Exception { Map patch = plan.fieldPatches().get("a"); assertEquals("2025", patch.get(StandardField.YEAR)); } + + @Test + void noConflictWhenOnlyLineEndingsDiffer() throws Exception { + String base = """ + @article{a, + comment = {line1\n\nline3\n\nline5}, + } + """; + String local = """ + @article{a, + comment = {line1\r\n\r\nline3\r\n\r\nline5}, + } + """; + String remote = """ + @article{a, + comment = {line1\n\nline3\n\nline5}, + } + """; + + 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 + ); + + assertTrue(diffs.isEmpty(), "Line ending differences should not be considered conflicts"); + } } From 7358c892b1b6022df07299522795dc7d2dfc4caa Mon Sep 17 00:00:00 2001 From: Wanling Fu Date: Wed, 6 Aug 2025 12:17:16 +0100 Subject: [PATCH 03/10] chore(rewrite) Apply OpenRewrite auto-fixes for code style compliance --- jablib/src/main/java/org/jabref/logic/git/GitSyncService.java | 2 +- .../jabref/logic/git/conflicts/SemanticConflictDetector.java | 4 ++-- .../java/org/jabref/logic/git/util/GitHandlerRegistry.java | 4 ++++ 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/jablib/src/main/java/org/jabref/logic/git/GitSyncService.java b/jablib/src/main/java/org/jabref/logic/git/GitSyncService.java index 4943bcf2669..ad13d01b816 100644 --- a/jablib/src/main/java/org/jabref/logic/git/GitSyncService.java +++ b/jablib/src/main/java/org/jabref/logic/git/GitSyncService.java @@ -54,7 +54,7 @@ public GitSyncService(ImportFormatPreferences importFormatPreferences, GitHandle public MergeResult fetchAndMerge(BibDatabaseContext localDatabaseContext, Path bibFilePath) throws GitAPIException, IOException, JabRefException { Optional repoRoot = GitHandler.findRepositoryRoot(bibFilePath); if (repoRoot.isEmpty()) { - LOGGER.warn("Path is not inside a Git repository"); + LOGGER.warn("Path is not inside a Git repository."); return MergeResult.failure(); } GitHandler gitHandler = gitHandlerRegistry.get(repoRoot.get()); 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 index bb00e63686d..f1826cc3e17 100644 --- a/jablib/src/main/java/org/jabref/logic/git/conflicts/SemanticConflictDetector.java +++ b/jablib/src/main/java/org/jabref/logic/git/conflicts/SemanticConflictDetector.java @@ -115,8 +115,8 @@ private static Optional detectEntryConflict(BibEntry base // Case 4: base exists, one side deleted, other modified -> conflict if (base != null) { - boolean localDeleted = (local == null); - boolean remoteDeleted = (remote == null); + boolean localDeleted = local == null; + boolean remoteDeleted = remote == null; boolean localChanged = !localDeleted && !Objects.equals(base.getFieldMap(), local.getFieldMap()); boolean remoteChanged = !remoteDeleted && !Objects.equals(base.getFieldMap(), remote.getFieldMap()); diff --git a/jablib/src/main/java/org/jabref/logic/git/util/GitHandlerRegistry.java b/jablib/src/main/java/org/jabref/logic/git/util/GitHandlerRegistry.java index 2a131272738..e362071e0fd 100644 --- a/jablib/src/main/java/org/jabref/logic/git/util/GitHandlerRegistry.java +++ b/jablib/src/main/java/org/jabref/logic/git/util/GitHandlerRegistry.java @@ -21,6 +21,10 @@ public class GitHandlerRegistry { private final Map handlerCache = new ConcurrentHashMap<>(); public GitHandler get(Path repoPath) { + if (repoPath == null) { + throw new IllegalArgumentException("repoPath must not be null"); + } + Path normalized = repoPath.toAbsolutePath().normalize(); return handlerCache.computeIfAbsent(normalized, GitHandler::new); } From 93460f124362b128ff1ac9c1e4877365fa06b63b Mon Sep 17 00:00:00 2001 From: Wanling Fu Date: Wed, 6 Aug 2025 12:20:21 +0100 Subject: [PATCH 04/10] test From 687698cb0aad12fd706b0b32ba6ca67475533139 Mon Sep 17 00:00:00 2001 From: Wanling Fu Date: Wed, 6 Aug 2025 12:31:23 +0100 Subject: [PATCH 05/10] fix: add null check --- .../java/org/jabref/logic/git/util/GitHandlerRegistry.java | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/jablib/src/main/java/org/jabref/logic/git/util/GitHandlerRegistry.java b/jablib/src/main/java/org/jabref/logic/git/util/GitHandlerRegistry.java index e362071e0fd..e41169892fb 100644 --- a/jablib/src/main/java/org/jabref/logic/git/util/GitHandlerRegistry.java +++ b/jablib/src/main/java/org/jabref/logic/git/util/GitHandlerRegistry.java @@ -22,7 +22,7 @@ public class GitHandlerRegistry { public GitHandler get(Path repoPath) { if (repoPath == null) { - throw new IllegalArgumentException("repoPath must not be null"); + throw new IllegalArgumentException("Path must not be null"); } Path normalized = repoPath.toAbsolutePath().normalize(); @@ -30,6 +30,10 @@ public GitHandler get(Path repoPath) { } public Optional fromAnyPath(Path anyPathInsideRepo) { + if (anyPathInsideRepo == null) { + throw new IllegalArgumentException("Path must not be null"); + } + return GitHandler.findRepositoryRoot(anyPathInsideRepo) .map(this::get); } From 8a7d1f6312ff4a2a162ded12b1af334ddd980f52 Mon Sep 17 00:00:00 2001 From: Wanling Fu Date: Wed, 6 Aug 2025 13:28:39 +0100 Subject: [PATCH 06/10] fix: SemanticConflictDetector logic Case comments are consistent with actual logic. --- .../conflicts/SemanticConflictDetector.java | 30 +++++-------------- .../logic/git/util/GitHandlerRegistry.java | 2 +- 2 files changed, 9 insertions(+), 23 deletions(-) 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 index f1826cc3e17..703c3b8bae1 100644 --- a/jablib/src/main/java/org/jabref/logic/git/conflicts/SemanticConflictDetector.java +++ b/jablib/src/main/java/org/jabref/logic/git/conflicts/SemanticConflictDetector.java @@ -78,7 +78,7 @@ private static Optional detectEntryConflict(BibEntry base return Optional.empty(); } - // Case 2: Both local and remote added same citation key -> compare their fields + // Case 1: Both local and remote added same citation key -> compare their fields if (base == null && local != null && remote != null) { if (hasConflictingFields(new BibEntry(), local, remote)) { return Optional.of(new ThreeWayEntryConflict(null, local, remote)); @@ -92,32 +92,15 @@ private static Optional detectEntryConflict(BibEntry base return Optional.empty(); } - // Case 3: base exists, local unchanged, remote deleted or modified - if (base != null && local != null && remote != null) { - boolean localUnchanged = Objects.equals(base.getFieldMap(), local.getFieldMap()); - boolean remoteUnchanged = Objects.equals(base.getFieldMap(), remote.getFieldMap()); - - boolean localChanged = !localUnchanged; - boolean remoteChanged = !remoteUnchanged; - - // Case: only one side changed -> no conflict - if (localChanged ^ remoteChanged) { - return Optional.empty(); - } - - // Case: both sides changed -> check for conflicting fields - if (localChanged && remoteChanged) { - if (hasConflictingFields(base, local, remote)) { - return Optional.of(new ThreeWayEntryConflict(base, local, remote)); - } - } + // Case 3: base exists, remote deleted, local unchanged + if (base != null && remote == null && Objects.equals(base.getFieldMap(), local.getFieldMap())) { + return Optional.empty(); } // Case 4: base exists, one side deleted, other modified -> conflict if (base != null) { boolean localDeleted = local == null; boolean remoteDeleted = remote == null; - boolean localChanged = !localDeleted && !Objects.equals(base.getFieldMap(), local.getFieldMap()); boolean remoteChanged = !remoteDeleted && !Objects.equals(base.getFieldMap(), remote.getFieldMap()); @@ -128,7 +111,10 @@ private static Optional detectEntryConflict(BibEntry base // Case 5: base exists, both sides modified the entry -> check field-level diff if (base != null && local != null && remote != null) { - if (hasConflictingFields(base, local, remote)) { + boolean localChanged = !Objects.equals(base.getFieldMap(), local.getFieldMap()); + boolean remoteChanged = !Objects.equals(base.getFieldMap(), remote.getFieldMap()); + + if (localChanged && remoteChanged && hasConflictingFields(base, local, remote)) { return Optional.of(new ThreeWayEntryConflict(base, local, remote)); } } diff --git a/jablib/src/main/java/org/jabref/logic/git/util/GitHandlerRegistry.java b/jablib/src/main/java/org/jabref/logic/git/util/GitHandlerRegistry.java index e41169892fb..7c6c50a26ab 100644 --- a/jablib/src/main/java/org/jabref/logic/git/util/GitHandlerRegistry.java +++ b/jablib/src/main/java/org/jabref/logic/git/util/GitHandlerRegistry.java @@ -33,7 +33,7 @@ public Optional fromAnyPath(Path anyPathInsideRepo) { if (anyPathInsideRepo == null) { throw new IllegalArgumentException("Path must not be null"); } - + return GitHandler.findRepositoryRoot(anyPathInsideRepo) .map(this::get); } From 8de1d4f69d28daf21f49c329f39ba992095ed432 Mon Sep 17 00:00:00 2001 From: Wanling Fu Date: Wed, 6 Aug 2025 13:35:53 +0100 Subject: [PATCH 07/10] refactor: remove redundant deletion checks --- .../conflicts/SemanticConflictDetector.java | 19 ++----------------- 1 file changed, 2 insertions(+), 17 deletions(-) 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 index 703c3b8bae1..37a2430a84f 100644 --- a/jablib/src/main/java/org/jabref/logic/git/conflicts/SemanticConflictDetector.java +++ b/jablib/src/main/java/org/jabref/logic/git/conflicts/SemanticConflictDetector.java @@ -73,11 +73,6 @@ public static List detectConflicts(BibDatabaseContext bas private static Optional detectEntryConflict(BibEntry base, BibEntry local, BibEntry remote) { - // Case 0: all null → skip - if (base == null && local == null && remote == null) { - return Optional.empty(); - } - // Case 1: Both local and remote added same citation key -> compare their fields if (base == null && local != null && remote != null) { if (hasConflictingFields(new BibEntry(), local, remote)) { @@ -87,17 +82,7 @@ private static Optional detectEntryConflict(BibEntry base } } - // Case 2: base exists, local deleted, remote unchanged -> accept local deletion - if (base != null && local == null && Objects.equals(base.getFieldMap(), remote.getFieldMap())) { - return Optional.empty(); - } - - // Case 3: base exists, remote deleted, local unchanged - if (base != null && remote == null && Objects.equals(base.getFieldMap(), local.getFieldMap())) { - return Optional.empty(); - } - - // Case 4: base exists, one side deleted, other modified -> conflict + // Case 2: base exists, one side deleted, other modified -> conflict if (base != null) { boolean localDeleted = local == null; boolean remoteDeleted = remote == null; @@ -109,7 +94,7 @@ private static Optional detectEntryConflict(BibEntry base } } - // Case 5: base exists, both sides modified the entry -> check field-level diff + // Case 3: base exists, both sides modified the entry -> check field-level diff if (base != null && local != null && remote != null) { boolean localChanged = !Objects.equals(base.getFieldMap(), local.getFieldMap()); boolean remoteChanged = !Objects.equals(base.getFieldMap(), remote.getFieldMap()); From 88de9dfd7f799c030a9276d023dcb969edbd1d08 Mon Sep 17 00:00:00 2001 From: Wanling Fu Date: Wed, 6 Aug 2025 20:59:37 +0100 Subject: [PATCH 08/10] Replace redundant null checks and use equals() where safe --- .../conflicts/SemanticConflictDetector.java | 19 ++++++++++--------- .../logic/git/util/GitHandlerRegistry.java | 14 ++++---------- 2 files changed, 14 insertions(+), 19 deletions(-) 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 index 37a2430a84f..6ba97f787e0 100644 --- a/jablib/src/main/java/org/jabref/logic/git/conflicts/SemanticConflictDetector.java +++ b/jablib/src/main/java/org/jabref/logic/git/conflicts/SemanticConflictDetector.java @@ -84,10 +84,10 @@ private static Optional detectEntryConflict(BibEntry base // Case 2: base exists, one side deleted, other modified -> conflict if (base != null) { - boolean localDeleted = local == null; - boolean remoteDeleted = remote == null; - boolean localChanged = !localDeleted && !Objects.equals(base.getFieldMap(), local.getFieldMap()); - boolean remoteChanged = !remoteDeleted && !Objects.equals(base.getFieldMap(), remote.getFieldMap()); + boolean localDeleted = (local == null); + boolean remoteDeleted = (remote == null); + boolean localChanged = !localDeleted && !base.getFieldMap().equals(local.getFieldMap()); + boolean remoteChanged = !remoteDeleted && !base.getFieldMap().equals(remote.getFieldMap()); if ((localChanged && remoteDeleted) || (remoteChanged && localDeleted)) { return Optional.of(new ThreeWayEntryConflict(base, local, remote)); @@ -96,8 +96,8 @@ private static Optional detectEntryConflict(BibEntry base // Case 3: base exists, both sides modified the entry -> check field-level diff if (base != null && local != null && remote != null) { - boolean localChanged = !Objects.equals(base.getFieldMap(), local.getFieldMap()); - boolean remoteChanged = !Objects.equals(base.getFieldMap(), remote.getFieldMap()); + boolean localChanged = !base.getFieldMap().equals(local.getFieldMap()); + boolean remoteChanged = !base.getFieldMap().equals(remote.getFieldMap()); if (localChanged && remoteChanged && hasConflictingFields(base, local, remote)) { return Optional.of(new ThreeWayEntryConflict(base, local, remote)); @@ -145,10 +145,11 @@ private static boolean entryTypeChangedDifferently(BibEntry base, BibEntry local return false; } - boolean localChanged = !Objects.equals(base.getType(), local.getType()); - boolean remoteChanged = !Objects.equals(base.getType(), remote.getType()); + boolean localChanged = !base.getType().equals(local.getType()); + boolean remoteChanged = !base.getType().equals(remote.getType()); + boolean changedToDifferentTypes = !local.getType().equals(remote.getType()); - return localChanged && remoteChanged && !Objects.equals(local.getType(), remote.getType()); + return localChanged && remoteChanged && changedToDifferentTypes; } private static boolean modifiedOnBothSidesWithDisagreement(String baseVal, String localVal, String remoteVal) { diff --git a/jablib/src/main/java/org/jabref/logic/git/util/GitHandlerRegistry.java b/jablib/src/main/java/org/jabref/logic/git/util/GitHandlerRegistry.java index 7c6c50a26ab..4936213f5b9 100644 --- a/jablib/src/main/java/org/jabref/logic/git/util/GitHandlerRegistry.java +++ b/jablib/src/main/java/org/jabref/logic/git/util/GitHandlerRegistry.java @@ -2,6 +2,7 @@ import java.nio.file.Path; import java.util.Map; +import java.util.Objects; import java.util.Optional; import java.util.concurrent.ConcurrentHashMap; @@ -21,20 +22,13 @@ public class GitHandlerRegistry { private final Map handlerCache = new ConcurrentHashMap<>(); public GitHandler get(Path repoPath) { - if (repoPath == null) { - throw new IllegalArgumentException("Path must not be null"); - } - - Path normalized = repoPath.toAbsolutePath().normalize(); + Path normalized = Objects.requireNonNull(repoPath, "Path must not be null") + .toAbsolutePath().normalize(); return handlerCache.computeIfAbsent(normalized, GitHandler::new); } public Optional fromAnyPath(Path anyPathInsideRepo) { - if (anyPathInsideRepo == null) { - throw new IllegalArgumentException("Path must not be null"); - } - - return GitHandler.findRepositoryRoot(anyPathInsideRepo) + return GitHandler.findRepositoryRoot(Objects.requireNonNull(anyPathInsideRepo, "Path must not be null")) .map(this::get); } } From 3da6327c57ae4c940576e77defc47c17f2b1a61a Mon Sep 17 00:00:00 2001 From: Wanling Fu Date: Wed, 6 Aug 2025 22:50:27 +0100 Subject: [PATCH 09/10] chore(rewrite) --- .../jabref/logic/git/conflicts/SemanticConflictDetector.java | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) 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 index 6ba97f787e0..f501d0055e6 100644 --- a/jablib/src/main/java/org/jabref/logic/git/conflicts/SemanticConflictDetector.java +++ b/jablib/src/main/java/org/jabref/logic/git/conflicts/SemanticConflictDetector.java @@ -84,8 +84,9 @@ private static Optional detectEntryConflict(BibEntry base // Case 2: base exists, one side deleted, other modified -> conflict if (base != null) { - boolean localDeleted = (local == null); - boolean remoteDeleted = (remote == null); + boolean localDeleted = local == null; + boolean remoteDeleted = remote == null; + boolean localChanged = !localDeleted && !base.getFieldMap().equals(local.getFieldMap()); boolean remoteChanged = !remoteDeleted && !base.getFieldMap().equals(remote.getFieldMap()); From 1ef48978a894571819ef36e85f51059fd4f7f5e3 Mon Sep 17 00:00:00 2001 From: Wanling Fu Date: Thu, 7 Aug 2025 14:46:04 +0100 Subject: [PATCH 10/10] chore(docs): adjust table formatting --- docs/code-howtos/git.md | 44 ++++++++++++++++++++--------------------- 1 file changed, 22 insertions(+), 22 deletions(-) diff --git a/docs/code-howtos/git.md b/docs/code-howtos/git.md index a937c5b0355..135de1e4713 100644 --- a/docs/code-howtos/git.md +++ b/docs/code-howtos/git.md @@ -125,26 +125,26 @@ Each individual field (such as title, author, etc.) may have one of the followin * Deleted: The field existed in the base version but is now missing (i.e., null). * Added: The field did not exist in the base version but was added in the local or remote version. -| TestID | Base | Local | Remote | Description | Conflict | -| ------ | ----------------------------- | ------------------- | ------------------- | ------------------------------------------ |----------| -| F01 | U | U | U | All equal | No | -| F02 | U | U | C | Remote changed | No | -| F03 | U | C | U | Local changed | No | -| F04 | U | C | C (=) | Both changed to same value | No | -| F05 | U | C | C (≠) | Both changed same field, different values | Yes | -| F06 | U | D | U | Local deleted | No | -| F07 | U | U | D | Remote deleted | No | -| F08 | U | D | D | Both deleted | No | -| F09 | U | C | D | Local changed, remote deleted | Yes | -| F10 | U | D | C | Local deleted, remote changed | Yes | -| F11 | – | – | – | Field missing on all sides | No | -| F12 | – | A | – | Local added field | No | -| F13 | – | – | A | Remote added field | No | -| F14 | – | A | A (=) | Both added same field with same value | No | +| TestID | Base | Local | Remote | Description | Conflict | +|--------|-------------------------------|---------------------|---------------------|---------------------------------------------|----------| +| F01 | U | U | U | All equal | No | +| F02 | U | U | C | Remote changed | No | +| F03 | U | C | U | Local changed | No | +| F04 | U | C | C (=) | Both changed to same value | No | +| F05 | U | C | C (≠) | Both changed same field, different values | Yes | +| F06 | U | D | U | Local deleted | No | +| F07 | U | U | D | Remote deleted | No | +| F08 | U | D | D | Both deleted | No | +| F09 | U | C | D | Local changed, remote deleted | Yes | +| F10 | U | D | C | Local deleted, remote changed | Yes | +| F11 | – | – | – | Field missing on all sides | No | +| F12 | – | A | – | Local added field | No | +| F13 | – | – | A | Remote added field | No | +| F14 | – | A | A (=) | Both added same field with same value | No | | F15 | – | A | A (≠) | Both added same field with different values | Yes | -| F16 | U | C | D | Changed in local, deleted in remote | Yes | -| F17 | U | D | C | Deleted in local, changed in remote | Yes | -| F18 | – | A | C | No base, both sides added different values | Yes | -| F19 | `{title=Hello, author=Alice}` | reordered | unchanged | Field order changed only | No | -| F20 | `@article{a}` | `@inproceedings{a}` | unchanged | Entry type changed in local | No | -| F21 | `@article{a}` | `@book{a}` | `@inproceedings{a}` | Both changed entry type differently | Yes | +| F16 | U | C | D | Changed in local, deleted in remote | Yes | +| F17 | U | D | C | Deleted in local, changed in remote | Yes | +| F18 | – | A | C | No base, both sides added different values | Yes | +| F19 | `{title=Hello, author=Alice}` | reordered | unchanged | Field order changed only | No | +| F20 | `@article{a}` | `@inproceedings{a}` | unchanged | Entry type changed in local | No | +| F21 | `@article{a}` | `@book{a}` | `@inproceedings{a}` | Both changed entry type differently | Yes |