From 43ca5f70b94d1dee7ffaaa194c79bc967dcd1bed Mon Sep 17 00:00:00 2001 From: Wanling Fu Date: Wed, 28 May 2025 21:25:57 +0100 Subject: [PATCH 01/37] init: add basic test case for git sync service --- .../org/jabref/logic/git/GitSyncService.java | 4 + .../jabref/logic/git/util/GitBibParser.java | 18 ++ .../jabref/logic/git/util/GitFileReader.java | 41 +++++ .../git/util/SemanticConflictDetector.java | 97 +++++++++++ .../jabref/logic/git/GitSyncServiceTest.java | 151 +++++++++++++++++ .../util/SemanticConflictDetectorTest.java | 160 ++++++++++++++++++ 6 files changed, 471 insertions(+) create mode 100644 jablib/src/main/java/org/jabref/logic/git/GitSyncService.java create mode 100644 jablib/src/main/java/org/jabref/logic/git/util/GitBibParser.java create mode 100644 jablib/src/main/java/org/jabref/logic/git/util/GitFileReader.java create mode 100644 jablib/src/main/java/org/jabref/logic/git/util/SemanticConflictDetector.java create mode 100644 jablib/src/test/java/org/jabref/logic/git/GitSyncServiceTest.java create mode 100644 jablib/src/test/java/org/jabref/logic/git/util/SemanticConflictDetectorTest.java 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..eadd7d6c5a3 --- /dev/null +++ b/jablib/src/main/java/org/jabref/logic/git/GitSyncService.java @@ -0,0 +1,4 @@ +package org.jabref.logic.git; + +public class GitSyncService { +} diff --git a/jablib/src/main/java/org/jabref/logic/git/util/GitBibParser.java b/jablib/src/main/java/org/jabref/logic/git/util/GitBibParser.java new file mode 100644 index 00000000000..0227edf82b3 --- /dev/null +++ b/jablib/src/main/java/org/jabref/logic/git/util/GitBibParser.java @@ -0,0 +1,18 @@ +package org.jabref.logic.git.util; + +import java.io.StringReader; + +import org.jabref.logic.importer.ImportFormatPreferences; +import org.jabref.logic.importer.ParserResult; +import org.jabref.logic.importer.fileformat.BibtexParser; +import org.jabref.model.database.BibDatabaseContext; +import org.jabref.model.util.DummyFileUpdateMonitor; + +public class GitBibParser { + // TODO: exception handling + public static BibDatabaseContext parseBibFromGit(String bibContent, ImportFormatPreferences importFormatPreferences) throws Exception { + BibtexParser parser = new BibtexParser(importFormatPreferences, new DummyFileUpdateMonitor()); + ParserResult result = parser.parse(new StringReader(bibContent)); + return result.getDatabaseContext(); + } +} diff --git a/jablib/src/main/java/org/jabref/logic/git/util/GitFileReader.java b/jablib/src/main/java/org/jabref/logic/git/util/GitFileReader.java new file mode 100644 index 00000000000..57b06a8a651 --- /dev/null +++ b/jablib/src/main/java/org/jabref/logic/git/util/GitFileReader.java @@ -0,0 +1,41 @@ +package org.jabref.logic.git.util; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; + +import org.jabref.logic.JabRefException; + +import org.eclipse.jgit.api.Git; +import org.eclipse.jgit.errors.IncorrectObjectTypeException; +import org.eclipse.jgit.errors.MissingObjectException; +import org.eclipse.jgit.lib.ObjectId; +import org.eclipse.jgit.lib.ObjectLoader; +import org.eclipse.jgit.lib.Repository; +import org.eclipse.jgit.revwalk.RevCommit; +import org.eclipse.jgit.revwalk.RevTree; +import org.eclipse.jgit.treewalk.TreeWalk; + +public class GitFileReader { + public static String readFileFromCommit(Git git, RevCommit commit, Path filePath) throws JabRefException { + // 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, String.valueOf(filePath), commitTree)) { + if (treeWalk == null) { + throw new JabRefException("File '" + filePath + "' not found in commit " + commit.getName()); + } + // 3. load blob object + ObjectId objectId = treeWalk.getObjectId(0); + ObjectLoader loader = repository.open(objectId); + return new String(loader.getBytes(), StandardCharsets.UTF_8); + } catch (MissingObjectException | + IncorrectObjectTypeException e) { + throw new JabRefException("Git object missing or incorrect when reading file: " + filePath, e); + } catch (IOException e) { + throw new JabRefException("I/O error while reading file from commit: " + filePath, e); + } + } +} diff --git a/jablib/src/main/java/org/jabref/logic/git/util/SemanticConflictDetector.java b/jablib/src/main/java/org/jabref/logic/git/util/SemanticConflictDetector.java new file mode 100644 index 00000000000..511d57179a9 --- /dev/null +++ b/jablib/src/main/java/org/jabref/logic/git/util/SemanticConflictDetector.java @@ -0,0 +1,97 @@ +package org.jabref.logic.git.util; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.function.Function; +import java.util.stream.Collectors; + +import org.jabref.logic.bibtex.comparator.BibDatabaseDiff; +import org.jabref.logic.bibtex.comparator.BibEntryDiff; +import org.jabref.model.database.BibDatabaseContext; +import org.jabref.model.entry.BibEntry; +import org.jabref.model.entry.field.Field; + +public class SemanticConflictDetector { + public static List detectConflicts(BibDatabaseContext base, BibDatabaseContext local, BibDatabaseContext remote) { + // 1. get diffs between base and local + // List localDiffs = BibDatabaseDiff.compare(base, local).getEntryDifferences(); + // 2. get diffs between base and remote + List remoteDiffs = BibDatabaseDiff.compare(base, remote).getEntryDifferences(); + // 3. map citation key to entry for local/remote diffs + Map baseEntries = toEntryMap(base); + Map localDiffMap = toDiffMap(localDiffs); + Map remoteDiffMap = toDiffMap(remoteDiffs); + + // result := local + remoteDiff + // and then create merge commit having result as file content and local and remotebranch as parent + + List conflicts = new ArrayList<>(); + + // 4. look for entries modified in both local and remote + for (String citationKey : localDiffMap.keySet()) { + // ignore only local modified + if (!remoteDiffMap.containsKey(citationKey)) { + continue; + } + BibEntryDiff localDiff = localDiffMap.get(citationKey); + BibEntryDiff remoteDiff = remoteDiffMap.get(citationKey); + + // get versions of this entry in base/local/remote; + BibEntry baseEntry = baseEntries.get(citationKey); + BibEntry localEntry = localDiff.newEntry(); + BibEntry remoteEntry = remoteDiff.newEntry(); + + if (baseEntry != null && localEntry != null && remoteEntry != null) { + // check if there are any field conflicts + if (hasConflictingFields(baseEntry, localEntry, remoteEntry)) { + conflicts.add(new BibEntryDiff(localEntry, remoteEntry)); + } + } + } + + return conflicts; + } + + private static Map toDiffMap(List diffs) { + return diffs.stream() + .filter(diff -> diff.newEntry().getCitationKey().isPresent()) + .collect(Collectors.toMap( + diff -> diff.newEntry().getCitationKey().get(), + Function.identity())); + } + + public static Map toEntryMap(BibDatabaseContext ctx) { + return ctx.getDatabase().getEntries().stream() + .filter(entry -> entry.getCitationKey().isPresent()) + .collect(Collectors.toMap( + entry -> entry.getCitationKey().get(), + Function.identity())); + } + + private static boolean hasConflictingFields(BibEntry base, BibEntry local, BibEntry remote) { + // Go through union of all fields + Set fields = new HashSet<>(); + fields.addAll(base.getFields()); + fields.addAll(local.getFields()); + fields.addAll(remote.getFields()); + + for (Field field : fields) { + String baseVal = base.getField(field).orElse(null); + String localVal = local.getField(field).orElse(null); + String remoteVal = remote.getField(field).orElse(null); + + boolean localChanged = !Objects.equals(baseVal, localVal); + boolean remoteChanged = !Objects.equals(baseVal, remoteVal); + + if (localChanged && remoteChanged && !Objects.equals(localVal, remoteVal)) { + return true; + } + } + + return false; + } +} 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..e0cc4a29ea1 --- /dev/null +++ b/jablib/src/test/java/org/jabref/logic/git/GitSyncServiceTest.java @@ -0,0 +1,151 @@ +package org.jabref.logic.git; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +import org.jabref.logic.git.util.GitFileReader; +import org.jabref.logic.importer.ImportFormatPreferences; +import org.jabref.model.database.BibDatabaseContext; +import org.jabref.model.entry.BibEntry; +import org.jabref.model.entry.field.StandardField; + +import org.eclipse.jgit.api.Git; +import org.eclipse.jgit.lib.PersonIdent; +import org.eclipse.jgit.revwalk.RevCommit; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.mockito.Answers; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class GitSyncServiceTest { + private Git git; + private Path library; + private ImportFormatPreferences importFormatPreferences; + + // These are setup by alieBobSetting + private RevCommit baseCommit; + private RevCommit aliceCommit; + private RevCommit bobCommit; + + private final PersonIdent alice = new PersonIdent("Alice", "alice@example.org"); + private final PersonIdent bob = new PersonIdent("Bob", "bob@example.org"); + private final String initialContent = """ + @article{a, + author = {don't know the author} + doi = {xya}, + } + + @article{b, + author = {author-b} + doi = {xyz}, + } + """; + + // Alice modifies a + private final String aliceUpdatedContent = """ + @article{a, + author = {author-a} + doi = {xya}, + } + + @article{b, + author = {author-b} + doi = {xyz}, + } + """; + + // Bob reorders a and b + private final String bobUpdatedContent = """ + @article{b, + author = {author-b} + doi = {xyz}, + } + + @article{a, + author = {lala} + doi = {xya}, + } + """; + + + /** + * Creates a commit graph with a base commit, one modification by Alice and one modification by Bob + */ + @BeforeEach + void aliceBobSimple(@TempDir Path tempDir) throws Exception { + importFormatPreferences = mock(ImportFormatPreferences.class, Answers.RETURNS_DEEP_STUBS); + when(importFormatPreferences.bibEntryPreferences().getKeywordSeparator()).thenReturn(','); + + // Create empty repository + git = Git.init() + .setDirectory(tempDir.toFile()) + .setInitialBranch("main") + .call(); + + library = tempDir.resolve("library.bib"); + + baseCommit = writeAndCommit(initialContent, "Inital commit", alice, library, git); + + aliceCommit = writeAndCommit(aliceUpdatedContent, "Fix author of a", alice, library, git); + + git.checkout().setStartPoint(baseCommit).setCreateBranch(true).setName("bob-branch").call(); + + bobCommit = writeAndCommit(bobUpdatedContent, "Exchange a with b", bob, library, git); + + // ToDo: Replace by call to GitSyncService crafting a merge commit +// git.merge().include(aliceCommit).include(bobCommit).call(); // Will throw exception bc of merge conflict + + // Debug hint: Show the created git graph on the command line + // git log --graph --oneline --decorate --all --reflog + } + + @Test + void performsSemanticMergeWhenNoConflicts() throws Exception { + GitSyncService.MergeResult result = GitSyncService.merge( + git, + baseCommit, + aliceCommit, + bobCommit, + library, + importFormatPreferences + ); + + assertFalse(result.hasConflict(), "Expected no semantic conflict"); + + BibDatabaseContext merged = result.merged(); + List entries = merged.getDatabase().getEntries(); + assertEquals(2, entries.size()); + + // Verify the author of entry a is modified from Alice + BibEntry entryA = entries.stream() + .filter(e -> e.getCitationKey().orElse("").equals("a")) + .findFirst() + .orElseThrow(() -> new IllegalStateException("Entry a not found")); + + assertEquals("author-a", entryA.getField(StandardField.AUTHOR).orElse("")); + } + + @Test + void readFromCommits() throws Exception { + String base = GitFileReader.readFileFromCommit(git, baseCommit, Path.of("library.bib")); + String local = GitFileReader.readFileFromCommit(git, aliceCommit, Path.of("library.bib")); + String remote = GitFileReader.readFileFromCommit(git, bobCommit, Path.of("library.bib")); + + 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); + git.add().addFilepattern(library.getFileName().toString()).call(); + return git.commit().setAuthor(author).setMessage(message).call(); + } +} diff --git a/jablib/src/test/java/org/jabref/logic/git/util/SemanticConflictDetectorTest.java b/jablib/src/test/java/org/jabref/logic/git/util/SemanticConflictDetectorTest.java new file mode 100644 index 00000000000..348cc1b251c --- /dev/null +++ b/jablib/src/test/java/org/jabref/logic/git/util/SemanticConflictDetectorTest.java @@ -0,0 +1,160 @@ +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 org.jabref.logic.bibtex.comparator.BibEntryDiff; +import org.jabref.logic.importer.ImportFormatPreferences; +import org.jabref.model.database.BibDatabaseContext; +import org.jabref.model.entry.BibEntry; +import org.jabref.model.entry.field.StandardField; + +import org.eclipse.jgit.api.Git; +import org.eclipse.jgit.lib.PersonIdent; +import org.eclipse.jgit.revwalk.RevCommit; +import org.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; + +class SemanticConflictDetectorTest { + private Git git; + private Path library; + private RevCommit baseCommit; + private RevCommit localCommit; + private RevCommit remoteCommitNoConflict; + private RevCommit remoteCommitConflict; + + 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"); + + String base = """ + @article{a, + author = {lala}, + doi = {xya}, + } + + @article{b, + author = {author-b}, + doi = {xyz}, + } + """; + + String local = """ + @article{a, + author = {author-a}, + doi = {xya}, + } + + @article{b, + author = {author-b}, + doi = {xyz}, + } + """; + + String remoteNoConflict = """ + @article{b, + author = {author-b}, + doi = {xyz}, + } + + @article{a, + author = {lala}, + doi = {xya}, + } + """; + + String remoteConflict = """ + @article{b, + author = {author-b}, + doi = {xyz}, + } + + @article{a, + author = {author-c}, + doi = {xya}, + } + """; + + baseCommit = writeAndCommit(base, "base", alice, library, git); + localCommit = writeAndCommit(local, "local change article a - author a", alice, library, git); + + // Remote with no conflict + git.checkout().setStartPoint(baseCommit).setCreateBranch(true).setName("remote-noconflict").call(); + remoteCommitNoConflict = writeAndCommit(remoteNoConflict, "remote change article b", bob, library, git); + + // Remote with conflict + git.checkout().setStartPoint(baseCommit).setCreateBranch(true).setName("remote-conflict").call(); + remoteCommitConflict = writeAndCommit(remoteConflict, "remote change article a - author c", bob, library, git); + } + + @Test + void detectsNoConflictWhenChangesAreInDifferentFields() throws Exception { + BibDatabaseContext base = parse(baseCommit); + BibDatabaseContext local = parse(localCommit); + BibDatabaseContext remote = parse(remoteCommitNoConflict); + + List diffs = SemanticConflictDetector.detectConflicts(base, local, remote); + assertTrue(diffs.isEmpty(), "Expected no semantic conflict, but found some"); + } + + @Test + void detectsConflictWhenSameFieldModifiedDifferently() throws Exception { + BibDatabaseContext base = parse(baseCommit); + BibDatabaseContext local = parse(localCommit); + BibDatabaseContext remote = parse(remoteCommitConflict); + + List diffs = SemanticConflictDetector.detectConflicts(base, local, remote); + assertEquals(1, diffs.size(), "Expected one conflicting entry"); + + BibEntryDiff diff = diffs.get(0); + BibEntry localEntry = diff.originalEntry(); // from local + BibEntry remoteEntry = diff.newEntry(); // from remote + + String localAuthor = localEntry.getField(StandardField.AUTHOR).orElse(""); + String remoteAuthor = remoteEntry.getField(StandardField.AUTHOR).orElse(""); + + assertEquals("author-a", localAuthor); + assertEquals("author-c", remoteAuthor); + assertTrue(!localAuthor.equals(remoteAuthor), "Expected AUTHOR field conflict in entry 'a'"); + } + + private BibDatabaseContext parse(RevCommit commit) throws Exception { + String content = GitFileReader.readFileFromCommit(git, commit, Path.of("library.bib")); + return GitBibParser.parseBibFromGit(content, importFormatPreferences); + } + + private RevCommit writeAndCommit(String content, String message, PersonIdent author, Path file, Git git) throws Exception { + Files.writeString(file, content, StandardCharsets.UTF_8); + git.add().addFilepattern(file.getFileName().toString()).call(); + return git.commit().setAuthor(author).setMessage(message).call(); + } + + private BibEntry findEntryByCitationKey(BibDatabaseContext ctx, String key) { + return ctx.getDatabase().getEntries().stream() + .filter(entry -> entry.getCitationKey().orElse("").equals(key)) + .findFirst() + .orElseThrow(() -> new IllegalStateException("Entry with key '" + key + "' not found")); + } +} From 7fa3b867c6968502db4e061a9c9cf25a90a44fd5 Mon Sep 17 00:00:00 2001 From: Wanling Fu Date: Thu, 12 Jun 2025 19:22:15 +0100 Subject: [PATCH 02/37] feat(git): Add SemanticMerger MVP #12350 --- .../org/jabref/logic/git/GitSyncService.java | 56 +++ .../org/jabref/logic/git/MergeResult.java | 35 ++ .../jabref/logic/git/util/GitFileWriter.java | 13 + .../git/util/SemanticConflictDetector.java | 38 +- .../jabref/logic/git/util/SemanticMerger.java | 79 +++ .../jabref/logic/git/GitSyncServiceTest.java | 27 -- .../logic/git/util/GitBibParserTest.java | 77 +++ .../util/SemanticConflictDetectorTest.java | 450 ++++++++++++++---- .../logic/git/util/SemanticMergerTest.java | 157 ++++++ 9 files changed, 788 insertions(+), 144 deletions(-) create mode 100644 jablib/src/main/java/org/jabref/logic/git/MergeResult.java create mode 100644 jablib/src/main/java/org/jabref/logic/git/util/GitFileWriter.java create mode 100644 jablib/src/main/java/org/jabref/logic/git/util/SemanticMerger.java create mode 100644 jablib/src/test/java/org/jabref/logic/git/util/GitBibParserTest.java create mode 100644 jablib/src/test/java/org/jabref/logic/git/util/SemanticMergerTest.java 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 eadd7d6c5a3..4974dc04c1d 100644 --- a/jablib/src/main/java/org/jabref/logic/git/GitSyncService.java +++ b/jablib/src/main/java/org/jabref/logic/git/GitSyncService.java @@ -1,4 +1,60 @@ package org.jabref.logic.git; +import java.nio.file.Path; +import java.util.List; + +import org.jabref.logic.bibtex.comparator.BibEntryDiff; +import org.jabref.logic.git.util.GitBibParser; +import org.jabref.logic.git.util.GitFileReader; +import org.jabref.logic.git.util.SemanticConflictDetector; +import org.jabref.logic.git.util.SemanticMerger; +import org.jabref.logic.importer.ImportFormatPreferences; +import org.jabref.model.database.BibDatabaseContext; + +import org.eclipse.jgit.api.Git; +import org.eclipse.jgit.revwalk.RevCommit; + public class GitSyncService { + private final ImportFormatPreferences importFormatPreferences; + + public GitSyncService(ImportFormatPreferences importFormatPreferences) { + this.importFormatPreferences = importFormatPreferences; + } + + public MergeResult performSemanticMerge(Git git, + RevCommit baseCommit, + RevCommit localCommit, + RevCommit remoteCommit, + Path bibFilePath, + ImportFormatPreferences importFormatPreferences) throws Exception { + + // 1. Load three versions + String baseContent = GitFileReader.readFileFromCommit(git, baseCommit, bibFilePath); + String localContent = GitFileReader.readFileFromCommit(git, localCommit, bibFilePath); + String remoteContent = GitFileReader.readFileFromCommit(git, remoteCommit, bibFilePath); + + BibDatabaseContext base = GitBibParser.parseBibFromGit(baseContent, importFormatPreferences); + BibDatabaseContext local = GitBibParser.parseBibFromGit(localContent, importFormatPreferences); + BibDatabaseContext remote = GitBibParser.parseBibFromGit(remoteContent, importFormatPreferences); + + // 2. Conflict detection + List conflicts = SemanticConflictDetector.detectConflicts(base, local, remote); + + if (!conflicts.isEmpty()) { + return MergeResult.conflictsFound(conflicts); // UI-resolvable + } + + // 3. Apply remote patch to local + SemanticMerger.applyRemotePatchToDatabase(base, local, remote); + + // 4. Write back merged result +// try { +// GitFileWriter.write(bibFilePath, local, importFormatPreferences); +// } catch (Exception e) { +// return MergeResult.failure("Failed to write merged file: " + e.getMessage()); +// } + + return MergeResult.success(); + } } + diff --git a/jablib/src/main/java/org/jabref/logic/git/MergeResult.java b/jablib/src/main/java/org/jabref/logic/git/MergeResult.java new file mode 100644 index 00000000000..120b160c53c --- /dev/null +++ b/jablib/src/main/java/org/jabref/logic/git/MergeResult.java @@ -0,0 +1,35 @@ +package org.jabref.logic.git; + +import java.util.List; + +import org.jabref.logic.bibtex.comparator.BibEntryDiff; + +public class MergeResult { + private final boolean success; + private final List conflicts; + + private MergeResult(boolean success, List conflicts) { + this.success = success; + this.conflicts = conflicts; + } + + public static MergeResult success() { + return new MergeResult(true, List.of()); + } + + public static MergeResult conflictsFound(List conflicts) { + return new MergeResult(false, conflicts); + } + + public boolean isSuccess() { + return success; + } + + public boolean hasConflicts() { + return !conflicts.isEmpty(); + } + + public List getConflicts() { + return conflicts; + } +} diff --git a/jablib/src/main/java/org/jabref/logic/git/util/GitFileWriter.java b/jablib/src/main/java/org/jabref/logic/git/util/GitFileWriter.java new file mode 100644 index 00000000000..da308dfbd4a --- /dev/null +++ b/jablib/src/main/java/org/jabref/logic/git/util/GitFileWriter.java @@ -0,0 +1,13 @@ +package org.jabref.logic.git.util; + +import java.io.IOException; +import java.nio.file.Path; + +import org.jabref.logic.importer.ImportFormatPreferences; +import org.jabref.model.database.BibDatabaseContext; + +public class GitFileWriter { + // TODO: Review `BibDatabaseWriter` + public static void write(Path bibFilePath, BibDatabaseContext context, ImportFormatPreferences prefs) throws IOException { + } +} diff --git a/jablib/src/main/java/org/jabref/logic/git/util/SemanticConflictDetector.java b/jablib/src/main/java/org/jabref/logic/git/util/SemanticConflictDetector.java index 511d57179a9..e077e2c724b 100644 --- a/jablib/src/main/java/org/jabref/logic/git/util/SemanticConflictDetector.java +++ b/jablib/src/main/java/org/jabref/logic/git/util/SemanticConflictDetector.java @@ -1,10 +1,12 @@ package org.jabref.logic.git.util; import java.util.ArrayList; +import java.util.Collections; import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Objects; +import java.util.Optional; import java.util.Set; import java.util.function.Function; import java.util.stream.Collectors; @@ -16,43 +18,41 @@ import org.jabref.model.entry.field.Field; public class SemanticConflictDetector { + /** + * result := local + remoteDiff + * and then create merge commit having result as file content and local and remote branch as parent + */ public static List detectConflicts(BibDatabaseContext base, BibDatabaseContext local, BibDatabaseContext remote) { - // 1. get diffs between base and local - // List localDiffs = BibDatabaseDiff.compare(base, local).getEntryDifferences(); - // 2. get diffs between base and remote + // 1. get diffs between base and remote List remoteDiffs = BibDatabaseDiff.compare(base, remote).getEntryDifferences(); - // 3. map citation key to entry for local/remote diffs + if (remoteDiffs == null) { + return Collections.emptyList(); + } + // 2. map citation key to entry for local/remote diffs Map baseEntries = toEntryMap(base); - Map localDiffMap = toDiffMap(localDiffs); - Map remoteDiffMap = toDiffMap(remoteDiffs); - - // result := local + remoteDiff - // and then create merge commit having result as file content and local and remotebranch as parent + Map localEntries = toEntryMap(local); List conflicts = new ArrayList<>(); - // 4. look for entries modified in both local and remote - for (String citationKey : localDiffMap.keySet()) { - // ignore only local modified - if (!remoteDiffMap.containsKey(citationKey)) { + // 3. look for entries modified in both local and remote + for (BibEntryDiff remoteDiff : remoteDiffs) { + Optional keyOpt = remoteDiff.newEntry().getCitationKey(); + if (keyOpt.isEmpty()) { continue; } - BibEntryDiff localDiff = localDiffMap.get(citationKey); - BibEntryDiff remoteDiff = remoteDiffMap.get(citationKey); - // get versions of this entry in base/local/remote; + String citationKey = keyOpt.get(); BibEntry baseEntry = baseEntries.get(citationKey); - BibEntry localEntry = localDiff.newEntry(); + BibEntry localEntry = localEntries.get(citationKey); BibEntry remoteEntry = remoteDiff.newEntry(); + // if the entry exists in all 3 versions if (baseEntry != null && localEntry != null && remoteEntry != null) { - // check if there are any field conflicts if (hasConflictingFields(baseEntry, localEntry, remoteEntry)) { conflicts.add(new BibEntryDiff(localEntry, remoteEntry)); } } } - return conflicts; } diff --git a/jablib/src/main/java/org/jabref/logic/git/util/SemanticMerger.java b/jablib/src/main/java/org/jabref/logic/git/util/SemanticMerger.java new file mode 100644 index 00000000000..09abf918528 --- /dev/null +++ b/jablib/src/main/java/org/jabref/logic/git/util/SemanticMerger.java @@ -0,0 +1,79 @@ +package org.jabref.logic.git.util; + +import java.util.HashSet; +import java.util.Map; +import java.util.Objects; +import java.util.Set; + +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); + /** + * Applies remote's non-conflicting field changes to local entry, in-place. + * Assumes conflict detection already run. + */ + public static void patchEntryNonConflictingFields(BibEntry base, BibEntry local, BibEntry remote) { + Set allFields = new HashSet<>(); + allFields.addAll(base.getFields()); + allFields.addAll(local.getFields()); + allFields.addAll(remote.getFields()); + + 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); + + if (Objects.equals(baseVal, localVal) && !Objects.equals(baseVal, remoteVal)) { + // Local untouched, remote changed -> apply remote + if (remoteVal != null) { + local.setField(field, remoteVal); + } else { + local.clearField(field); + } + } else if (!Objects.equals(baseVal, localVal) && !Objects.equals(baseVal, remoteVal) && !Objects.equals(localVal, remoteVal)) { + // This should be conflict, but always assume it's already filtered before this class + LOGGER.debug("Unexpected field-level conflict skipped: " + field.getName()); + } + // else: either already applied or local wins + } + } + + /** + * Applies remote diffs (based on base) onto local BibDatabaseContext. + * - Adds new entries from remote if not present locally. + * - Applies field-level patches on existing entries. + * - Does NOT handle deletions. + */ + public static void applyRemotePatchToDatabase(BibDatabaseContext base, + BibDatabaseContext local, + BibDatabaseContext remote) { + Map baseMap = SemanticConflictDetector.toEntryMap(base); + Map localMap = SemanticConflictDetector.toEntryMap(local); + Map remoteMap = SemanticConflictDetector.toEntryMap(remote); + + for (Map.Entry entry : remoteMap.entrySet()) { + String key = entry.getKey(); + BibEntry remoteEntry = entry.getValue(); + BibEntry baseEntry = baseMap.getOrDefault(key, new BibEntry()); + BibEntry localEntry = localMap.get(key); + + if (localEntry != null) { + // Apply patch to existing entry + patchEntryNonConflictingFields(baseEntry, localEntry, remoteEntry); + } else if (baseEntry == null) { + // New entry from remote (not in base or local) -> insert + BibEntry newEntry = (BibEntry) remoteEntry.clone(); + local.getDatabase().insertEntry(newEntry); + } else { + // Entry was deleted in local → respect deletion (do nothing) + } + } + // Optional: if localMap contains entries absent in remote+base -> do nothing (local additions) + } +} 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 e0cc4a29ea1..893bc37a726 100644 --- a/jablib/src/test/java/org/jabref/logic/git/GitSyncServiceTest.java +++ b/jablib/src/test/java/org/jabref/logic/git/GitSyncServiceTest.java @@ -3,13 +3,9 @@ import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; -import java.util.List; import org.jabref.logic.git.util.GitFileReader; import org.jabref.logic.importer.ImportFormatPreferences; -import org.jabref.model.database.BibDatabaseContext; -import org.jabref.model.entry.BibEntry; -import org.jabref.model.entry.field.StandardField; import org.eclipse.jgit.api.Git; import org.eclipse.jgit.lib.PersonIdent; @@ -20,7 +16,6 @@ import org.mockito.Answers; import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; @@ -108,28 +103,6 @@ void aliceBobSimple(@TempDir Path tempDir) throws Exception { @Test void performsSemanticMergeWhenNoConflicts() throws Exception { - GitSyncService.MergeResult result = GitSyncService.merge( - git, - baseCommit, - aliceCommit, - bobCommit, - library, - importFormatPreferences - ); - - assertFalse(result.hasConflict(), "Expected no semantic conflict"); - - BibDatabaseContext merged = result.merged(); - List entries = merged.getDatabase().getEntries(); - assertEquals(2, entries.size()); - - // Verify the author of entry a is modified from Alice - BibEntry entryA = entries.stream() - .filter(e -> e.getCitationKey().orElse("").equals("a")) - .findFirst() - .orElseThrow(() -> new IllegalStateException("Entry a not found")); - - assertEquals("author-a", entryA.getField(StandardField.AUTHOR).orElse("")); } @Test diff --git a/jablib/src/test/java/org/jabref/logic/git/util/GitBibParserTest.java b/jablib/src/test/java/org/jabref/logic/git/util/GitBibParserTest.java new file mode 100644 index 00000000000..68c5b276d5d --- /dev/null +++ b/jablib/src/test/java/org/jabref/logic/git/util/GitBibParserTest.java @@ -0,0 +1,77 @@ +package org.jabref.logic.git.util; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Optional; + +import org.jabref.logic.importer.ImportFormatPreferences; +import org.jabref.model.database.BibDatabaseContext; +import org.jabref.model.entry.BibEntry; +import org.jabref.model.entry.field.FieldFactory; + +import org.eclipse.jgit.api.Git; +import org.eclipse.jgit.lib.PersonIdent; +import org.eclipse.jgit.revwalk.RevCommit; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.mockito.Answers; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class GitBibParserTest { + private Git git; + private Path library; + private RevCommit commit; + + private final PersonIdent alice = new PersonIdent("Alice", "alice@example.org"); + private final String bibContent = """ + @article{test2025, + author = {Alice}, + title = {Test Title}, + year = {2025} + } + """; + + private ImportFormatPreferences importFormatPreferences; + + @BeforeEach + void setup(@TempDir Path tempDir) throws Exception { + importFormatPreferences = mock(ImportFormatPreferences.class, Answers.RETURNS_DEEP_STUBS); + when(importFormatPreferences.bibEntryPreferences().getKeywordSeparator()).thenReturn(','); + + git = Git.init() + .setDirectory(tempDir.toFile()) + .setInitialBranch("main") + .call(); + + library = tempDir.resolve("library.bib"); + commit = writeAndCommit(bibContent, "Initial commit", alice, library, git); + } + + @Test + void parsesBibContentFromCommit() throws Exception { + String rawBib = GitFileReader.readFileFromCommit(git, commit, Path.of("library.bib")); + + BibDatabaseContext context = GitBibParser.parseBibFromGit(rawBib, importFormatPreferences); + + List entries = context.getEntries(); + assertEquals(1, entries.size()); + + BibEntry entry = entries.get(0); + assertEquals(Optional.of("Alice"), entry.getField(FieldFactory.parseField("author"))); + assertEquals(Optional.of("Test Title"), entry.getField(FieldFactory.parseField("title"))); + assertEquals(Optional.of("2025"), entry.getField(FieldFactory.parseField("year"))); + assertEquals(Optional.of("test2025"), entry.getCitationKey()); + } + + private RevCommit writeAndCommit(String content, String message, PersonIdent author, Path library, Git git) throws Exception { + Files.writeString(library, content, StandardCharsets.UTF_8); + git.add().addFilepattern(library.getFileName().toString()).call(); + return git.commit().setAuthor(author).setMessage(message).call(); + } +} diff --git a/jablib/src/test/java/org/jabref/logic/git/util/SemanticConflictDetectorTest.java b/jablib/src/test/java/org/jabref/logic/git/util/SemanticConflictDetectorTest.java index 348cc1b251c..f4398398bd8 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 @@ -4,19 +4,20 @@ import java.nio.file.Files; import java.nio.file.Path; import java.util.List; +import java.util.stream.Stream; import org.jabref.logic.bibtex.comparator.BibEntryDiff; import org.jabref.logic.importer.ImportFormatPreferences; import org.jabref.model.database.BibDatabaseContext; -import org.jabref.model.entry.BibEntry; -import org.jabref.model.entry.field.StandardField; import org.eclipse.jgit.api.Git; import org.eclipse.jgit.lib.PersonIdent; import org.eclipse.jgit.revwalk.RevCommit; import org.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; @@ -27,11 +28,6 @@ class SemanticConflictDetectorTest { private Git git; private Path library; - private RevCommit baseCommit; - private RevCommit localCommit; - private RevCommit remoteCommitNoConflict; - private RevCommit remoteCommitConflict; - private final PersonIdent alice = new PersonIdent("Alice", "alice@example.org"); private final PersonIdent bob = new PersonIdent("Bob", "bob@example.org"); @@ -48,96 +44,26 @@ void setup(@TempDir Path tempDir) throws Exception { .call(); library = tempDir.resolve("library.bib"); - - String base = """ - @article{a, - author = {lala}, - doi = {xya}, - } - - @article{b, - author = {author-b}, - doi = {xyz}, - } - """; - - String local = """ - @article{a, - author = {author-a}, - doi = {xya}, - } - - @article{b, - author = {author-b}, - doi = {xyz}, - } - """; - - String remoteNoConflict = """ - @article{b, - author = {author-b}, - doi = {xyz}, - } - - @article{a, - author = {lala}, - doi = {xya}, - } - """; - - String remoteConflict = """ - @article{b, - author = {author-b}, - doi = {xyz}, - } - - @article{a, - author = {author-c}, - doi = {xya}, - } - """; - - baseCommit = writeAndCommit(base, "base", alice, library, git); - localCommit = writeAndCommit(local, "local change article a - author a", alice, library, git); - - // Remote with no conflict - git.checkout().setStartPoint(baseCommit).setCreateBranch(true).setName("remote-noconflict").call(); - remoteCommitNoConflict = writeAndCommit(remoteNoConflict, "remote change article b", bob, library, git); - - // Remote with conflict - git.checkout().setStartPoint(baseCommit).setCreateBranch(true).setName("remote-conflict").call(); - remoteCommitConflict = writeAndCommit(remoteConflict, "remote change article a - author c", bob, library, git); - } - - @Test - void detectsNoConflictWhenChangesAreInDifferentFields() throws Exception { - BibDatabaseContext base = parse(baseCommit); - BibDatabaseContext local = parse(localCommit); - BibDatabaseContext remote = parse(remoteCommitNoConflict); - - List diffs = SemanticConflictDetector.detectConflicts(base, local, remote); - assertTrue(diffs.isEmpty(), "Expected no semantic conflict, but found some"); } - @Test - void detectsConflictWhenSameFieldModifiedDifferently() throws Exception { - BibDatabaseContext base = parse(baseCommit); - BibDatabaseContext local = parse(localCommit); - BibDatabaseContext remote = parse(remoteCommitConflict); + @ParameterizedTest(name = "{0}") + @MethodSource("provideConflictCases") + void testSemanticConflicts(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); - List diffs = SemanticConflictDetector.detectConflicts(base, local, remote); - assertEquals(1, diffs.size(), "Expected one conflicting entry"); + BibDatabaseContext baseCtx = parse(baseCommit); + BibDatabaseContext localCtx = parse(localCommit); + BibDatabaseContext remoteCtx = parse(remoteCommit); - BibEntryDiff diff = diffs.get(0); - BibEntry localEntry = diff.originalEntry(); // from local - BibEntry remoteEntry = diff.newEntry(); // from remote + List diffs = SemanticConflictDetector.detectConflicts(baseCtx, localCtx, remoteCtx); - String localAuthor = localEntry.getField(StandardField.AUTHOR).orElse(""); - String remoteAuthor = remoteEntry.getField(StandardField.AUTHOR).orElse(""); - - assertEquals("author-a", localAuthor); - assertEquals("author-c", remoteAuthor); - assertTrue(!localAuthor.equals(remoteAuthor), "Expected AUTHOR field conflict in entry 'a'"); + 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 { @@ -151,10 +77,338 @@ private RevCommit writeAndCommit(String content, String message, PersonIdent aut return git.commit().setAuthor(author).setMessage(message).call(); } - private BibEntry findEntryByCitationKey(BibDatabaseContext ctx, String key) { - return ctx.getDatabase().getEntries().stream() - .filter(entry -> entry.getCitationKey().orElse("").equals(key)) - .findFirst() - .orElseThrow(() -> new IllegalStateException("Entry with key '" + key + "' not found")); + private RevCommit writeAndCommit(String content, String message, PersonIdent author) throws Exception { + return writeAndCommit(content, message, author, library, git); + } + + static Stream provideConflictCases() { + return Stream.of( + Arguments.of("T1 - remote changed a field, local unchanged", + """ + @article{a, + author = {lala}, + doi = {xya}, + } + """, + """ + @article{a, + author = {lala}, + doi = {xya}, + } + """, + """ + @article{a, + author = {bob}, + doi = {xya}, + } + """, + false + ), + Arguments.of("T2 - local changed a field, remote unchanged", + """ + @article{a, + author = {lala}, + doi = {xya}, + } + """, + """ + @article{a, + author = {alice}, + doi = {xya}, + } + """, + """ + @article{a, + author = {lala}, + doi = {xya}, + } + """, + false + ), + Arguments.of("T3 - both changed to same value", + """ + @article{a, + author = {lala}, + doi = {xya}, + } + """, + """ + @article{a, + author = {bob}, + doi = {xya}, + } + """, + """ + @article{a, + author = {bob}, + doi = {xya}, + } + """, + false + ), + Arguments.of("T4 - both changed to different values", + """ + @article{a, + author = {lala}, + doi = {xya}, + } + """, + """ + @article{a, + author = {alice}, + doi = {xya}, + } + """, + """ + @article{a, + author = {bob}, + doi = {xya}, + } + """, + true + ), + Arguments.of("T5 - local deleted field, remote changed it", + """ + @article{a, + author = {lala}, + doi = {xya}, + } + """, + """ + @article{a, + } + """, + """ + @article{a, + author = {bob}, + doi = {xya}, + } + """, + true + ), + Arguments.of("T6 - local changed, remote deleted", + """ + @article{a, + author = {lala}, + doi = {xya}, + } + """, + """ + @article{a, + author = {alice}, + doi = {xya}, + } + """, + """ + @article{a, + } + """, + true + ), + Arguments.of("T7 - remote deleted, local unchanged", + """ + @article{a, + author = {lala}, + doi = {xya}, + } + """, + """ + @article{a, + author = {lala}, + doi = {xya}, + } + """, + """ + @article{a, + } + """, + false + ), + Arguments.of("T8 - local changed field A, remote changed field B", + """ + @article{a, + author = {lala}, + doi = {xya}, + } + """, + """ + @article{a, + author = {alice}, + doi = {xya}, + } + """, + """ + @article{a, + author = {lala}, + doi = {xyz}, + } + """, + false + ), + Arguments.of("T9 - field order changed only", + """ + @article{a, + author = {lala}, + doi = {xya}, + } + """, + """ + @article{a, + doi = {xya}, + author = {lala}, + } + """, + """ + @article{a, + author = {lala}, + doi = {xya}, + } + """, + false + ), + Arguments.of("T10 - local changed entry a, remote changed entry b", + """ + @article{a, + author = {lala}, + doi = {xya}, + } + + @article{b, + author = {lala}, + doi = {xyz}, + } + """, + """ + @article{a, + author = {author-a}, + doi = {xya}, + } + @article{b, + author = {lala}, + doi = {xyz}, + } + """, + """ + @article{b, + author = {author-b}, + doi = {xyz}, + } + + @article{a, + author = {lala}, + doi = {xya}, + } + """, + false + ), + Arguments.of("T11 - remote added field, local unchanged", + """ + @article{a, + author = {lala}, + doi = {xya}, + } + """, + """ + @article{a, + author = {lala}, + doi = {xya}, + } + """, + """ + @article{a, + author = {lala}, + doi = {xya}, + year = {2025}, + } + """, + false + ), + Arguments.of("T12 - both added same field with different values", + """ + @article{a, + author = {lala}, + doi = {xya}, + } + """, + """ + @article{a, + author = {lala}, + doi = {xya}, + year = {2023}, + } + """, + """ + @article{a, + author = {lala}, + doi = {xya}, + year = {2025}, + } + """, + true + ), + Arguments.of("T13 - local added field, remote unchanged", + """ + @article{a, + author = {lala}, + doi = {xya}, + } + """, + """ + @article{a, + author = {lala}, + doi = {newfield}, + } + """, + """ + @article{a, + author = {lala}, + doi = {xya}, + } + """, + false + ), + Arguments.of("T14 - both added same field with same value", + """ + @article{a, + author = {lala}, + doi = {xya}, + } + """, + """ + @article{a, + author = {lala}, + doi = {value}, + } + """, + """ + @article{a, + author = {lala}, + doi = {value}, + } + """, + false + ), + Arguments.of("T15 - both added same field with different values", + """ + @article{a, + author = {lala}, + doi = {xya}, + } + """, + """ + @article{a, + author = {lala}, + doi = {value1}, + } + """, + """ + @article{a, + author = {lala}, + doi = {value2}, + } + """, + true + ) + ); } } 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..4e28ecb1149 --- /dev/null +++ b/jablib/src/test/java/org/jabref/logic/git/util/SemanticMergerTest.java @@ -0,0 +1,157 @@ +package org.jabref.logic.git.util; + +import java.util.List; +import java.util.stream.Stream; + +import org.jabref.logic.importer.ImportFormatPreferences; +import org.jabref.model.database.BibDatabaseContext; +import org.jabref.model.entry.BibEntry; +import org.jabref.model.entry.field.StandardField; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.mockito.Answers; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class SemanticMergerTest { + private ImportFormatPreferences importFormatPreferences; + + @BeforeEach + void setup() { + importFormatPreferences = mock(ImportFormatPreferences.class, Answers.RETURNS_DEEP_STUBS); + when(importFormatPreferences.bibEntryPreferences().getKeywordSeparator()).thenReturn(','); + } + + @ParameterizedTest(name = "{0}") + @MethodSource("providePatchCases") + void testPatchEntry(String description, String base, String local, String remote, String expectedAuthor) throws Exception { + BibEntry baseEntry = parseSingleEntry(base); + BibEntry localEntry = parseSingleEntry(local); + BibEntry remoteEntry = parseSingleEntry(remote); + + SemanticMerger.patchEntryNonConflictingFields(baseEntry, localEntry, remoteEntry); + + assertEquals(expectedAuthor, localEntry.getField(StandardField.AUTHOR).orElse(null)); + } + + @ParameterizedTest(name = "Database patch: {0}") + @MethodSource("provideDatabasePatchCases") + void testPatchDatabase(String description, String base, String local, String remote, String expectedAuthor) throws Exception { + BibDatabaseContext baseCtx = GitBibParser.parseBibFromGit(base, importFormatPreferences); + BibDatabaseContext localCtx = GitBibParser.parseBibFromGit(local, importFormatPreferences); + BibDatabaseContext remoteCtx = GitBibParser.parseBibFromGit(remote, importFormatPreferences); + + SemanticMerger.applyRemotePatchToDatabase(baseCtx, localCtx, remoteCtx); + + BibEntry patched = localCtx.getDatabase().getEntryByCitationKey("a").orElseThrow(); + assertEquals(expectedAuthor, patched.getField(StandardField.AUTHOR).orElse(null)); + } + + static Stream providePatchCases() { + return Stream.of( + Arguments.of("Remote changed, local unchanged", + "@article{a, author = {X} }", + "@article{a, author = {X} }", + "@article{a, author = {Bob} }", + "Bob" + ), + Arguments.of("Local changed, remote unchanged", + "@article{a, author = {X} }", + "@article{a, author = {Alice} }", + "@article{a, author = {X} }", + "Alice" + ), + Arguments.of("Both changed to same value", + "@article{a, author = {X} }", + "@article{a, author = {Y} }", + "@article{a, author = {Y} }", + "Y" + ) + ); + } + + static Stream provideDatabasePatchCases() { + return Stream.of( + // TODO: more test case + Arguments.of("T1 - remote changed a field, local unchanged", + """ + @article{a, + author = {lala}, + doi = {xya}, + } + """, + """ + @article{a, + author = {lala}, + doi = {xya}, + } + """, + """ + @article{a, + author = {bob}, + doi = {xya}, + } + """, + "bob" + ), + + Arguments.of("T2 - local changed a field, remote unchanged", + """ + @article{a, + author = {lala}, + doi = {xya}, + } + """, + """ + @article{a, + author = {alice}, + doi = {xya}, + } + """, + """ + @article{a, + author = {lala}, + doi = {xya}, + } + """, + "alice" + ), + + Arguments.of("T3 - both changed to same value", + """ + @article{a, + author = {lala}, + doi = {xya}, + } + """, + """ + @article{a, + author = {bob}, + doi = {xya}, + } + """, + """ + @article{a, + author = {bob}, + doi = {xya}, + } + """, + "bob" + ) + ); + } + + private BibEntry parseSingleEntry(String content) throws Exception { + BibDatabaseContext context = GitBibParser.parseBibFromGit(content, importFormatPreferences); + List entries = context.getDatabase().getEntries(); + if (entries.size() != 1) { + throw new IllegalStateException("Test assumes exactly one entry"); + } + return entries.get(0); + } +} From 6233df93513cb623af8fbfaeb2926272b8d2fd4e Mon Sep 17 00:00:00 2001 From: Wanling Fu Date: Wed, 18 Jun 2025 18:56:33 +0100 Subject: [PATCH 03/37] feat(git): Implement normal sync loop without conflicts #12350 --- docs/code-howtos/git.md | 75 +++++++++++++ jablib/src/main/java/module-info.java | 1 + .../org/jabref/logic/git/GitSyncService.java | 94 +++++++++++++--- .../org/jabref/logic/git/MergeResult.java | 35 ------ .../jabref/logic/git/util/GitBibParser.java | 12 +- .../jabref/logic/git/util/GitFileReader.java | 12 +- .../jabref/logic/git/util/GitFileWriter.java | 31 +++++- .../logic/git/util/GitRevisionLocator.java | 38 +++++++ .../org/jabref/logic/git/util/MergePlan.java | 19 ++++ .../jabref/logic/git/util/MergeResult.java | 19 ++++ .../jabref/logic/git/util/RevisionTriple.java | 15 +++ .../git/util/SemanticConflictDetector.java | 73 +++++++++--- .../jabref/logic/git/util/SemanticMerger.java | 86 ++++++--------- .../jabref/logic/git/GitSyncServiceTest.java | 104 ++++++++++++++---- .../logic/git/util/GitFileWriterTest.java | 51 +++++++++ .../git/util/GitRevisionLocatorTest.java | 50 +++++++++ .../util/SemanticConflictDetectorTest.java | 69 ++++++++++++ .../logic/git/util/SemanticMergerTest.java | 51 +-------- 18 files changed, 638 insertions(+), 197 deletions(-) create mode 100644 docs/code-howtos/git.md delete mode 100644 jablib/src/main/java/org/jabref/logic/git/MergeResult.java create mode 100644 jablib/src/main/java/org/jabref/logic/git/util/GitRevisionLocator.java create mode 100644 jablib/src/main/java/org/jabref/logic/git/util/MergePlan.java create mode 100644 jablib/src/main/java/org/jabref/logic/git/util/MergeResult.java create mode 100644 jablib/src/main/java/org/jabref/logic/git/util/RevisionTriple.java create mode 100644 jablib/src/test/java/org/jabref/logic/git/util/GitFileWriterTest.java create mode 100644 jablib/src/test/java/org/jabref/logic/git/util/GitRevisionLocatorTest.java diff --git a/docs/code-howtos/git.md b/docs/code-howtos/git.md new file mode 100644 index 00000000000..d8d50a08d63 --- /dev/null +++ b/docs/code-howtos/git.md @@ -0,0 +1,75 @@ +# git + +## Conflict Scenarios +- **T1.** Remote changed a field, local did not + → No conflict. + The local version remained unchanged, so the remote change can be safely applied. + + +- **T2.** Local changed a field, remote did not + → No conflict. + The remote version did not touch the field, so the local change is preserved. + + +- **T3.** Both local and remote changed the same field to the same value + → No conflict. + Although both sides changed the field, the result is identical—therefore, no conflict. + + +- **T4.** Both local and remote changed the same field to different values + → Conflict. + This is a true semantic conflict that requires resolution. + + +- **T5.** Local deleted a field, remote modified the same field + → Conflict. + One side deleted the field while the other updated it—this is contradictory. + + +- **T6.** Local modified a field, remote deleted it + → Conflict. + Similar to T5, one side deletes, the other edits—this is a conflict. + + +- **T7.** Local unchanged, remote deleted a field + → No conflict. + Local did not modify anything, so remote deletion is accepted. + + +- **T8.** Local changed field A, remote changed field B (within the same entry) + → No conflict. + Changes are on separate fields, so they can be merged safely. + + +- **T9.** Both changed the same entry, but only field order changed + → No conflict. + Field order is not semantically meaningful, so no conflict is detected. + +- **T10.** Local modified entry A, remote modified entry B + → No conflict. + Modifications are on different entries, which are always safe to merge. + + +- **T11.** Remote added a new field, local did nothing + → No conflict. + Remote addition can be applied without issues. + + +- **T12.** Remote added a field, local also added the same field, but with different value + → Conflict. + One side added while the other side modified—there is a semantic conflict. + + +- **T13.** Local added a field, remote did nothing + → No conflict. + Safe to preserve the local addition. + + +- **T14.** Both added the same field with the same value + → No conflict. + Even though both sides added it, the value is the same—no need for resolution. + + +- **T15.** Both added the same field with different values + → Conflict. + The same field is introduced with different values, which creates a conflict. diff --git a/jablib/src/main/java/module-info.java b/jablib/src/main/java/module-info.java index 07bd3b6884e..8ff80c20e92 100644 --- a/jablib/src/main/java/module-info.java +++ b/jablib/src/main/java/module-info.java @@ -106,6 +106,7 @@ exports org.jabref.logic.git; exports org.jabref.logic.pseudonymization; exports org.jabref.logic.citation.repository; + exports org.jabref.logic.git.util; requires java.base; 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 4974dc04c1d..77fdeac7fbd 100644 --- a/jablib/src/main/java/org/jabref/logic/git/GitSyncService.java +++ b/jablib/src/main/java/org/jabref/logic/git/GitSyncService.java @@ -6,6 +6,11 @@ import org.jabref.logic.bibtex.comparator.BibEntryDiff; import org.jabref.logic.git.util.GitBibParser; import org.jabref.logic.git.util.GitFileReader; +import org.jabref.logic.git.util.GitFileWriter; +import org.jabref.logic.git.util.GitRevisionLocator; +import org.jabref.logic.git.util.MergePlan; +import org.jabref.logic.git.util.MergeResult; +import org.jabref.logic.git.util.RevisionTriple; import org.jabref.logic.git.util.SemanticConflictDetector; import org.jabref.logic.git.util.SemanticMerger; import org.jabref.logic.importer.ImportFormatPreferences; @@ -13,25 +18,70 @@ import org.eclipse.jgit.api.Git; import org.eclipse.jgit.revwalk.RevCommit; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +/** + * Orchestrator for git sync service + * if (hasConflict) + * → UI merge; + * else + * → autoMerge := local + remoteDiff + */ public class GitSyncService { + private static final Logger LOGGER = LoggerFactory.getLogger(GitSyncService.class); private final ImportFormatPreferences importFormatPreferences; + private GitHandler gitHandler; - public GitSyncService(ImportFormatPreferences importFormatPreferences) { + public GitSyncService(ImportFormatPreferences importFormatPreferences, GitHandler gitHandler) { this.importFormatPreferences = importFormatPreferences; + this.gitHandler = gitHandler; + } + + /** + * Called when user clicks Pull + */ + public MergeResult pullAndMerge(Path bibFilePath) throws Exception { + Git git = Git.open(bibFilePath.getParent().toFile()); + + // 1. fetch latest remote branch + gitHandler.pullOnCurrentBranch(); + + // 2. Locating the base / local / remote versions + GitRevisionLocator locator = new GitRevisionLocator(); + RevisionTriple triple = locator.locateMergeCommits(git); + + // 3. Calling semantic merge logic + MergeResult result = performSemanticMerge(git, triple.base(), triple.local(), triple.remote(), bibFilePath); + + // 4. Automatic merge + if (result.successful()) { + gitHandler.createCommitOnCurrentBranch("Auto-merged by JabRef", false); + } + + return result; } public MergeResult performSemanticMerge(Git git, RevCommit baseCommit, RevCommit localCommit, RevCommit remoteCommit, - Path bibFilePath, - ImportFormatPreferences importFormatPreferences) throws Exception { + Path bibFilePath) throws Exception { + + Path bibPath = bibFilePath.toRealPath(); + Path workTree = git.getRepository().getWorkTree().toPath().toRealPath(); + Path relativePath; + + if (bibPath.startsWith(workTree)) { + relativePath = workTree.relativize(bibPath); + } else { + throw new IllegalStateException("Given .bib file is not inside repository"); + } // 1. Load three versions - String baseContent = GitFileReader.readFileFromCommit(git, baseCommit, bibFilePath); - String localContent = GitFileReader.readFileFromCommit(git, localCommit, bibFilePath); - String remoteContent = GitFileReader.readFileFromCommit(git, remoteCommit, bibFilePath); + String baseContent = GitFileReader.readFileFromCommit(git, baseCommit, relativePath); + String localContent = GitFileReader.readFileFromCommit(git, localCommit, relativePath); + String remoteContent = GitFileReader.readFileFromCommit(git, remoteCommit, relativePath); BibDatabaseContext base = GitBibParser.parseBibFromGit(baseContent, importFormatPreferences); BibDatabaseContext local = GitBibParser.parseBibFromGit(localContent, importFormatPreferences); @@ -41,20 +91,38 @@ public MergeResult performSemanticMerge(Git git, List conflicts = SemanticConflictDetector.detectConflicts(base, local, remote); if (!conflicts.isEmpty()) { - return MergeResult.conflictsFound(conflicts); // UI-resolvable + // Currently only handles non-conflicting cases. In the future, it may: + // - Store the current state along with 3 versions + // - Return conflicts along with base/local/remote versions for each entry + // - Invoke a UI merger (let the UI handle merging and return the result) + return MergeResult.conflictsFound(conflicts); // Conflicts: return the conflict result and let the UI layer handle it } + // If the user returns a manually merged result, it should use: i.e.: MergeResult performSemanticMerge(..., BibDatabaseContext userResolvedResult) + // 3. Apply remote patch to local - SemanticMerger.applyRemotePatchToDatabase(base, local, remote); + MergePlan plan = SemanticConflictDetector.extractMergePlan(base, remote); + SemanticMerger.applyMergePlan(local, plan); // 4. Write back merged result -// try { -// GitFileWriter.write(bibFilePath, local, importFormatPreferences); -// } catch (Exception e) { -// return MergeResult.failure("Failed to write merged file: " + e.getMessage()); -// } + GitFileWriter.write(bibFilePath, local, importFormatPreferences); return MergeResult.success(); } + + // WIP + public void push(Path bibFilePath) throws Exception { + this.gitHandler = new GitHandler(bibFilePath.getParent()); + + // 1. Auto-commit: commit if there are changes + boolean committed = gitHandler.createCommitOnCurrentBranch("Changes committed by JabRef", false); + + // 2. push to remote + if (committed) { + gitHandler.pushCommitsToRemoteRepository(); + } else { + LOGGER.info("No changes to commit — skipping push"); + } + } } diff --git a/jablib/src/main/java/org/jabref/logic/git/MergeResult.java b/jablib/src/main/java/org/jabref/logic/git/MergeResult.java deleted file mode 100644 index 120b160c53c..00000000000 --- a/jablib/src/main/java/org/jabref/logic/git/MergeResult.java +++ /dev/null @@ -1,35 +0,0 @@ -package org.jabref.logic.git; - -import java.util.List; - -import org.jabref.logic.bibtex.comparator.BibEntryDiff; - -public class MergeResult { - private final boolean success; - private final List conflicts; - - private MergeResult(boolean success, List conflicts) { - this.success = success; - this.conflicts = conflicts; - } - - public static MergeResult success() { - return new MergeResult(true, List.of()); - } - - public static MergeResult conflictsFound(List conflicts) { - return new MergeResult(false, conflicts); - } - - public boolean isSuccess() { - return success; - } - - public boolean hasConflicts() { - return !conflicts.isEmpty(); - } - - public List getConflicts() { - return conflicts; - } -} diff --git a/jablib/src/main/java/org/jabref/logic/git/util/GitBibParser.java b/jablib/src/main/java/org/jabref/logic/git/util/GitBibParser.java index 0227edf82b3..3cb33cf22d4 100644 --- a/jablib/src/main/java/org/jabref/logic/git/util/GitBibParser.java +++ b/jablib/src/main/java/org/jabref/logic/git/util/GitBibParser.java @@ -1,7 +1,9 @@ package org.jabref.logic.git.util; +import java.io.IOException; import java.io.StringReader; +import org.jabref.logic.JabRefException; import org.jabref.logic.importer.ImportFormatPreferences; import org.jabref.logic.importer.ParserResult; import org.jabref.logic.importer.fileformat.BibtexParser; @@ -9,10 +11,14 @@ import org.jabref.model.util.DummyFileUpdateMonitor; public class GitBibParser { - // TODO: exception handling - public static BibDatabaseContext parseBibFromGit(String bibContent, ImportFormatPreferences importFormatPreferences) throws Exception { + public static BibDatabaseContext parseBibFromGit(String bibContent, ImportFormatPreferences importFormatPreferences) throws JabRefException { BibtexParser parser = new BibtexParser(importFormatPreferences, new DummyFileUpdateMonitor()); - ParserResult result = parser.parse(new StringReader(bibContent)); + ParserResult result = null; + try { + result = parser.parse(new StringReader(bibContent)); + } catch (IOException e) { + throw new JabRefException("Failed to parse BibTeX content from Git", e); + } return result.getDatabaseContext(); } } diff --git a/jablib/src/main/java/org/jabref/logic/git/util/GitFileReader.java b/jablib/src/main/java/org/jabref/logic/git/util/GitFileReader.java index 57b06a8a651..22e6a593478 100644 --- a/jablib/src/main/java/org/jabref/logic/git/util/GitFileReader.java +++ b/jablib/src/main/java/org/jabref/logic/git/util/GitFileReader.java @@ -17,15 +17,17 @@ import org.eclipse.jgit.treewalk.TreeWalk; public class GitFileReader { - public static String readFileFromCommit(Git git, RevCommit commit, Path filePath) throws JabRefException { + // Unit test is in the GitSyncServiceTest + public static String 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, String.valueOf(filePath), commitTree)) { + try (TreeWalk treeWalk = TreeWalk.forPath(repository, String.valueOf(relativePath), commitTree)) { if (treeWalk == null) { - throw new JabRefException("File '" + filePath + "' not found in commit " + commit.getName()); + throw new JabRefException("File '" + relativePath + "' not found in commit " + commit.getName()); } // 3. load blob object ObjectId objectId = treeWalk.getObjectId(0); @@ -33,9 +35,9 @@ public static String readFileFromCommit(Git git, RevCommit commit, Path filePath return new String(loader.getBytes(), StandardCharsets.UTF_8); } catch (MissingObjectException | IncorrectObjectTypeException e) { - throw new JabRefException("Git object missing or incorrect when reading file: " + filePath, 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: " + filePath, 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/util/GitFileWriter.java b/jablib/src/main/java/org/jabref/logic/git/util/GitFileWriter.java index da308dfbd4a..dfb53496adc 100644 --- a/jablib/src/main/java/org/jabref/logic/git/util/GitFileWriter.java +++ b/jablib/src/main/java/org/jabref/logic/git/util/GitFileWriter.java @@ -1,13 +1,40 @@ package org.jabref.logic.git.util; import java.io.IOException; +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; import java.nio.file.Path; +import org.jabref.logic.exporter.AtomicFileWriter; +import org.jabref.logic.exporter.BibWriter; +import org.jabref.logic.exporter.BibtexDatabaseWriter; +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 { - // TODO: Review `BibDatabaseWriter` - public static void write(Path bibFilePath, BibDatabaseContext context, ImportFormatPreferences prefs) throws IOException { + public static void write(Path file, BibDatabaseContext ctx, ImportFormatPreferences importPrefs) throws IOException { + SelfContainedSaveConfiguration saveConfiguration = new SelfContainedSaveConfiguration(); + Charset encoding = ctx.getMetaData().getEncoding().orElse(StandardCharsets.UTF_8); + + synchronized (ctx) { + try (AtomicFileWriter fileWriter = new AtomicFileWriter(file, encoding, saveConfiguration.shouldMakeBackup())) { + BibWriter bibWriter = new BibWriter(fileWriter, ctx.getDatabase().getNewLineSeparator()); + BibtexDatabaseWriter writer = new BibtexDatabaseWriter( + bibWriter, + saveConfiguration, + importPrefs.fieldPreferences(), + importPrefs.citationKeyPatternPreferences(), + new BibEntryTypesManager() + ); + writer.saveDatabase(ctx); + + 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/util/GitRevisionLocator.java b/jablib/src/main/java/org/jabref/logic/git/util/GitRevisionLocator.java new file mode 100644 index 00000000000..b0a6791e515 --- /dev/null +++ b/jablib/src/main/java/org/jabref/logic/git/util/GitRevisionLocator.java @@ -0,0 +1,38 @@ +package org.jabref.logic.git.util; + +import org.eclipse.jgit.api.Git; +import org.eclipse.jgit.lib.ObjectId; +import org.eclipse.jgit.revwalk.RevCommit; +import org.eclipse.jgit.revwalk.RevWalk; + +/** + * Find the base/local/remote three commits: + * base = merge-base of HEAD and origin/main + * local = HEAD + * remote = origin/main + */ +public class GitRevisionLocator { + public RevisionTriple locateMergeCommits(Git git) throws Exception { + // assumes the remote branch is 'origin/main' + ObjectId headId = git.getRepository().resolve("HEAD"); + // and uses the default remote tracking reference + // does not support multiple remotes or custom remote branch names so far + ObjectId remoteId = git.getRepository().resolve("refs/remotes/origin/main"); + if (remoteId == null) { + throw new IllegalStateException("Remote branch missing origin/main."); + } + + try (RevWalk walk = new RevWalk(git.getRepository())) { + RevCommit local = walk.parseCommit(headId); + RevCommit remote = walk.parseCommit(remoteId); + + walk.setRevFilter(org.eclipse.jgit.revwalk.filter.RevFilter.MERGE_BASE); + walk.markStart(local); + walk.markStart(remote); + + RevCommit base = walk.next(); + + return new RevisionTriple(base, local, remote); + } + } +} diff --git a/jablib/src/main/java/org/jabref/logic/git/util/MergePlan.java b/jablib/src/main/java/org/jabref/logic/git/util/MergePlan.java new file mode 100644 index 00000000000..c8a501a1e68 --- /dev/null +++ b/jablib/src/main/java/org/jabref/logic/git/util/MergePlan.java @@ -0,0 +1,19 @@ +package org.jabref.logic.git.util; + +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. + * Currently placed in the logic package as a merge-specific value object since it's not a persistent or user-visible concept. + * may be moved to model in the future + * + * @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/util/MergeResult.java b/jablib/src/main/java/org/jabref/logic/git/util/MergeResult.java new file mode 100644 index 00000000000..8708b96a248 --- /dev/null +++ b/jablib/src/main/java/org/jabref/logic/git/util/MergeResult.java @@ -0,0 +1,19 @@ +package org.jabref.logic.git.util; + +import java.util.List; + +import org.jabref.logic.bibtex.comparator.BibEntryDiff; + +public record MergeResult(boolean successful, List conflicts) { + public static MergeResult conflictsFound(List conflicts) { + return new MergeResult(false, conflicts); + } + + public static MergeResult success() { + return new MergeResult(true, List.of()); + } + + public boolean hasConflicts() { + return !conflicts.isEmpty(); + } +} diff --git a/jablib/src/main/java/org/jabref/logic/git/util/RevisionTriple.java b/jablib/src/main/java/org/jabref/logic/git/util/RevisionTriple.java new file mode 100644 index 00000000000..6062ddc650c --- /dev/null +++ b/jablib/src/main/java/org/jabref/logic/git/util/RevisionTriple.java @@ -0,0 +1,15 @@ +package org.jabref.logic.git.util; + +import org.eclipse.jgit.revwalk.RevCommit; + +/** + * Holds the three relevant commits involved in a semantic three-way merge, + * it is a helper value object used exclusively during merge resolution, not part of the domain model + * so currently placed in the logic package, may be moved to model in the future + * + * @param base the merge base (common ancestor of local and remote) + * @param local the current local branch tip + * @param remote the tip of the remote tracking branch (typically origin/main) + */ +public record RevisionTriple( + RevCommit base, RevCommit local, RevCommit remote) { } diff --git a/jablib/src/main/java/org/jabref/logic/git/util/SemanticConflictDetector.java b/jablib/src/main/java/org/jabref/logic/git/util/SemanticConflictDetector.java index e077e2c724b..b04122f94c2 100644 --- a/jablib/src/main/java/org/jabref/logic/git/util/SemanticConflictDetector.java +++ b/jablib/src/main/java/org/jabref/logic/git/util/SemanticConflictDetector.java @@ -3,6 +3,8 @@ import java.util.ArrayList; import java.util.Collections; import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; import java.util.List; import java.util.Map; import java.util.Objects; @@ -56,20 +58,15 @@ public static List detectConflicts(BibDatabaseContext base, BibDat return conflicts; } - private static Map toDiffMap(List diffs) { - return diffs.stream() - .filter(diff -> diff.newEntry().getCitationKey().isPresent()) - .collect(Collectors.toMap( - diff -> diff.newEntry().getCitationKey().get(), - Function.identity())); - } - - public static Map toEntryMap(BibDatabaseContext ctx) { - return ctx.getDatabase().getEntries().stream() - .filter(entry -> entry.getCitationKey().isPresent()) - .collect(Collectors.toMap( - entry -> entry.getCitationKey().get(), - Function.identity())); + public static Map toEntryMap(BibDatabaseContext context) { + return context.getDatabase().getEntries().stream() + .filter(e -> e.getCitationKey().isPresent()) + .collect(Collectors.toMap( + e -> e.getCitationKey().get(), + Function.identity(), + (a, b) -> b, + LinkedHashMap::new + )); } private static boolean hasConflictingFields(BibEntry base, BibEntry local, BibEntry remote) { @@ -94,4 +91,52 @@ private static boolean hasConflictingFields(BibEntry base, BibEntry local, BibEn return false; } + + public static MergePlan extractMergePlan(BibDatabaseContext base, BibDatabaseContext remote) { + Map baseMap = toEntryMap(base); + Map remoteMap = toEntryMap(remote); + + Map> fieldPatches = new LinkedHashMap<>(); + List newEntries = new ArrayList<>(); + + for (Map.Entry remoteEntryPair : remoteMap.entrySet()) { + String key = remoteEntryPair.getKey(); + BibEntry remoteEntry = remoteEntryPair.getValue(); + BibEntry baseEntry = baseMap.get(key); + + if (baseEntry == null) { + // New entry (not in base) + newEntries.add(remoteEntry); + } else { + Map patch = computeFieldPatch(baseEntry, remoteEntry); + if (!patch.isEmpty()) { + fieldPatches.put(key, patch); + } + } + } + + return new MergePlan(fieldPatches, newEntries); + } + + /** + * Compares base and remote and constructs a patch at the field level. null == the field is deleted. + */ + private static Map computeFieldPatch(BibEntry base, BibEntry remote) { + Map patch = new LinkedHashMap<>(); + + Set allFields = new LinkedHashSet<>(); + allFields.addAll(base.getFields()); + allFields.addAll(remote.getFields()); + + for (Field field : allFields) { + String baseValue = base.getField(field).orElse(null); + String remoteValue = remote.getField(field).orElse(null); + + if (!Objects.equals(baseValue, remoteValue)) { + patch.put(field, remoteValue); + } + } + + return patch; + } } diff --git a/jablib/src/main/java/org/jabref/logic/git/util/SemanticMerger.java b/jablib/src/main/java/org/jabref/logic/git/util/SemanticMerger.java index 09abf918528..5f83cccb403 100644 --- a/jablib/src/main/java/org/jabref/logic/git/util/SemanticMerger.java +++ b/jablib/src/main/java/org/jabref/logic/git/util/SemanticMerger.java @@ -1,9 +1,7 @@ package org.jabref.logic.git.util; -import java.util.HashSet; import java.util.Map; -import java.util.Objects; -import java.util.Set; +import java.util.Optional; import org.jabref.model.database.BibDatabaseContext; import org.jabref.model.entry.BibEntry; @@ -14,66 +12,50 @@ public class SemanticMerger { private static final Logger LOGGER = LoggerFactory.getLogger(SemanticMerger.class); + /** - * Applies remote's non-conflicting field changes to local entry, in-place. - * Assumes conflict detection already run. + * 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 SemanticConflictDetector */ - public static void patchEntryNonConflictingFields(BibEntry base, BibEntry local, BibEntry remote) { - Set allFields = new HashSet<>(); - allFields.addAll(base.getFields()); - allFields.addAll(local.getFields()); - allFields.addAll(remote.getFields()); + public static void applyMergePlan(BibDatabaseContext local, MergePlan plan) { + applyPatchToDatabase(local, plan.fieldPatches()); - 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); + for (BibEntry newEntry : plan.newEntries()) { + BibEntry clone = (BibEntry) newEntry.clone(); + local.getDatabase().insertEntry(clone); + LOGGER.debug("Inserted new entry '{}'", newEntry.getCitationKey().orElse("?")); + } + } + + public static void applyPatchToDatabase(BibDatabaseContext local, Map> patchMap) { + for (Map.Entry> entry : patchMap.entrySet()) { + String key = entry.getKey(); + Map fieldPatch = entry.getValue(); + Optional maybeLocalEntry = local.getDatabase().getEntryByCitationKey(key); - if (Objects.equals(baseVal, localVal) && !Objects.equals(baseVal, remoteVal)) { - // Local untouched, remote changed -> apply remote - if (remoteVal != null) { - local.setField(field, remoteVal); - } else { - local.clearField(field); - } - } else if (!Objects.equals(baseVal, localVal) && !Objects.equals(baseVal, remoteVal) && !Objects.equals(localVal, remoteVal)) { - // This should be conflict, but always assume it's already filtered before this class - LOGGER.debug("Unexpected field-level conflict skipped: " + field.getName()); + if (maybeLocalEntry.isEmpty()) { + LOGGER.warn("Skip patch: local does not contain entry '{}'", key); + continue; } - // else: either already applied or local wins + + BibEntry localEntry = maybeLocalEntry.get(); + applyFieldPatchToEntry(localEntry, fieldPatch); } } - /** - * Applies remote diffs (based on base) onto local BibDatabaseContext. - * - Adds new entries from remote if not present locally. - * - Applies field-level patches on existing entries. - * - Does NOT handle deletions. - */ - public static void applyRemotePatchToDatabase(BibDatabaseContext base, - BibDatabaseContext local, - BibDatabaseContext remote) { - Map baseMap = SemanticConflictDetector.toEntryMap(base); - Map localMap = SemanticConflictDetector.toEntryMap(local); - Map remoteMap = SemanticConflictDetector.toEntryMap(remote); - - for (Map.Entry entry : remoteMap.entrySet()) { - String key = entry.getKey(); - BibEntry remoteEntry = entry.getValue(); - BibEntry baseEntry = baseMap.getOrDefault(key, new BibEntry()); - BibEntry localEntry = localMap.get(key); + 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 (localEntry != null) { - // Apply patch to existing entry - patchEntryNonConflictingFields(baseEntry, localEntry, remoteEntry); - } else if (baseEntry == null) { - // New entry from remote (not in base or local) -> insert - BibEntry newEntry = (BibEntry) remoteEntry.clone(); - local.getDatabase().insertEntry(newEntry); + if (newValue == null) { + localEntry.clearField(field); + LOGGER.debug("Cleared field '{}' (was '{}')", field.getName(), oldValue); } else { - // Entry was deleted in local → respect deletion (do nothing) + localEntry.setField(field, newValue); + LOGGER.debug("Set field '{}' to '{}', replacing '{}'", field.getName(), newValue, oldValue); } } - // Optional: if localMap contains entries absent in remote+base -> do nothing (local additions) } } 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 893bc37a726..f93ca1944dd 100644 --- a/jablib/src/test/java/org/jabref/logic/git/GitSyncServiceTest.java +++ b/jablib/src/test/java/org/jabref/logic/git/GitSyncServiceTest.java @@ -3,19 +3,23 @@ import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; +import java.util.List; import org.jabref.logic.git.util.GitFileReader; +import org.jabref.logic.git.util.MergeResult; import org.jabref.logic.importer.ImportFormatPreferences; 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.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; @@ -33,12 +37,12 @@ class GitSyncServiceTest { private final PersonIdent bob = new PersonIdent("Bob", "bob@example.org"); private final String initialContent = """ @article{a, - author = {don't know the author} + author = {don't know the author}, doi = {xya}, } @article{b, - author = {author-b} + author = {don't know the author}, doi = {xyz}, } """; @@ -46,12 +50,12 @@ class GitSyncServiceTest { // Alice modifies a private final String aliceUpdatedContent = """ @article{a, - author = {author-a} + author = {author-a}, doi = {xya}, } @article{b, - author = {author-b} + author = {don't know the author}, doi = {xyz}, } """; @@ -59,12 +63,12 @@ class GitSyncServiceTest { // Bob reorders a and b private final String bobUpdatedContent = """ @article{b, - author = {author-b} + author = {author-b}, doi = {xyz}, } @article{a, - author = {lala} + author = {don't know the author}, doi = {xya}, } """; @@ -72,27 +76,48 @@ class GitSyncServiceTest { /** * 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(','); - // Create empty repository - git = Git.init() - .setDirectory(tempDir.toFile()) - .setInitialBranch("main") - .call(); - - library = tempDir.resolve("library.bib"); - - baseCommit = writeAndCommit(initialContent, "Inital commit", alice, library, git); - - aliceCommit = writeAndCommit(aliceUpdatedContent, "Fix author of a", alice, library, git); - - git.checkout().setStartPoint(baseCommit).setCreateBranch(true).setName("bob-branch").call(); - - bobCommit = writeAndCommit(bobUpdatedContent, "Exchange a with b", bob, library, git); + // create fake remote repo + Path remoteDir = tempDir.resolve("remote.git"); + Git remoteGit = Git.init().setBare(true).setDirectory(remoteDir.toFile()).call(); + + // Alice clone remote -> local repository + Path aliceDir = tempDir.resolve("alice"); + Git aliceGit = Git.cloneRepository() + .setURI(remoteDir.toUri().toString()) + .setDirectory(aliceDir.toFile()) + .setBranch("main") + .call(); + this.git = aliceGit; + this.library = aliceDir.resolve("library.bib"); + + // Alice: initial commit + baseCommit = writeAndCommit(initialContent, "Inital commit", alice, library, aliceGit); + git.push().setRemote("origin").setRefSpecs(new RefSpec("main")).call(); + + // Bob clone remote + Path bobDir = tempDir.resolve("bob"); + Git bobGit = Git.cloneRepository() + .setURI(remoteDir.toUri().toString()) + .setDirectory(bobDir.toFile()) + .setBranchesToClone(List.of("refs/heads/main")) + .setBranch("refs/heads/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("main")).call(); + + // back to Alice's branch, fetch remote + aliceCommit = writeAndCommit(aliceUpdatedContent, "Fix author of a", alice, library, aliceGit); + git.fetch().setRemote("origin").call(); // ToDo: Replace by call to GitSyncService crafting a merge commit // git.merge().include(aliceCommit).include(bobCommit).call(); // Will throw exception bc of merge conflict @@ -102,7 +127,27 @@ void aliceBobSimple(@TempDir Path tempDir) throws Exception { } @Test - void performsSemanticMergeWhenNoConflicts() throws Exception { + void pullTriggersSemanticMergeWhenNoConflicts() throws Exception { + GitHandler gitHandler = mock(GitHandler.class); + GitSyncService syncService = new GitSyncService(importFormatPreferences, gitHandler); + MergeResult result = syncService.pullAndMerge(library); + + assertTrue(result.successful()); + 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 @@ -118,7 +163,18 @@ void readFromCommits() throws Exception { private RevCommit writeAndCommit(String content, String message, PersonIdent author, Path library, Git git) throws Exception { Files.writeString(library, content, StandardCharsets.UTF_8); - git.add().addFilepattern(library.getFileName().toString()).call(); - return git.commit().setAuthor(author).setMessage(message).call(); + String relativePath = git.getRepository().getWorkTree().toPath().relativize(library).toString(); + git.add().addFilepattern(relativePath).call(); + return git.commit() + .setAuthor(author) + .setMessage(message) + .call(); + } + + private String normalize(String s) { + return s.trim() + .replaceAll("@[aA]rticle", "@article") + .replaceAll("\\s+", "") + .toLowerCase(); } } diff --git a/jablib/src/test/java/org/jabref/logic/git/util/GitFileWriterTest.java b/jablib/src/test/java/org/jabref/logic/git/util/GitFileWriterTest.java new file mode 100644 index 00000000000..acf05e01a0c --- /dev/null +++ b/jablib/src/test/java/org/jabref/logic/git/util/GitFileWriterTest.java @@ -0,0 +1,51 @@ +package org.jabref.logic.git.util; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +import org.jabref.logic.importer.ImportFormatPreferences; +import org.jabref.model.database.BibDatabaseContext; +import org.jabref.model.entry.BibEntry; +import org.jabref.model.entry.field.StandardField; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Answers; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class GitFileWriterTest { + private ImportFormatPreferences importFormatPreferences; + @BeforeEach + void setUp() { + importFormatPreferences = mock(ImportFormatPreferences.class, Answers.RETURNS_DEEP_STUBS); + when(importFormatPreferences.bibEntryPreferences().getKeywordSeparator()).thenReturn(','); + } + + @Test + void testWriteThenReadBack() throws Exception { + BibDatabaseContext inputCtx = GitBibParser.parseBibFromGit( + """ + @article{a, + author = {Alice}, + title = {Test} + } + """, importFormatPreferences); + + Path tempFile = Files.createTempFile("tempgitwriter", ".bib"); + + GitFileWriter.write(tempFile, inputCtx, importFormatPreferences); + + BibDatabaseContext outputCtx = GitBibParser.parseBibFromGit(Files.readString(tempFile), importFormatPreferences); + + List inputEntries = inputCtx.getDatabase().getEntries(); + List outputEntries = outputCtx.getDatabase().getEntries(); + + assertEquals(inputEntries.size(), outputEntries.size()); + assertEquals(inputEntries.get(0).getCitationKey(), outputEntries.get(0).getCitationKey()); + assertEquals(inputEntries.get(0).getField(StandardField.AUTHOR), outputEntries.get(0).getField(StandardField.AUTHOR)); + } +} diff --git a/jablib/src/test/java/org/jabref/logic/git/util/GitRevisionLocatorTest.java b/jablib/src/test/java/org/jabref/logic/git/util/GitRevisionLocatorTest.java new file mode 100644 index 00000000000..602d2f3edf0 --- /dev/null +++ b/jablib/src/test/java/org/jabref/logic/git/util/GitRevisionLocatorTest.java @@ -0,0 +1,50 @@ +package org.jabref.logic.git.util; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; + +import org.eclipse.jgit.api.Git; +import org.eclipse.jgit.revwalk.RevCommit; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class GitRevisionLocatorTest { + @Test + void testLocateMergeCommits(@TempDir Path tempDir) throws Exception { + Path bibFile = tempDir.resolve("library.bib"); + Git git = Git.init().setDirectory(tempDir.toFile()).setInitialBranch("main").call(); + + // create base commit + Files.writeString(bibFile, "@article{a, author = {x}}", StandardCharsets.UTF_8); + git.add().addFilepattern("library.bib").call(); + RevCommit base = git.commit().setMessage("base").call(); + + // create local (HEAD) + Files.writeString(bibFile, "@article{a, author = {local}}", StandardCharsets.UTF_8); + git.add().addFilepattern("library.bib").call(); + RevCommit local = git.commit().setMessage("local").call(); + + // create remote branch and commit + git.checkout().setName("remote").setCreateBranch(true).setStartPoint(base).call(); + Files.writeString(bibFile, "@article{a, author = {remote}}", StandardCharsets.UTF_8); + git.add().addFilepattern("library.bib").call(); + RevCommit remote = git.commit().setMessage("remote").call(); + + // restore HEAD to local + git.checkout().setName("main").call(); + + // simulate fake remote ref + git.getRepository().updateRef("refs/remotes/origin/main").link("refs/heads/remote"); + + // test locator + GitRevisionLocator locator = new GitRevisionLocator(); + RevisionTriple triple = locator.locateMergeCommits(git); + + assertEquals(base.getId(), triple.base().getId()); + assertEquals(local.getId(), triple.local().getId()); + assertEquals(remote.getId(), triple.remote().getId()); + } +} diff --git a/jablib/src/test/java/org/jabref/logic/git/util/SemanticConflictDetectorTest.java b/jablib/src/test/java/org/jabref/logic/git/util/SemanticConflictDetectorTest.java index f4398398bd8..7d512230050 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 @@ -4,16 +4,20 @@ 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.bibtex.comparator.BibEntryDiff; import org.jabref.logic.importer.ImportFormatPreferences; import org.jabref.model.database.BibDatabaseContext; +import org.jabref.model.entry.field.Field; +import org.jabref.model.entry.field.StandardField; import org.eclipse.jgit.api.Git; import org.eclipse.jgit.lib.PersonIdent; import org.eclipse.jgit.revwalk.RevCommit; import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.Arguments; @@ -411,4 +415,69 @@ static Stream provideConflictCases() { ) ); } + + @Test + void testExtractMergePlan_T10_onlyRemoteChangedEntryB() throws Exception { + String base = """ + @article{a, + author = {lala}, + doi = {xya}, + } + @article{b, + author = {lala}, + doi = {xyz}, + } + """; + String remote = """ + @article{b, + author = {author-b}, + doi = {xyz}, + } + @article{a, + author = {lala}, + doi = {xya}, + } + """; + + RevCommit baseCommit = writeAndCommit(base, "base", alice); + RevCommit remoteCommit = writeAndCommit(remote, "remote", bob); + BibDatabaseContext baseCtx = parse(baseCommit); + BibDatabaseContext remoteCtx = parse(remoteCommit); + + MergePlan plan = SemanticConflictDetector.extractMergePlan(baseCtx, remoteCtx); + + 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 testExtractMergePlan_T11_remoteAddsField() throws Exception { + String base = """ + @article{a, + author = {lala}, + doi = {xya}, + } + """; + String remote = """ + @article{a, + author = {lala}, + doi = {xya}, + year = {2025}, + } + """; + + RevCommit baseCommit = writeAndCommit(base, "base", alice); + RevCommit remoteCommit = writeAndCommit(remote, "remote", bob); + BibDatabaseContext baseCtx = parse(baseCommit); + BibDatabaseContext remoteCtx = parse(remoteCommit); + + MergePlan plan = SemanticConflictDetector.extractMergePlan(baseCtx, remoteCtx); + + 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 index 4e28ecb1149..b15d70f1048 100644 --- a/jablib/src/test/java/org/jabref/logic/git/util/SemanticMergerTest.java +++ b/jablib/src/test/java/org/jabref/logic/git/util/SemanticMergerTest.java @@ -1,6 +1,5 @@ package org.jabref.logic.git.util; -import java.util.List; import java.util.stream.Stream; import org.jabref.logic.importer.ImportFormatPreferences; @@ -27,18 +26,6 @@ void setup() { when(importFormatPreferences.bibEntryPreferences().getKeywordSeparator()).thenReturn(','); } - @ParameterizedTest(name = "{0}") - @MethodSource("providePatchCases") - void testPatchEntry(String description, String base, String local, String remote, String expectedAuthor) throws Exception { - BibEntry baseEntry = parseSingleEntry(base); - BibEntry localEntry = parseSingleEntry(local); - BibEntry remoteEntry = parseSingleEntry(remote); - - SemanticMerger.patchEntryNonConflictingFields(baseEntry, localEntry, remoteEntry); - - assertEquals(expectedAuthor, localEntry.getField(StandardField.AUTHOR).orElse(null)); - } - @ParameterizedTest(name = "Database patch: {0}") @MethodSource("provideDatabasePatchCases") void testPatchDatabase(String description, String base, String local, String remote, String expectedAuthor) throws Exception { @@ -46,38 +33,15 @@ void testPatchDatabase(String description, String base, String local, String rem BibDatabaseContext localCtx = GitBibParser.parseBibFromGit(local, importFormatPreferences); BibDatabaseContext remoteCtx = GitBibParser.parseBibFromGit(remote, importFormatPreferences); - SemanticMerger.applyRemotePatchToDatabase(baseCtx, localCtx, remoteCtx); + MergePlan plan = SemanticConflictDetector.extractMergePlan(baseCtx, remoteCtx); + SemanticMerger.applyMergePlan(localCtx, plan); BibEntry patched = localCtx.getDatabase().getEntryByCitationKey("a").orElseThrow(); assertEquals(expectedAuthor, patched.getField(StandardField.AUTHOR).orElse(null)); } - static Stream providePatchCases() { - return Stream.of( - Arguments.of("Remote changed, local unchanged", - "@article{a, author = {X} }", - "@article{a, author = {X} }", - "@article{a, author = {Bob} }", - "Bob" - ), - Arguments.of("Local changed, remote unchanged", - "@article{a, author = {X} }", - "@article{a, author = {Alice} }", - "@article{a, author = {X} }", - "Alice" - ), - Arguments.of("Both changed to same value", - "@article{a, author = {X} }", - "@article{a, author = {Y} }", - "@article{a, author = {Y} }", - "Y" - ) - ); - } - static Stream provideDatabasePatchCases() { return Stream.of( - // TODO: more test case Arguments.of("T1 - remote changed a field, local unchanged", """ @article{a, @@ -99,7 +63,6 @@ static Stream provideDatabasePatchCases() { """, "bob" ), - Arguments.of("T2 - local changed a field, remote unchanged", """ @article{a, @@ -121,7 +84,6 @@ static Stream provideDatabasePatchCases() { """, "alice" ), - Arguments.of("T3 - both changed to same value", """ @article{a, @@ -145,13 +107,4 @@ static Stream provideDatabasePatchCases() { ) ); } - - private BibEntry parseSingleEntry(String content) throws Exception { - BibDatabaseContext context = GitBibParser.parseBibFromGit(content, importFormatPreferences); - List entries = context.getDatabase().getEntries(); - if (entries.size() != 1) { - throw new IllegalStateException("Test assumes exactly one entry"); - } - return entries.get(0); - } } From 8d3fa2bb0120f21df00b3073b3d2a5eefbe97803 Mon Sep 17 00:00:00 2001 From: Wanling Fu Date: Sun, 22 Jun 2025 23:12:03 +0100 Subject: [PATCH 04/37] chore(git): Apply review suggestions #12350 --- .../org/jabref/gui/LibraryTabContainer.java | 2 +- .../gui/autosaveandbackup/BackupManager.java | 2 +- .../java/org/jabref/gui/desktop/os/Linux.java | 4 +- .../gui/exporter/SaveDatabaseAction.java | 6 +-- .../gui/openoffice/OOBibBaseConnect.java | 2 +- .../shared/SharedDatabaseLoginDialogView.java | 2 +- .../bibtex/comparator/EntryComparator.java | 2 +- .../bibtex/comparator/FieldComparator.java | 2 +- .../SearchCitationsRelationsService.java | 2 +- .../exporter/AtomicFileOutputStream.java | 4 +- .../java/org/jabref/logic/git/GitHandler.java | 10 +++++ .../org/jabref/logic/git/GitSyncService.java | 26 ++++++----- .../jabref/logic/git/util/GitBibParser.java | 4 +- .../jabref/logic/git/util/GitFileReader.java | 8 ++-- .../jabref/logic/git/util/GitFileWriter.java | 10 ++--- .../logic/git/util/GitRevisionLocator.java | 14 ++++-- .../jabref/logic/git/util/MergeResult.java | 9 ++-- .../jabref/logic/git/util/RevisionTriple.java | 3 +- .../git/util/SemanticConflictDetector.java | 19 +++++--- .../fetcher/MergingIdBasedFetcher.java | 2 +- .../fileformat/pdf/PdfContentImporter.java | 2 +- .../GrobidPlainCitationParser.java | 2 +- .../logic/remote/client/RemoteClient.java | 4 +- .../org/jabref/logic/util/BackgroundTask.java | 2 +- .../logic/util/ExternalLinkCreator.java | 2 +- .../org/jabref/logic/git/GitHandlerTest.java | 43 +++++++++++++++++++ .../jabref/logic/git/GitSyncServiceTest.java | 4 +- .../logic/git/util/GitBibParserTest.java | 2 +- .../logic/git/util/GitFileWriterTest.java | 10 ++--- .../util/SemanticConflictDetectorTest.java | 20 ++++----- .../logic/git/util/SemanticMergerTest.java | 12 +++--- 31 files changed, 154 insertions(+), 82 deletions(-) diff --git a/jabgui/src/main/java/org/jabref/gui/LibraryTabContainer.java b/jabgui/src/main/java/org/jabref/gui/LibraryTabContainer.java index b572d190e6d..4ed838c9f00 100644 --- a/jabgui/src/main/java/org/jabref/gui/LibraryTabContainer.java +++ b/jabgui/src/main/java/org/jabref/gui/LibraryTabContainer.java @@ -27,7 +27,7 @@ public interface LibraryTabContainer { * Closes a designated libraryTab * * @param tab to be closed. - * @return true if closing the tab was successful + * @return true if closing the tab was isSuccessful */ boolean closeTab(@Nullable LibraryTab tab); diff --git a/jabgui/src/main/java/org/jabref/gui/autosaveandbackup/BackupManager.java b/jabgui/src/main/java/org/jabref/gui/autosaveandbackup/BackupManager.java index 2ccf1492940..efed58ae162 100644 --- a/jabgui/src/main/java/org/jabref/gui/autosaveandbackup/BackupManager.java +++ b/jabgui/src/main/java/org/jabref/gui/autosaveandbackup/BackupManager.java @@ -275,7 +275,7 @@ void performBackup(Path backupPath) { BibDatabaseContext bibDatabaseContextClone = new BibDatabaseContext(bibDatabaseClone, bibDatabaseContext.getMetaData()); Charset encoding = bibDatabaseContext.getMetaData().getEncoding().orElse(StandardCharsets.UTF_8); - // We want to have successful backups only + // We want to have isSuccessful backups only // Thus, we do not use a plain "FileWriter", but the "AtomicFileWriter" // Example: What happens if one hard powers off the machine (or kills the jabref process) during writing of the backup? // This MUST NOT create a broken backup file that then jabref wants to "restore" from? diff --git a/jabgui/src/main/java/org/jabref/gui/desktop/os/Linux.java b/jabgui/src/main/java/org/jabref/gui/desktop/os/Linux.java index aae1d826d3e..66f313ab1db 100644 --- a/jabgui/src/main/java/org/jabref/gui/desktop/os/Linux.java +++ b/jabgui/src/main/java/org/jabref/gui/desktop/os/Linux.java @@ -47,10 +47,10 @@ private void nativeOpenFile(String filePath) { String[] cmd = {"xdg-open", filePath}; Runtime.getRuntime().exec(cmd); } catch (Exception e2) { - LoggerFactory.getLogger(Linux.class).warn("Open operation not successful: ", e2); + LoggerFactory.getLogger(Linux.class).warn("Open operation not isSuccessful: ", e2); } } catch (IOException e) { - LoggerFactory.getLogger(Linux.class).warn("Native open operation not successful: ", e); + LoggerFactory.getLogger(Linux.class).warn("Native open operation not isSuccessful: ", e); } }); } diff --git a/jabgui/src/main/java/org/jabref/gui/exporter/SaveDatabaseAction.java b/jabgui/src/main/java/org/jabref/gui/exporter/SaveDatabaseAction.java index bfb39ee2e45..ed2790b9cba 100644 --- a/jabgui/src/main/java/org/jabref/gui/exporter/SaveDatabaseAction.java +++ b/jabgui/src/main/java/org/jabref/gui/exporter/SaveDatabaseAction.java @@ -52,7 +52,7 @@ * when closing a database or quitting the applications. *

* The save operation is loaded off of the GUI thread using {@link BackgroundTask}. Callers can query whether the - * operation was canceled, or whether it was successful. + * operation was canceled, or whether it was isSuccessful. */ public class SaveDatabaseAction { private static final Logger LOGGER = LoggerFactory.getLogger(SaveDatabaseAction.class); @@ -134,8 +134,8 @@ public void saveSelectedAsPlain() { /** * @param file the new file name to save the database to. This is stored in the database context of the panel upon - * successful save. - * @return true on successful save + * isSuccessful save. + * @return true on isSuccessful save */ boolean saveAs(Path file, SaveDatabaseMode mode) { BibDatabaseContext context = libraryTab.getBibDatabaseContext(); diff --git a/jabgui/src/main/java/org/jabref/gui/openoffice/OOBibBaseConnect.java b/jabgui/src/main/java/org/jabref/gui/openoffice/OOBibBaseConnect.java index 333c9ed3d49..b2e55c9e8b0 100644 --- a/jabgui/src/main/java/org/jabref/gui/openoffice/OOBibBaseConnect.java +++ b/jabgui/src/main/java/org/jabref/gui/openoffice/OOBibBaseConnect.java @@ -173,7 +173,7 @@ public String toString() { *

* If there is a single document to choose from, selects that. If there are more than one, shows selection dialog. If there are none, throws NoDocumentFoundException *

- * After successful selection connects to the selected document and extracts some frequently used parts (starting points for managing its content). + * After isSuccessful selection connects to the selected document and extracts some frequently used parts (starting points for managing its content). *

* Finally initializes this.xTextDocument with the selected document and parts extracted. */ diff --git a/jabgui/src/main/java/org/jabref/gui/shared/SharedDatabaseLoginDialogView.java b/jabgui/src/main/java/org/jabref/gui/shared/SharedDatabaseLoginDialogView.java index ed30a868a2b..dc6903bf8f5 100644 --- a/jabgui/src/main/java/org/jabref/gui/shared/SharedDatabaseLoginDialogView.java +++ b/jabgui/src/main/java/org/jabref/gui/shared/SharedDatabaseLoginDialogView.java @@ -34,7 +34,7 @@ /** * This offers the user to connect to a remove SQL database. - * Moreover, it directly opens the shared database after successful connection. + * Moreover, it directly opens the shared database after isSuccessful connection. */ public class SharedDatabaseLoginDialogView extends BaseDialog { @FXML private ComboBox databaseType; diff --git a/jablib/src/main/java/org/jabref/logic/bibtex/comparator/EntryComparator.java b/jablib/src/main/java/org/jabref/logic/bibtex/comparator/EntryComparator.java index f80bd483709..664b0ba9e30 100644 --- a/jablib/src/main/java/org/jabref/logic/bibtex/comparator/EntryComparator.java +++ b/jablib/src/main/java/org/jabref/logic/bibtex/comparator/EntryComparator.java @@ -86,7 +86,7 @@ public int compare(BibEntry e1, BibEntry e2) { try { int i1 = Integer.parseInt((String) f1); int i2 = Integer.parseInt((String) f2); - // Ok, parsing was successful. Update f1 and f2: + // Ok, parsing was isSuccessful. Update f1 and f2: f1 = i1; f2 = i2; } catch (NumberFormatException ex) { diff --git a/jablib/src/main/java/org/jabref/logic/bibtex/comparator/FieldComparator.java b/jablib/src/main/java/org/jabref/logic/bibtex/comparator/FieldComparator.java index e1e2ff77782..f2dbac25ceb 100644 --- a/jablib/src/main/java/org/jabref/logic/bibtex/comparator/FieldComparator.java +++ b/jablib/src/main/java/org/jabref/logic/bibtex/comparator/FieldComparator.java @@ -154,7 +154,7 @@ public int compare(BibEntry e1, BibEntry e2) { } if (i1present && i2present) { - // Ok, parsing was successful. Update f1 and f2: + // Ok, parsing was isSuccessful. Update f1 and f2: return Integer.compare(i1, i2) * multiplier; } else if (i1present) { // The first one was parsable, but not the second one. diff --git a/jablib/src/main/java/org/jabref/logic/citation/SearchCitationsRelationsService.java b/jablib/src/main/java/org/jabref/logic/citation/SearchCitationsRelationsService.java index 95db8900c1b..499ff8f6cfd 100644 --- a/jablib/src/main/java/org/jabref/logic/citation/SearchCitationsRelationsService.java +++ b/jablib/src/main/java/org/jabref/logic/citation/SearchCitationsRelationsService.java @@ -66,7 +66,7 @@ public List searchReferences(BibEntry referenced) { /** * If the store was empty and nothing was fetch in any case (empty fetch, or error) then yes => empty list - * If the store was not empty and nothing was fetched after a successful fetch => the store will be erased and the returned collection will be empty + * If the store was not empty and nothing was fetched after a isSuccessful fetch => the store will be erased and the returned collection will be empty * If the store was not empty and an error occurs while fetching => will return the content of the store */ public List searchCitations(BibEntry cited) { diff --git a/jablib/src/main/java/org/jabref/logic/exporter/AtomicFileOutputStream.java b/jablib/src/main/java/org/jabref/logic/exporter/AtomicFileOutputStream.java index a52633c975a..bfd163517bc 100644 --- a/jablib/src/main/java/org/jabref/logic/exporter/AtomicFileOutputStream.java +++ b/jablib/src/main/java/org/jabref/logic/exporter/AtomicFileOutputStream.java @@ -76,7 +76,7 @@ public class AtomicFileOutputStream extends FilterOutputStream { * Creates a new output stream to write to or replace the file at the specified path. * * @param path the path of the file to write to or replace - * @param keepBackup whether to keep the backup file (.sav) after a successful write process + * @param keepBackup whether to keep the backup file (.sav) after a isSuccessful write process */ public AtomicFileOutputStream(Path path, boolean keepBackup) throws IOException { // Files.newOutputStream(getPathOfTemporaryFile(path)) leads to a "sun.nio.ch.ChannelOutputStream", which does not offer "lock" @@ -85,7 +85,7 @@ public AtomicFileOutputStream(Path path, boolean keepBackup) throws IOException /** * Creates a new output stream to write to or replace the file at the specified path. - * The backup file (.sav) is deleted when write was successful. + * The backup file (.sav) is deleted when write was isSuccessful. * * @param path the path of the file to write to or replace */ 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..55c49f07fe9 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,14 @@ public String getCurrentlyCheckedOutBranch() throws IOException { return git.getRepository().getBranch(); } } + + public void fetchOnCurrentBranch() throws IOException { + try (Git git = Git.open(this.repositoryPathAsFile)) { + git.fetch() + .setCredentialsProvider(credentialsProvider) + .call(); + } catch (GitAPIException e) { + LOGGER.info("Failed to fetch from remote", e); + } + } } 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 77fdeac7fbd..579ac7b0918 100644 --- a/jablib/src/main/java/org/jabref/logic/git/GitSyncService.java +++ b/jablib/src/main/java/org/jabref/logic/git/GitSyncService.java @@ -1,8 +1,10 @@ package org.jabref.logic.git; +import java.io.IOException; import java.nio.file.Path; import java.util.List; +import org.jabref.logic.JabRefException; import org.jabref.logic.bibtex.comparator.BibEntryDiff; import org.jabref.logic.git.util.GitBibParser; import org.jabref.logic.git.util.GitFileReader; @@ -17,6 +19,7 @@ import org.jabref.model.database.BibDatabaseContext; import org.eclipse.jgit.api.Git; +import org.eclipse.jgit.api.errors.GitAPIException; import org.eclipse.jgit.revwalk.RevCommit; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -30,6 +33,8 @@ */ public class GitSyncService { private static final Logger LOGGER = LoggerFactory.getLogger(GitSyncService.class); + + private static final boolean AMEND = true; private final ImportFormatPreferences importFormatPreferences; private GitHandler gitHandler; @@ -41,11 +46,11 @@ public GitSyncService(ImportFormatPreferences importFormatPreferences, GitHandle /** * Called when user clicks Pull */ - public MergeResult pullAndMerge(Path bibFilePath) throws Exception { + public MergeResult fetchAndMerge(Path bibFilePath) throws GitAPIException, IOException, JabRefException { Git git = Git.open(bibFilePath.getParent().toFile()); // 1. fetch latest remote branch - gitHandler.pullOnCurrentBranch(); + gitHandler.fetchOnCurrentBranch(); // 2. Locating the base / local / remote versions GitRevisionLocator locator = new GitRevisionLocator(); @@ -55,8 +60,8 @@ public MergeResult pullAndMerge(Path bibFilePath) throws Exception { MergeResult result = performSemanticMerge(git, triple.base(), triple.local(), triple.remote(), bibFilePath); // 4. Automatic merge - if (result.successful()) { - gitHandler.createCommitOnCurrentBranch("Auto-merged by JabRef", false); + if (result.isSuccessful()) { + gitHandler.createCommitOnCurrentBranch("Auto-merged by JabRef", !AMEND); } return result; @@ -66,17 +71,16 @@ public MergeResult performSemanticMerge(Git git, RevCommit baseCommit, RevCommit localCommit, RevCommit remoteCommit, - Path bibFilePath) throws Exception { + Path bibFilePath) throws IOException, JabRefException { Path bibPath = bibFilePath.toRealPath(); Path workTree = git.getRepository().getWorkTree().toPath().toRealPath(); Path relativePath; - if (bibPath.startsWith(workTree)) { - relativePath = workTree.relativize(bibPath); - } else { + if (!bibPath.startsWith(workTree)) { throw new IllegalStateException("Given .bib file is not inside repository"); } + relativePath = workTree.relativize(bibPath); // 1. Load three versions String baseContent = GitFileReader.readFileFromCommit(git, baseCommit, relativePath); @@ -95,7 +99,7 @@ public MergeResult performSemanticMerge(Git git, // - Store the current state along with 3 versions // - Return conflicts along with base/local/remote versions for each entry // - Invoke a UI merger (let the UI handle merging and return the result) - return MergeResult.conflictsFound(conflicts); // Conflicts: return the conflict result and let the UI layer handle it + return MergeResult.withConflicts(conflicts); // TODO: revisit the naming } // If the user returns a manually merged result, it should use: i.e.: MergeResult performSemanticMerge(..., BibDatabaseContext userResolvedResult) @@ -111,11 +115,11 @@ public MergeResult performSemanticMerge(Git git, } // WIP - public void push(Path bibFilePath) throws Exception { + public void push(Path bibFilePath) throws GitAPIException, IOException { this.gitHandler = new GitHandler(bibFilePath.getParent()); // 1. Auto-commit: commit if there are changes - boolean committed = gitHandler.createCommitOnCurrentBranch("Changes committed by JabRef", false); + boolean committed = gitHandler.createCommitOnCurrentBranch("Changes committed by JabRef", !AMEND); // 2. push to remote if (committed) { diff --git a/jablib/src/main/java/org/jabref/logic/git/util/GitBibParser.java b/jablib/src/main/java/org/jabref/logic/git/util/GitBibParser.java index 3cb33cf22d4..358d696aa9c 100644 --- a/jablib/src/main/java/org/jabref/logic/git/util/GitBibParser.java +++ b/jablib/src/main/java/org/jabref/logic/git/util/GitBibParser.java @@ -13,12 +13,12 @@ public class GitBibParser { public static BibDatabaseContext parseBibFromGit(String bibContent, ImportFormatPreferences importFormatPreferences) throws JabRefException { BibtexParser parser = new BibtexParser(importFormatPreferences, new DummyFileUpdateMonitor()); - ParserResult result = null; + ParserResult result; try { result = parser.parse(new StringReader(bibContent)); + return result.getDatabaseContext(); } catch (IOException e) { throw new JabRefException("Failed to parse BibTeX content from Git", e); } - return result.getDatabaseContext(); } } diff --git a/jablib/src/main/java/org/jabref/logic/git/util/GitFileReader.java b/jablib/src/main/java/org/jabref/logic/git/util/GitFileReader.java index 22e6a593478..3f56f8a9eb7 100644 --- a/jablib/src/main/java/org/jabref/logic/git/util/GitFileReader.java +++ b/jablib/src/main/java/org/jabref/logic/git/util/GitFileReader.java @@ -15,17 +15,18 @@ import org.eclipse.jgit.revwalk.RevCommit; import org.eclipse.jgit.revwalk.RevTree; import org.eclipse.jgit.treewalk.TreeWalk; +import org.jspecify.annotations.NonNull; public class GitFileReader { // Unit test is in the GitSyncServiceTest - public static String readFileFromCommit(Git git, RevCommit commit, Path relativePath) throws JabRefException { + public static String readFileFromCommit(Git git, RevCommit commit, @NonNull Path relativePath) throws JabRefException { // ref: https://github.com/centic9/jgit-cookbook/blob/master/src/main/java/org/dstadler/jgit/api/ReadFileFromCommit.java // 1. get commit-pointing tree Repository repository = git.getRepository(); RevTree commitTree = commit.getTree(); // 2. setup TreeWalk + to the target file - try (TreeWalk treeWalk = TreeWalk.forPath(repository, String.valueOf(relativePath), commitTree)) { + try (TreeWalk treeWalk = TreeWalk.forPath(repository, relativePath.toString(), commitTree)) { if (treeWalk == null) { throw new JabRefException("File '" + relativePath + "' not found in commit " + commit.getName()); } @@ -33,8 +34,7 @@ public static String readFileFromCommit(Git git, RevCommit commit, Path relative ObjectId objectId = treeWalk.getObjectId(0); ObjectLoader loader = repository.open(objectId); return new String(loader.getBytes(), StandardCharsets.UTF_8); - } catch (MissingObjectException | - IncorrectObjectTypeException e) { + } 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/util/GitFileWriter.java b/jablib/src/main/java/org/jabref/logic/git/util/GitFileWriter.java index dfb53496adc..aae93010d11 100644 --- a/jablib/src/main/java/org/jabref/logic/git/util/GitFileWriter.java +++ b/jablib/src/main/java/org/jabref/logic/git/util/GitFileWriter.java @@ -14,13 +14,13 @@ import org.jabref.model.entry.BibEntryTypesManager; public class GitFileWriter { - public static void write(Path file, BibDatabaseContext ctx, ImportFormatPreferences importPrefs) throws IOException { + public static void write(Path file, BibDatabaseContext bibDatabaseContext, ImportFormatPreferences importPrefs) throws IOException { SelfContainedSaveConfiguration saveConfiguration = new SelfContainedSaveConfiguration(); - Charset encoding = ctx.getMetaData().getEncoding().orElse(StandardCharsets.UTF_8); + Charset encoding = bibDatabaseContext.getMetaData().getEncoding().orElse(StandardCharsets.UTF_8); - synchronized (ctx) { + synchronized (bibDatabaseContext) { try (AtomicFileWriter fileWriter = new AtomicFileWriter(file, encoding, saveConfiguration.shouldMakeBackup())) { - BibWriter bibWriter = new BibWriter(fileWriter, ctx.getDatabase().getNewLineSeparator()); + BibWriter bibWriter = new BibWriter(fileWriter, bibDatabaseContext.getDatabase().getNewLineSeparator()); BibtexDatabaseWriter writer = new BibtexDatabaseWriter( bibWriter, saveConfiguration, @@ -28,7 +28,7 @@ public static void write(Path file, BibDatabaseContext ctx, ImportFormatPreferen importPrefs.citationKeyPatternPreferences(), new BibEntryTypesManager() ); - writer.saveDatabase(ctx); + writer.saveDatabase(bibDatabaseContext); if (fileWriter.hasEncodingProblems()) { throw new IOException("Encoding problem detected when saving .bib file: " diff --git a/jablib/src/main/java/org/jabref/logic/git/util/GitRevisionLocator.java b/jablib/src/main/java/org/jabref/logic/git/util/GitRevisionLocator.java index b0a6791e515..dfd5397fbb0 100644 --- a/jablib/src/main/java/org/jabref/logic/git/util/GitRevisionLocator.java +++ b/jablib/src/main/java/org/jabref/logic/git/util/GitRevisionLocator.java @@ -1,6 +1,11 @@ package org.jabref.logic.git.util; +import java.io.IOException; + +import org.jabref.logic.JabRefException; + import org.eclipse.jgit.api.Git; +import org.eclipse.jgit.api.errors.GitAPIException; import org.eclipse.jgit.lib.ObjectId; import org.eclipse.jgit.revwalk.RevCommit; import org.eclipse.jgit.revwalk.RevWalk; @@ -12,12 +17,15 @@ * remote = origin/main */ public class GitRevisionLocator { - public RevisionTriple locateMergeCommits(Git git) throws Exception { + private static final String HEAD = "HEAD"; + private static final String REMOTE = "refs/remotes/origin/main"; + + public RevisionTriple locateMergeCommits(Git git) throws GitAPIException, IOException, JabRefException { // assumes the remote branch is 'origin/main' - ObjectId headId = git.getRepository().resolve("HEAD"); + ObjectId headId = git.getRepository().resolve(HEAD); // and uses the default remote tracking reference // does not support multiple remotes or custom remote branch names so far - ObjectId remoteId = git.getRepository().resolve("refs/remotes/origin/main"); + ObjectId remoteId = git.getRepository().resolve(REMOTE); if (remoteId == null) { throw new IllegalStateException("Remote branch missing origin/main."); } diff --git a/jablib/src/main/java/org/jabref/logic/git/util/MergeResult.java b/jablib/src/main/java/org/jabref/logic/git/util/MergeResult.java index 8708b96a248..edc235e3424 100644 --- a/jablib/src/main/java/org/jabref/logic/git/util/MergeResult.java +++ b/jablib/src/main/java/org/jabref/logic/git/util/MergeResult.java @@ -4,13 +4,14 @@ import org.jabref.logic.bibtex.comparator.BibEntryDiff; -public record MergeResult(boolean successful, List conflicts) { - public static MergeResult conflictsFound(List conflicts) { - return new MergeResult(false, conflicts); +public record MergeResult(boolean isSuccessful, List conflicts) { + private static boolean SUCCESS = true; + public static MergeResult withConflicts(List conflicts) { + return new MergeResult(!SUCCESS, conflicts); } public static MergeResult success() { - return new MergeResult(true, List.of()); + return new MergeResult(SUCCESS, List.of()); } public boolean hasConflicts() { diff --git a/jablib/src/main/java/org/jabref/logic/git/util/RevisionTriple.java b/jablib/src/main/java/org/jabref/logic/git/util/RevisionTriple.java index 6062ddc650c..9dc4c11c98b 100644 --- a/jablib/src/main/java/org/jabref/logic/git/util/RevisionTriple.java +++ b/jablib/src/main/java/org/jabref/logic/git/util/RevisionTriple.java @@ -11,5 +11,4 @@ * @param local the current local branch tip * @param remote the tip of the remote tracking branch (typically origin/main) */ -public record RevisionTriple( - RevCommit base, RevCommit local, RevCommit remote) { } +public record RevisionTriple(RevCommit base, RevCommit local, RevCommit remote) { } diff --git a/jablib/src/main/java/org/jabref/logic/git/util/SemanticConflictDetector.java b/jablib/src/main/java/org/jabref/logic/git/util/SemanticConflictDetector.java index b04122f94c2..1f7b232ff03 100644 --- a/jablib/src/main/java/org/jabref/logic/git/util/SemanticConflictDetector.java +++ b/jablib/src/main/java/org/jabref/logic/git/util/SemanticConflictDetector.java @@ -1,7 +1,6 @@ package org.jabref.logic.git.util; import java.util.ArrayList; -import java.util.Collections; import java.util.HashSet; import java.util.LinkedHashMap; import java.util.LinkedHashSet; @@ -28,7 +27,7 @@ public static List detectConflicts(BibDatabaseContext base, BibDat // 1. get diffs between base and remote List remoteDiffs = BibDatabaseDiff.compare(base, remote).getEntryDifferences(); if (remoteDiffs == null) { - return Collections.emptyList(); + return List.of(); } // 2. map citation key to entry for local/remote diffs Map baseEntries = toEntryMap(base); @@ -60,11 +59,11 @@ public static List detectConflicts(BibDatabaseContext base, BibDat public static Map toEntryMap(BibDatabaseContext context) { return context.getDatabase().getEntries().stream() - .filter(e -> e.getCitationKey().isPresent()) + .filter(entry -> entry.getCitationKey().isPresent()) .collect(Collectors.toMap( - e -> e.getCitationKey().get(), + entry -> entry.getCitationKey().get(), Function.identity(), - (a, b) -> b, + (existing, replacement) -> replacement, LinkedHashMap::new )); } @@ -88,10 +87,18 @@ private static boolean hasConflictingFields(BibEntry base, BibEntry local, BibEn return true; } } - return false; } + /** + * Compares base and remote, finds all semantic-level changes (new entries, updated fields), and builds a patch plan. + * This plan is meant to be applied to local during merge: + * result = local + (remote − base) + * + * @param base The base version of the database. + * @param remote The remote version to be merged. + * @return A {@link MergePlan} describing how to update the local copy with remote changes. + */ public static MergePlan extractMergePlan(BibDatabaseContext base, BibDatabaseContext remote) { Map baseMap = toEntryMap(base); Map remoteMap = toEntryMap(remote); diff --git a/jablib/src/main/java/org/jabref/logic/importer/fetcher/MergingIdBasedFetcher.java b/jablib/src/main/java/org/jabref/logic/importer/fetcher/MergingIdBasedFetcher.java index 26af88a915f..d94c95fdf64 100644 --- a/jablib/src/main/java/org/jabref/logic/importer/fetcher/MergingIdBasedFetcher.java +++ b/jablib/src/main/java/org/jabref/logic/importer/fetcher/MergingIdBasedFetcher.java @@ -19,7 +19,7 @@ /// Fetches and merges bibliographic information from external sources into existing BibEntry objects. /// Supports multiple identifier types (DOI, ISBN, Eprint) and attempts fetching in a defined order -/// until successful. +/// until isSuccessful. /// The merging only adds new fields from the fetched entry and does not modify existing fields /// in the library entry. public class MergingIdBasedFetcher { diff --git a/jablib/src/main/java/org/jabref/logic/importer/fileformat/pdf/PdfContentImporter.java b/jablib/src/main/java/org/jabref/logic/importer/fileformat/pdf/PdfContentImporter.java index d848c3725d6..50b7aa77512 100644 --- a/jablib/src/main/java/org/jabref/logic/importer/fileformat/pdf/PdfContentImporter.java +++ b/jablib/src/main/java/org/jabref/logic/importer/fileformat/pdf/PdfContentImporter.java @@ -318,7 +318,7 @@ private boolean isThereSpace(TextPosition previous, TextPosition current) { * @param titleByFontSize An optional title string determined by font size; if provided, this overrides the * default title parsing. * @return An {@link Optional} containing a {@link BibEntry} with the parsed bibliographic data if extraction - * is successful. Otherwise, an empty {@link Optional}. + * is isSuccessful. Otherwise, an empty {@link Optional}. */ @VisibleForTesting Optional getEntryFromPDFContent(String firstpageContents, String lineSeparator, Optional titleByFontSize) { diff --git a/jablib/src/main/java/org/jabref/logic/importer/plaincitation/GrobidPlainCitationParser.java b/jablib/src/main/java/org/jabref/logic/importer/plaincitation/GrobidPlainCitationParser.java index 34f1db1ec26..ae126b32bbd 100644 --- a/jablib/src/main/java/org/jabref/logic/importer/plaincitation/GrobidPlainCitationParser.java +++ b/jablib/src/main/java/org/jabref/logic/importer/plaincitation/GrobidPlainCitationParser.java @@ -35,7 +35,7 @@ public GrobidPlainCitationParser(GrobidPreferences grobidPreferences, ImportForm * Passes request to grobid server, using consolidateCitations option to improve result. Takes a while, since the * server has to look up the entry. * - * @return A BibTeX string if extraction is successful + * @return A BibTeX string if extraction is isSuccessful */ @Override public Optional parsePlainCitation(String text) throws FetcherException { diff --git a/jablib/src/main/java/org/jabref/logic/remote/client/RemoteClient.java b/jablib/src/main/java/org/jabref/logic/remote/client/RemoteClient.java index 38d1047d224..c0211451413 100644 --- a/jablib/src/main/java/org/jabref/logic/remote/client/RemoteClient.java +++ b/jablib/src/main/java/org/jabref/logic/remote/client/RemoteClient.java @@ -50,7 +50,7 @@ public boolean ping() { * Attempt to send command line arguments to already running JabRef instance. * * @param args command line arguments. - * @return true if successful, false otherwise. + * @return true if isSuccessful, false otherwise. */ public boolean sendCommandLineArguments(String[] args) { try (Protocol protocol = openNewConnection()) { @@ -66,7 +66,7 @@ public boolean sendCommandLineArguments(String[] args) { /** * Attempt to send a focus command to already running JabRef instance. * - * @return true if successful, false otherwise. + * @return true if isSuccessful, false otherwise. */ public boolean sendFocus() { try (Protocol protocol = openNewConnection()) { diff --git a/jablib/src/main/java/org/jabref/logic/util/BackgroundTask.java b/jablib/src/main/java/org/jabref/logic/util/BackgroundTask.java index 0a576c74659..e20dd3b9775 100644 --- a/jablib/src/main/java/org/jabref/logic/util/BackgroundTask.java +++ b/jablib/src/main/java/org/jabref/logic/util/BackgroundTask.java @@ -215,7 +215,7 @@ public Future scheduleWith(TaskExecutor taskExecutor, long delay, TimeUnit un } /** - * Sets the {@link Runnable} that is invoked after the task is finished, irrespectively if it was successful or + * Sets the {@link Runnable} that is invoked after the task is finished, irrespectively if it was isSuccessful or * failed with an error. */ public BackgroundTask onFinished(Runnable onFinished) { diff --git a/jablib/src/main/java/org/jabref/logic/util/ExternalLinkCreator.java b/jablib/src/main/java/org/jabref/logic/util/ExternalLinkCreator.java index ed9a6a0362b..f5562d5f89a 100644 --- a/jablib/src/main/java/org/jabref/logic/util/ExternalLinkCreator.java +++ b/jablib/src/main/java/org/jabref/logic/util/ExternalLinkCreator.java @@ -14,7 +14,7 @@ public class ExternalLinkCreator { /** * Get a URL to the search results of ShortScience for the BibEntry's title * - * @param entry The entry to search for. Expects the BibEntry's title to be set for successful return. + * @param entry The entry to search for. Expects the BibEntry's title to be set for isSuccessful return. * @return The URL if it was successfully created */ public static Optional getShortScienceSearchURL(BibEntry entry) { 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..850231bb67f 100644 --- a/jablib/src/test/java/org/jabref/logic/git/GitHandlerTest.java +++ b/jablib/src/test/java/org/jabref/logic/git/GitHandlerTest.java @@ -1,6 +1,7 @@ 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; @@ -10,6 +11,7 @@ import org.eclipse.jgit.lib.AnyObjectId; import org.eclipse.jgit.lib.Constants; import org.eclipse.jgit.revwalk.RevCommit; +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; @@ -55,4 +57,45 @@ void createCommitOnCurrentBranch() throws IOException, GitAPIException { void getCurrentlyCheckedOutBranch() throws IOException { assertEquals("main", gitHandler.getCurrentlyCheckedOutBranch()); } + + @Test + void fetchOnCurrentBranch() throws IOException, GitAPIException, URISyntaxException { + Path remoteRepoPath = Files.createTempDirectory("remote-repo"); + try (Git remoteGit = Git.init() + .setDirectory(remoteRepoPath.toFile()) + .setBare(true) + .call()) { + try (Git localGit = Git.open(repositoryPath.toFile())) { + localGit.remoteAdd() + .setName("origin") + .setUri(new URIish(remoteRepoPath.toUri().toString())) + .call(); + } + + Path testFile = repositoryPath.resolve("test.txt"); + Files.writeString(testFile, "hello"); + gitHandler.createCommitOnCurrentBranch("First commit", false); + try (Git localGit = Git.open(repositoryPath.toFile())) { + localGit.push().setRemote("origin").call(); + } + + Path clonePath = Files.createTempDirectory("clone-of-remote"); + 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())) { + assertEquals(true, git.getRepository().getRefDatabase().hasRefs()); + assertEquals(true, git.getRepository().exactRef("refs/remotes/origin/main") != null); + } + } + } } 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 f93ca1944dd..885f81e5d3c 100644 --- a/jablib/src/test/java/org/jabref/logic/git/GitSyncServiceTest.java +++ b/jablib/src/test/java/org/jabref/logic/git/GitSyncServiceTest.java @@ -130,9 +130,9 @@ void aliceBobSimple(@TempDir Path tempDir) throws Exception { void pullTriggersSemanticMergeWhenNoConflicts() throws Exception { GitHandler gitHandler = mock(GitHandler.class); GitSyncService syncService = new GitSyncService(importFormatPreferences, gitHandler); - MergeResult result = syncService.pullAndMerge(library); + MergeResult result = syncService.fetchAndMerge(library); - assertTrue(result.successful()); + assertTrue(result.isSuccessful()); String merged = Files.readString(library); String expected = """ diff --git a/jablib/src/test/java/org/jabref/logic/git/util/GitBibParserTest.java b/jablib/src/test/java/org/jabref/logic/git/util/GitBibParserTest.java index 68c5b276d5d..e7346b9a405 100644 --- a/jablib/src/test/java/org/jabref/logic/git/util/GitBibParserTest.java +++ b/jablib/src/test/java/org/jabref/logic/git/util/GitBibParserTest.java @@ -62,7 +62,7 @@ void parsesBibContentFromCommit() throws Exception { List entries = context.getEntries(); assertEquals(1, entries.size()); - BibEntry entry = entries.get(0); + BibEntry entry = entries.getFirst(); assertEquals(Optional.of("Alice"), entry.getField(FieldFactory.parseField("author"))); assertEquals(Optional.of("Test Title"), entry.getField(FieldFactory.parseField("title"))); assertEquals(Optional.of("2025"), entry.getField(FieldFactory.parseField("year"))); 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 index acf05e01a0c..599dc2fd9af 100644 --- a/jablib/src/test/java/org/jabref/logic/git/util/GitFileWriterTest.java +++ b/jablib/src/test/java/org/jabref/logic/git/util/GitFileWriterTest.java @@ -27,7 +27,7 @@ void setUp() { @Test void testWriteThenReadBack() throws Exception { - BibDatabaseContext inputCtx = GitBibParser.parseBibFromGit( + BibDatabaseContext inputDatabaseContext = GitBibParser.parseBibFromGit( """ @article{a, author = {Alice}, @@ -37,15 +37,15 @@ void testWriteThenReadBack() throws Exception { Path tempFile = Files.createTempFile("tempgitwriter", ".bib"); - GitFileWriter.write(tempFile, inputCtx, importFormatPreferences); + GitFileWriter.write(tempFile, inputDatabaseContext, importFormatPreferences); BibDatabaseContext outputCtx = GitBibParser.parseBibFromGit(Files.readString(tempFile), importFormatPreferences); - List inputEntries = inputCtx.getDatabase().getEntries(); + List inputEntries = inputDatabaseContext.getDatabase().getEntries(); List outputEntries = outputCtx.getDatabase().getEntries(); assertEquals(inputEntries.size(), outputEntries.size()); - assertEquals(inputEntries.get(0).getCitationKey(), outputEntries.get(0).getCitationKey()); - assertEquals(inputEntries.get(0).getField(StandardField.AUTHOR), outputEntries.get(0).getField(StandardField.AUTHOR)); + assertEquals(inputEntries.getFirst().getCitationKey(), outputEntries.getFirst().getCitationKey()); + assertEquals(inputEntries.getFirst().getField(StandardField.AUTHOR), outputEntries.getFirst().getField(StandardField.AUTHOR)); } } diff --git a/jablib/src/test/java/org/jabref/logic/git/util/SemanticConflictDetectorTest.java b/jablib/src/test/java/org/jabref/logic/git/util/SemanticConflictDetectorTest.java index 7d512230050..1bed281f58a 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 @@ -57,11 +57,11 @@ void testSemanticConflicts(String description, String base, String local, String RevCommit localCommit = writeAndCommit(local, "local", alice); RevCommit remoteCommit = writeAndCommit(remote, "remote", bob); - BibDatabaseContext baseCtx = parse(baseCommit); - BibDatabaseContext localCtx = parse(localCommit); - BibDatabaseContext remoteCtx = parse(remoteCommit); + BibDatabaseContext baseDatabaseContext = parse(baseCommit); + BibDatabaseContext localDatabaseContext = parse(localCommit); + BibDatabaseContext remoteDatabaseContext = parse(remoteCommit); - List diffs = SemanticConflictDetector.detectConflicts(baseCtx, localCtx, remoteCtx); + List diffs = SemanticConflictDetector.detectConflicts(baseDatabaseContext, localDatabaseContext, remoteDatabaseContext); if (expectConflict) { assertEquals(1, diffs.size(), "Expected a conflict but found none"); @@ -441,10 +441,10 @@ void testExtractMergePlan_T10_onlyRemoteChangedEntryB() throws Exception { RevCommit baseCommit = writeAndCommit(base, "base", alice); RevCommit remoteCommit = writeAndCommit(remote, "remote", bob); - BibDatabaseContext baseCtx = parse(baseCommit); - BibDatabaseContext remoteCtx = parse(remoteCommit); + BibDatabaseContext baseDatabaseContext = parse(baseCommit); + BibDatabaseContext remoteDatabaseContext = parse(remoteCommit); - MergePlan plan = SemanticConflictDetector.extractMergePlan(baseCtx, remoteCtx); + MergePlan plan = SemanticConflictDetector.extractMergePlan(baseDatabaseContext, remoteDatabaseContext); assertEquals(1, plan.fieldPatches().size()); assertTrue(plan.fieldPatches().containsKey("b")); @@ -471,10 +471,10 @@ void testExtractMergePlan_T11_remoteAddsField() throws Exception { RevCommit baseCommit = writeAndCommit(base, "base", alice); RevCommit remoteCommit = writeAndCommit(remote, "remote", bob); - BibDatabaseContext baseCtx = parse(baseCommit); - BibDatabaseContext remoteCtx = parse(remoteCommit); + BibDatabaseContext baseDatabaseContext = parse(baseCommit); + BibDatabaseContext remoteDatabaseContext = parse(remoteCommit); - MergePlan plan = SemanticConflictDetector.extractMergePlan(baseCtx, remoteCtx); + MergePlan plan = SemanticConflictDetector.extractMergePlan(baseDatabaseContext, remoteDatabaseContext); assertEquals(1, plan.fieldPatches().size()); Map patch = plan.fieldPatches().get("a"); 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 index b15d70f1048..2e6f4b88b96 100644 --- a/jablib/src/test/java/org/jabref/logic/git/util/SemanticMergerTest.java +++ b/jablib/src/test/java/org/jabref/logic/git/util/SemanticMergerTest.java @@ -29,14 +29,14 @@ void setup() { @ParameterizedTest(name = "Database patch: {0}") @MethodSource("provideDatabasePatchCases") void testPatchDatabase(String description, String base, String local, String remote, String expectedAuthor) throws Exception { - BibDatabaseContext baseCtx = GitBibParser.parseBibFromGit(base, importFormatPreferences); - BibDatabaseContext localCtx = GitBibParser.parseBibFromGit(local, importFormatPreferences); - BibDatabaseContext remoteCtx = GitBibParser.parseBibFromGit(remote, importFormatPreferences); + BibDatabaseContext baseDatabaseContext = GitBibParser.parseBibFromGit(base, importFormatPreferences); + BibDatabaseContext localDatabaseContext = GitBibParser.parseBibFromGit(local, importFormatPreferences); + BibDatabaseContext remoteDatabaseContext = GitBibParser.parseBibFromGit(remote, importFormatPreferences); - MergePlan plan = SemanticConflictDetector.extractMergePlan(baseCtx, remoteCtx); - SemanticMerger.applyMergePlan(localCtx, plan); + MergePlan plan = SemanticConflictDetector.extractMergePlan(baseDatabaseContext, remoteDatabaseContext); + SemanticMerger.applyMergePlan(localDatabaseContext, plan); - BibEntry patched = localCtx.getDatabase().getEntryByCitationKey("a").orElseThrow(); + BibEntry patched = localDatabaseContext.getDatabase().getEntryByCitationKey("a").orElseThrow(); assertEquals(expectedAuthor, patched.getField(StandardField.AUTHOR).orElse(null)); } From 269473dbf64a96d93cb7c002039232a0ca022fa0 Mon Sep 17 00:00:00 2001 From: Wanling Fu Date: Mon, 7 Jul 2025 16:19:11 +0100 Subject: [PATCH 05/37] chore(git): Fix CI-related issues #12350 --- docs/code-howtos/git.md | 22 ++-- .../jabref/gui/git/GitConflictResolver.java | 10 ++ .../gui/git/GitConflictResolverViaDialog.java | 46 +++++++ .../org/jabref/gui/git/GitPullAction.java | 77 ++++++++++++ .../org/jabref/gui/git/GitPullViewModel.java | 113 ++++++++++++++++++ jablib/src/main/java/module-info.java | 5 +- .../java/org/jabref/logic/git/GitHandler.java | 18 +++ .../org/jabref/logic/git/GitSyncService.java | 55 +++++---- .../SemanticConflictDetector.java | 24 +++- .../git/conflicts/ThreeWayEntryConflict.java | 9 ++ .../logic/git/{util => io}/GitBibParser.java | 7 +- .../logic/git/{util => io}/GitFileReader.java | 2 +- .../logic/git/{util => io}/GitFileWriter.java | 2 +- .../git/{util => io}/GitRevisionLocator.java | 2 +- .../git/{util => io}/RevisionTriple.java | 3 +- .../jabref/logic/git/merge/GitMergeUtil.java | 52 ++++++++ .../git/merge/GitSemanticMergeExecutor.java | 22 ++++ .../merge/GitSemanticMergeExecutorImpl.java | 33 +++++ .../logic/git/{util => merge}/MergePlan.java | 4 +- .../git/{util => merge}/SemanticMerger.java | 6 +- .../git/{util => model}/MergeResult.java | 2 +- .../org/jabref/logic/git/GitHandlerTest.java | 19 ++- .../jabref/logic/git/GitSyncServiceTest.java | 9 +- .../merge/GitSemanticMergeExecutorTest.java | 58 +++++++++ .../logic/git/util/GitBibParserTest.java | 2 + .../logic/git/util/GitFileWriterTest.java | 4 +- .../git/util/GitRevisionLocatorTest.java | 5 +- .../util/SemanticConflictDetectorTest.java | 46 ++++++- .../logic/git/util/SemanticMergerTest.java | 6 +- 29 files changed, 591 insertions(+), 72 deletions(-) create mode 100644 jabgui/src/main/java/org/jabref/gui/git/GitConflictResolver.java create mode 100644 jabgui/src/main/java/org/jabref/gui/git/GitConflictResolverViaDialog.java create mode 100644 jabgui/src/main/java/org/jabref/gui/git/GitPullAction.java create mode 100644 jabgui/src/main/java/org/jabref/gui/git/GitPullViewModel.java rename jablib/src/main/java/org/jabref/logic/git/{util => conflicts}/SemanticConflictDetector.java (80%) create mode 100644 jablib/src/main/java/org/jabref/logic/git/conflicts/ThreeWayEntryConflict.java rename jablib/src/main/java/org/jabref/logic/git/{util => io}/GitBibParser.java (84%) rename jablib/src/main/java/org/jabref/logic/git/{util => io}/GitFileReader.java (98%) rename jablib/src/main/java/org/jabref/logic/git/{util => io}/GitFileWriter.java (98%) rename jablib/src/main/java/org/jabref/logic/git/{util => io}/GitRevisionLocator.java (97%) rename jablib/src/main/java/org/jabref/logic/git/{util => io}/RevisionTriple.java (81%) create mode 100644 jablib/src/main/java/org/jabref/logic/git/merge/GitMergeUtil.java create mode 100644 jablib/src/main/java/org/jabref/logic/git/merge/GitSemanticMergeExecutor.java create mode 100644 jablib/src/main/java/org/jabref/logic/git/merge/GitSemanticMergeExecutorImpl.java rename jablib/src/main/java/org/jabref/logic/git/{util => merge}/MergePlan.java (73%) rename jablib/src/main/java/org/jabref/logic/git/{util => merge}/SemanticMerger.java (90%) rename jablib/src/main/java/org/jabref/logic/git/{util => model}/MergeResult.java (93%) create mode 100644 jablib/src/test/java/org/jabref/logic/git/merge/GitSemanticMergeExecutorTest.java diff --git a/docs/code-howtos/git.md b/docs/code-howtos/git.md index d8d50a08d63..9589e333fc8 100644 --- a/docs/code-howtos/git.md +++ b/docs/code-howtos/git.md @@ -5,42 +5,34 @@ → No conflict. The local version remained unchanged, so the remote change can be safely applied. - - **T2.** Local changed a field, remote did not → No conflict. The remote version did not touch the field, so the local change is preserved. - - **T3.** Both local and remote changed the same field to the same value → No conflict. Although both sides changed the field, the result is identical—therefore, no conflict. - - **T4.** Both local and remote changed the same field to different values → Conflict. This is a true semantic conflict that requires resolution. - - **T5.** Local deleted a field, remote modified the same field → Conflict. One side deleted the field while the other updated it—this is contradictory. - - **T6.** Local modified a field, remote deleted it → Conflict. Similar to T5, one side deletes, the other edits—this is a conflict. - - **T7.** Local unchanged, remote deleted a field → No conflict. Local did not modify anything, so remote deletion is accepted. - - **T8.** Local changed field A, remote changed field B (within the same entry) → No conflict. Changes are on separate fields, so they can be merged safely. - - **T9.** Both changed the same entry, but only field order changed → No conflict. Field order is not semantically meaningful, so no conflict is detected. @@ -49,27 +41,31 @@ → No conflict. Modifications are on different entries, which are always safe to merge. - - **T11.** Remote added a new field, local did nothing → No conflict. Remote addition can be applied without issues. - - **T12.** Remote added a field, local also added the same field, but with different value → Conflict. One side added while the other side modified—there is a semantic conflict. - - **T13.** Local added a field, remote did nothing → No conflict. Safe to preserve the local addition. - - **T14.** Both added the same field with the same value → No conflict. Even though both sides added it, the value is the same—no need for resolution. - - **T15.** Both added the same field with different values → Conflict. The same field is introduced with different values, which creates a conflict. + +- **T16.** Both added the same entry key with different values + → Conflict. + Both sides created a new entry with the same citation key, but the fields differ. + +- **T17.** Both added the same entry key with identical values + → No conflict. + Both sides created a new entry with the same citation key and identical fields, so it can be merged safely. + diff --git a/jabgui/src/main/java/org/jabref/gui/git/GitConflictResolver.java b/jabgui/src/main/java/org/jabref/gui/git/GitConflictResolver.java new file mode 100644 index 00000000000..c62ae0187ad --- /dev/null +++ b/jabgui/src/main/java/org/jabref/gui/git/GitConflictResolver.java @@ -0,0 +1,10 @@ +package org.jabref.gui.git; + +import java.util.Optional; + +import org.jabref.logic.git.conflicts.ThreeWayEntryConflict; +import org.jabref.model.entry.BibEntry; + +public interface GitConflictResolver { + Optional resolveConflict(ThreeWayEntryConflict conflict); +} diff --git a/jabgui/src/main/java/org/jabref/gui/git/GitConflictResolverViaDialog.java b/jabgui/src/main/java/org/jabref/gui/git/GitConflictResolverViaDialog.java new file mode 100644 index 00000000000..bc2a315c357 --- /dev/null +++ b/jabgui/src/main/java/org/jabref/gui/git/GitConflictResolverViaDialog.java @@ -0,0 +1,46 @@ +package org.jabref.gui.git; + +import java.util.Optional; + +import org.jabref.gui.DialogService; +import org.jabref.gui.mergeentries.MergeEntriesDialog; +import org.jabref.gui.mergeentries.newmergedialog.ShowDiffConfig; +import org.jabref.gui.mergeentries.newmergedialog.diffhighlighter.DiffHighlighter; +import org.jabref.gui.mergeentries.newmergedialog.toolbar.ThreeWayMergeToolbar; +import org.jabref.gui.preferences.GuiPreferences; +import org.jabref.logic.git.conflicts.ThreeWayEntryConflict; +import org.jabref.model.entry.BibEntry; + +/** + * UI wrapper + * Receives a semantic conflict (ThreeWayEntryConflict), pops up an interactive GUI (belonging to mergeentries), and returns a user-confirmed BibEntry merge result. + */ +public class GitConflictResolverViaDialog implements GitConflictResolver { + private final DialogService dialogService; + private final GuiPreferences preferences; + + public GitConflictResolverViaDialog(DialogService dialogService, GuiPreferences preferences) { + this.dialogService = dialogService; + this.preferences = preferences; + } + + @Override + public Optional resolveConflict(ThreeWayEntryConflict conflict) { + BibEntry base = conflict.base(); + BibEntry local = conflict.local(); + BibEntry remote = conflict.remote(); + + // Create Dialog + Set Title + Configure Diff Highlighting + MergeEntriesDialog dialog = new MergeEntriesDialog(local, remote, preferences); + dialog.setLeftHeaderText("Local"); + dialog.setRightHeaderText("Remote"); + ShowDiffConfig diffConfig = new ShowDiffConfig( + ThreeWayMergeToolbar.DiffView.SPLIT, + DiffHighlighter.BasicDiffMethod.WORDS + ); + dialog.configureDiff(diffConfig); + + return dialogService.showCustomDialogAndWait(dialog) + .map(result -> result.mergedEntry()); + } +} diff --git a/jabgui/src/main/java/org/jabref/gui/git/GitPullAction.java b/jabgui/src/main/java/org/jabref/gui/git/GitPullAction.java new file mode 100644 index 00000000000..5701734a234 --- /dev/null +++ b/jabgui/src/main/java/org/jabref/gui/git/GitPullAction.java @@ -0,0 +1,77 @@ +package org.jabref.gui.git; + +import java.io.IOException; +import java.nio.file.Path; + +import javax.swing.undo.UndoManager; + +import org.jabref.gui.DialogService; +import org.jabref.gui.StateManager; +import org.jabref.gui.actions.SimpleCommand; +import org.jabref.gui.preferences.GuiPreferences; +import org.jabref.logic.JabRefException; +import org.jabref.logic.git.model.MergeResult; +import org.jabref.model.database.BibDatabaseContext; + +import org.eclipse.jgit.api.errors.GitAPIException; + +/** + * - Check if Git is enabled + * - Verify activeDatabase is not null + * - Call GitPullViewModel.pull() + */ +public class GitPullAction extends SimpleCommand { + + private final DialogService dialogService; + private final StateManager stateManager; + private final GuiPreferences guiPreferences; + private final UndoManager undoManager; + + public GitPullAction(DialogService dialogService, + StateManager stateManager, + GuiPreferences guiPreferences, + UndoManager undoManager) { + this.dialogService = dialogService; + this.stateManager = stateManager; + this.guiPreferences = guiPreferences; + this.undoManager = undoManager; + } + + @Override + public void execute() { + // TODO: reconsider error handling + if (stateManager.getActiveDatabase().isEmpty()) { + dialogService.showErrorDialogAndWait("No database open", "Please open a database before pulling."); + return; + } + + BibDatabaseContext database = stateManager.getActiveDatabase().get(); + if (database.getDatabasePath().isEmpty()) { + dialogService.showErrorDialogAndWait("No .bib file path", "Cannot pull from Git: No file is associated with this database."); + return; + } + + Path bibFilePath = database.getDatabasePath().get(); + try { + GitPullViewModel viewModel = new GitPullViewModel( + guiPreferences.getImportFormatPreferences(), + new GitConflictResolverViaDialog(dialogService, guiPreferences), + dialogService + ); + MergeResult result = viewModel.pull(bibFilePath); + + if (result.isSuccessful()) { + dialogService.showInformationDialogAndWait("Git Pull", "Successfully merged and updated."); + } else { + dialogService.showWarningDialogAndWait("Git Pull", "Merge completed with conflicts."); + } + } catch (JabRefException e) { + dialogService.showErrorDialogAndWait("Git Pull Failed", e); + // TODO: error handling + } catch (GitAPIException e) { + throw new RuntimeException(e); + } catch (IOException e) { + throw new RuntimeException(e); + } + } +} diff --git a/jabgui/src/main/java/org/jabref/gui/git/GitPullViewModel.java b/jabgui/src/main/java/org/jabref/gui/git/GitPullViewModel.java new file mode 100644 index 00000000000..c580f668821 --- /dev/null +++ b/jabgui/src/main/java/org/jabref/gui/git/GitPullViewModel.java @@ -0,0 +1,113 @@ +package org.jabref.gui.git; + +import java.io.IOException; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +import org.jabref.gui.AbstractViewModel; +import org.jabref.gui.DialogService; +import org.jabref.logic.JabRefException; +import org.jabref.logic.git.GitHandler; +import org.jabref.logic.git.conflicts.SemanticConflictDetector; +import org.jabref.logic.git.conflicts.ThreeWayEntryConflict; +import org.jabref.logic.git.io.GitBibParser; +import org.jabref.logic.git.io.GitFileReader; +import org.jabref.logic.git.io.GitFileWriter; +import org.jabref.logic.git.io.GitRevisionLocator; +import org.jabref.logic.git.io.RevisionTriple; +import org.jabref.logic.git.merge.GitMergeUtil; +import org.jabref.logic.git.merge.MergePlan; +import org.jabref.logic.git.merge.SemanticMerger; +import org.jabref.logic.git.model.MergeResult; +import org.jabref.logic.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; + +public class GitPullViewModel extends AbstractViewModel { + private final ImportFormatPreferences importFormatPreferences; + private final GitConflictResolver conflictResolver; + private final DialogService dialogService; + + public GitPullViewModel(ImportFormatPreferences importFormatPreferences, + GitConflictResolver conflictResolver, + DialogService dialogService) { + this.importFormatPreferences = importFormatPreferences; + this.conflictResolver = conflictResolver; + this.dialogService = dialogService; + } + + public MergeResult pull(Path bibFilePath) throws IOException, GitAPIException, JabRefException { + // Open the Git repository from the parent folder of the .bib file + Git git = Git.open(bibFilePath.getParent().toFile()); + + // Fetch latest changes from remote + // TODO: Temporary — GitHandler should be injected from GitStatusViewModel once centralized git status is implemented. + GitHandler gitHandler = GitHandler.fromAnyPath(bibFilePath) + .orElseThrow(() -> new IllegalStateException("Not inside a Git repository")); + + gitHandler.fetchOnCurrentBranch(); + + // Determine the three-way merge base, local, and remote commits + GitRevisionLocator locator = new GitRevisionLocator(); + RevisionTriple triple = locator.locateMergeCommits(git); + + RevCommit baseCommit = triple.base(); + RevCommit localCommit = triple.local(); + RevCommit remoteCommit = triple.remote(); + + // Ensure file is inside the Git working tree + Path bibPath = bibFilePath.toRealPath(); + Path workTree = git.getRepository().getWorkTree().toPath().toRealPath(); + if (!bibPath.startsWith(workTree)) { + throw new IllegalStateException("Given .bib file is not inside repository"); + } + Path relativePath = workTree.relativize(bibPath); + + // 1. Load three versions + String baseContent = GitFileReader.readFileFromCommit(git, baseCommit, relativePath); + String localContent = GitFileReader.readFileFromCommit(git, localCommit, relativePath); + String remoteContent = GitFileReader.readFileFromCommit(git, remoteCommit, relativePath); + + BibDatabaseContext base = GitBibParser.parseBibFromGit(baseContent, importFormatPreferences); + BibDatabaseContext local = GitBibParser.parseBibFromGit(localContent, importFormatPreferences); + BibDatabaseContext remote = GitBibParser.parseBibFromGit(remoteContent, importFormatPreferences); + + // 2. Conflict detection + List conflicts = SemanticConflictDetector.detectConflicts(base, local, remote); + + // 3. If there are conflicts, prompt user to resolve them via GUI + BibDatabaseContext effectiveRemote = remote; + if (!conflicts.isEmpty()) { + List resolvedRemoteEntries = new ArrayList<>(); + for (ThreeWayEntryConflict conflict : conflicts) { + // Ask user to resolve this conflict via GUI dialog + Optional maybeResolved = conflictResolver.resolveConflict(conflict); + if (maybeResolved.isPresent()) { + resolvedRemoteEntries.add(maybeResolved.get()); + } else { + // User canceled the merge dialog → abort the whole merge + throw new JabRefException("Merge aborted: Not all conflicts were resolved by user."); + } + } + // Replace original conflicting entries in remote with resolved versions + effectiveRemote = GitMergeUtil.replaceEntries(remote, resolvedRemoteEntries); + } + + // Extract merge plan and apply it to the local database + MergePlan plan = SemanticConflictDetector.extractMergePlan(base, effectiveRemote); + SemanticMerger.applyMergePlan(local, plan); + + // Save merged result to .bib file + GitFileWriter.write(bibFilePath, local, importFormatPreferences); + + // Create Git commit for the merged result + gitHandler.createCommitOnCurrentBranch("Auto-merged by JabRef", true); + return MergeResult.success(); + } +} diff --git a/jablib/src/main/java/module-info.java b/jablib/src/main/java/module-info.java index 8ff80c20e92..a777c9bbad1 100644 --- a/jablib/src/main/java/module-info.java +++ b/jablib/src/main/java/module-info.java @@ -106,7 +106,10 @@ exports org.jabref.logic.git; exports org.jabref.logic.pseudonymization; exports org.jabref.logic.citation.repository; - exports org.jabref.logic.git.util; + exports org.jabref.logic.git.conflicts; + exports org.jabref.logic.git.merge; + exports org.jabref.logic.git.io; + exports org.jabref.logic.git.model; 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 55c49f07fe9..4a8771f6e60 100644 --- a/jablib/src/main/java/org/jabref/logic/git/GitHandler.java +++ b/jablib/src/main/java/org/jabref/logic/git/GitHandler.java @@ -212,4 +212,22 @@ public void fetchOnCurrentBranch() throws IOException { LOGGER.info("Failed to fetch from remote", e); } } + + /** + * Try to locate the Git repository root by walking up the directory tree starting from the given path. + * If a directory containing a `.git` folder is found, a new GitHandler is created and returned. + * + * @param anyPathInsideRepo Any file or directory path that is assumed to be inside a Git repository + * @return Optional containing a GitHandler initialized with the repository root, or empty if not found + */ + public static Optional fromAnyPath(Path anyPathInsideRepo) { + Path current = anyPathInsideRepo.toAbsolutePath(); + while (current != null) { + if (Files.exists(current.resolve(".git"))) { + return Optional.of(new GitHandler(current)); + } + current = current.getParent(); + } + return Optional.empty(); + } } 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 579ac7b0918..b427731a6a7 100644 --- a/jablib/src/main/java/org/jabref/logic/git/GitSyncService.java +++ b/jablib/src/main/java/org/jabref/logic/git/GitSyncService.java @@ -2,21 +2,23 @@ import java.io.IOException; import java.nio.file.Path; +import java.util.ArrayList; import java.util.List; import org.jabref.logic.JabRefException; -import org.jabref.logic.bibtex.comparator.BibEntryDiff; -import org.jabref.logic.git.util.GitBibParser; -import org.jabref.logic.git.util.GitFileReader; -import org.jabref.logic.git.util.GitFileWriter; -import org.jabref.logic.git.util.GitRevisionLocator; -import org.jabref.logic.git.util.MergePlan; -import org.jabref.logic.git.util.MergeResult; -import org.jabref.logic.git.util.RevisionTriple; -import org.jabref.logic.git.util.SemanticConflictDetector; -import org.jabref.logic.git.util.SemanticMerger; +import org.jabref.logic.git.conflicts.SemanticConflictDetector; +import org.jabref.logic.git.conflicts.ThreeWayEntryConflict; +import org.jabref.logic.git.io.GitBibParser; +import org.jabref.logic.git.io.GitFileReader; +import org.jabref.logic.git.io.GitFileWriter; +import org.jabref.logic.git.io.GitRevisionLocator; +import org.jabref.logic.git.io.RevisionTriple; +import org.jabref.logic.git.merge.MergePlan; +import org.jabref.logic.git.merge.SemanticMerger; +import org.jabref.logic.git.model.MergeResult; import org.jabref.logic.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; @@ -36,7 +38,7 @@ public class GitSyncService { private static final boolean AMEND = true; private final ImportFormatPreferences importFormatPreferences; - private GitHandler gitHandler; + private final GitHandler gitHandler; public GitSyncService(ImportFormatPreferences importFormatPreferences, GitHandler gitHandler) { this.importFormatPreferences = importFormatPreferences; @@ -77,6 +79,8 @@ public MergeResult performSemanticMerge(Git git, Path workTree = git.getRepository().getWorkTree().toPath().toRealPath(); Path relativePath; + // TODO: Validate that the .bib file is inside the Git repository earlier in the workflow. + // This check might be better placed before calling performSemanticMerge. if (!bibPath.startsWith(workTree)) { throw new IllegalStateException("Given .bib file is not inside repository"); } @@ -92,32 +96,35 @@ public MergeResult performSemanticMerge(Git git, BibDatabaseContext remote = GitBibParser.parseBibFromGit(remoteContent, importFormatPreferences); // 2. Conflict detection - List conflicts = SemanticConflictDetector.detectConflicts(base, local, remote); + List conflicts = SemanticConflictDetector.detectConflicts(base, local, remote); + // 3. If there are conflicts, prompt user to resolve them via GUI + BibDatabaseContext effectiveRemote = remote; if (!conflicts.isEmpty()) { - // Currently only handles non-conflicting cases. In the future, it may: - // - Store the current state along with 3 versions - // - Return conflicts along with base/local/remote versions for each entry - // - Invoke a UI merger (let the UI handle merging and return the result) - return MergeResult.withConflicts(conflicts); // TODO: revisit the naming + List resolvedRemoteEntries = new ArrayList<>(); + +// for (ThreeWayEntryConflict conflict : conflicts) { +// // Uses a GUI dialog to let the user merge entries interactively +// BibEntry resolvedEntry = this.conflictResolver.resolveConflict(conflict, prefs, dialogService); +// resolvedRemoteEntries.add(resolvedEntry); +// } +// // Replace conflicted entries in remote with user-resolved ones +// effectiveRemote = GitMergeUtil.replaceEntries(remote, resolvedRemoteEntries); } - // If the user returns a manually merged result, it should use: i.e.: MergeResult performSemanticMerge(..., BibDatabaseContext userResolvedResult) - - // 3. Apply remote patch to local - MergePlan plan = SemanticConflictDetector.extractMergePlan(base, remote); + // 4. Apply resolved remote (either original or conflict-resolved) to local + MergePlan plan = SemanticConflictDetector.extractMergePlan(base, effectiveRemote); SemanticMerger.applyMergePlan(local, plan); - // 4. Write back merged result + // 5. Write back merged result GitFileWriter.write(bibFilePath, local, importFormatPreferences); return MergeResult.success(); } // WIP + // TODO: add test public void push(Path bibFilePath) throws GitAPIException, IOException { - this.gitHandler = new GitHandler(bibFilePath.getParent()); - // 1. Auto-commit: commit if there are changes boolean committed = gitHandler.createCommitOnCurrentBranch("Changes committed by JabRef", !AMEND); diff --git a/jablib/src/main/java/org/jabref/logic/git/util/SemanticConflictDetector.java b/jablib/src/main/java/org/jabref/logic/git/conflicts/SemanticConflictDetector.java similarity index 80% rename from jablib/src/main/java/org/jabref/logic/git/util/SemanticConflictDetector.java rename to jablib/src/main/java/org/jabref/logic/git/conflicts/SemanticConflictDetector.java index 1f7b232ff03..b10c6d2e6e6 100644 --- a/jablib/src/main/java/org/jabref/logic/git/util/SemanticConflictDetector.java +++ b/jablib/src/main/java/org/jabref/logic/git/conflicts/SemanticConflictDetector.java @@ -1,4 +1,4 @@ -package org.jabref.logic.git.util; +package org.jabref.logic.git.conflicts; import java.util.ArrayList; import java.util.HashSet; @@ -14,6 +14,7 @@ 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; @@ -23,7 +24,7 @@ public class SemanticConflictDetector { * result := local + remoteDiff * and then create merge commit having result as file content and local and remote branch as parent */ - public static List detectConflicts(BibDatabaseContext base, BibDatabaseContext local, BibDatabaseContext remote) { + public static List detectConflicts(BibDatabaseContext base, BibDatabaseContext local, BibDatabaseContext remote) { // 1. get diffs between base and remote List remoteDiffs = BibDatabaseDiff.compare(base, remote).getEntryDifferences(); if (remoteDiffs == null) { @@ -33,7 +34,7 @@ public static List detectConflicts(BibDatabaseContext base, BibDat Map baseEntries = toEntryMap(base); Map localEntries = toEntryMap(local); - List conflicts = new ArrayList<>(); + List conflicts = new ArrayList<>(); // 3. look for entries modified in both local and remote for (BibEntryDiff remoteDiff : remoteDiffs) { @@ -47,10 +48,23 @@ public static List detectConflicts(BibDatabaseContext base, BibDat BibEntry localEntry = localEntries.get(citationKey); BibEntry remoteEntry = remoteDiff.newEntry(); - // if the entry exists in all 3 versions + // Conflict 1: if the entry exists in all 3 versions if (baseEntry != null && localEntry != null && remoteEntry != null) { if (hasConflictingFields(baseEntry, localEntry, remoteEntry)) { - conflicts.add(new BibEntryDiff(localEntry, remoteEntry)); + conflicts.add(new ThreeWayEntryConflict(baseEntry, localEntry, remoteEntry)); + } + // Conflict 2: base missing, but local + remote both added same citation key with different content + } else if (baseEntry == null && localEntry != null && remoteEntry != null) { + if (!Objects.equals(localEntry, remoteEntry)) { + conflicts.add(new ThreeWayEntryConflict(null, localEntry, remoteEntry)); + } + // Case 3: one side deleted, other side modified + } else if (baseEntry != null) { + if (localEntry != null && remoteEntry == null && !Objects.equals(baseEntry, localEntry)) { + conflicts.add(new ThreeWayEntryConflict(baseEntry, localEntry, null)); + } + if (localEntry == null && remoteEntry != null && !Objects.equals(baseEntry, remoteEntry)) { + conflicts.add(new ThreeWayEntryConflict(baseEntry, null, remoteEntry)); } } } diff --git a/jablib/src/main/java/org/jabref/logic/git/conflicts/ThreeWayEntryConflict.java b/jablib/src/main/java/org/jabref/logic/git/conflicts/ThreeWayEntryConflict.java new file mode 100644 index 00000000000..694d0e210f5 --- /dev/null +++ b/jablib/src/main/java/org/jabref/logic/git/conflicts/ThreeWayEntryConflict.java @@ -0,0 +1,9 @@ +package org.jabref.logic.git.conflicts; + +import org.jabref.model.entry.BibEntry; + +public record ThreeWayEntryConflict( + BibEntry base, + BibEntry local, + BibEntry remote +) { } diff --git a/jablib/src/main/java/org/jabref/logic/git/util/GitBibParser.java b/jablib/src/main/java/org/jabref/logic/git/io/GitBibParser.java similarity index 84% rename from jablib/src/main/java/org/jabref/logic/git/util/GitBibParser.java rename to jablib/src/main/java/org/jabref/logic/git/io/GitBibParser.java index 358d696aa9c..74b59691629 100644 --- a/jablib/src/main/java/org/jabref/logic/git/util/GitBibParser.java +++ b/jablib/src/main/java/org/jabref/logic/git/io/GitBibParser.java @@ -1,7 +1,7 @@ -package org.jabref.logic.git.util; +package org.jabref.logic.git.io; import java.io.IOException; -import java.io.StringReader; +import java.io.Reader; import org.jabref.logic.JabRefException; import org.jabref.logic.importer.ImportFormatPreferences; @@ -15,7 +15,8 @@ public static BibDatabaseContext parseBibFromGit(String bibContent, ImportFormat BibtexParser parser = new BibtexParser(importFormatPreferences, new DummyFileUpdateMonitor()); ParserResult result; try { - result = parser.parse(new StringReader(bibContent)); + Reader reader = Reader.of(bibContent); + result = parser.parse(reader); return result.getDatabaseContext(); } catch (IOException e) { throw new JabRefException("Failed to parse BibTeX content from Git", e); diff --git a/jablib/src/main/java/org/jabref/logic/git/util/GitFileReader.java b/jablib/src/main/java/org/jabref/logic/git/io/GitFileReader.java similarity index 98% rename from jablib/src/main/java/org/jabref/logic/git/util/GitFileReader.java rename to jablib/src/main/java/org/jabref/logic/git/io/GitFileReader.java index 3f56f8a9eb7..f5474f01313 100644 --- a/jablib/src/main/java/org/jabref/logic/git/util/GitFileReader.java +++ b/jablib/src/main/java/org/jabref/logic/git/io/GitFileReader.java @@ -1,4 +1,4 @@ -package org.jabref.logic.git.util; +package org.jabref.logic.git.io; import java.io.IOException; import java.nio.charset.StandardCharsets; diff --git a/jablib/src/main/java/org/jabref/logic/git/util/GitFileWriter.java b/jablib/src/main/java/org/jabref/logic/git/io/GitFileWriter.java similarity index 98% rename from jablib/src/main/java/org/jabref/logic/git/util/GitFileWriter.java rename to jablib/src/main/java/org/jabref/logic/git/io/GitFileWriter.java index aae93010d11..b2f249ef9ef 100644 --- a/jablib/src/main/java/org/jabref/logic/git/util/GitFileWriter.java +++ b/jablib/src/main/java/org/jabref/logic/git/io/GitFileWriter.java @@ -1,4 +1,4 @@ -package org.jabref.logic.git.util; +package org.jabref.logic.git.io; import java.io.IOException; import java.nio.charset.Charset; diff --git a/jablib/src/main/java/org/jabref/logic/git/util/GitRevisionLocator.java b/jablib/src/main/java/org/jabref/logic/git/io/GitRevisionLocator.java similarity index 97% rename from jablib/src/main/java/org/jabref/logic/git/util/GitRevisionLocator.java rename to jablib/src/main/java/org/jabref/logic/git/io/GitRevisionLocator.java index dfd5397fbb0..93d01500623 100644 --- a/jablib/src/main/java/org/jabref/logic/git/util/GitRevisionLocator.java +++ b/jablib/src/main/java/org/jabref/logic/git/io/GitRevisionLocator.java @@ -1,4 +1,4 @@ -package org.jabref.logic.git.util; +package org.jabref.logic.git.io; import java.io.IOException; diff --git a/jablib/src/main/java/org/jabref/logic/git/util/RevisionTriple.java b/jablib/src/main/java/org/jabref/logic/git/io/RevisionTriple.java similarity index 81% rename from jablib/src/main/java/org/jabref/logic/git/util/RevisionTriple.java rename to jablib/src/main/java/org/jabref/logic/git/io/RevisionTriple.java index 9dc4c11c98b..8b9d38fdcf1 100644 --- a/jablib/src/main/java/org/jabref/logic/git/util/RevisionTriple.java +++ b/jablib/src/main/java/org/jabref/logic/git/io/RevisionTriple.java @@ -1,11 +1,10 @@ -package org.jabref.logic.git.util; +package org.jabref.logic.git.io; import org.eclipse.jgit.revwalk.RevCommit; /** * Holds the three relevant commits involved in a semantic three-way merge, * it is a helper value object used exclusively during merge resolution, not part of the domain model - * so currently placed in the logic package, may be moved to model in the future * * @param base the merge base (common ancestor of local and remote) * @param local the current local branch tip 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..c515ba7d792 --- /dev/null +++ b/jablib/src/main/java/org/jabref/logic/git/merge/GitMergeUtil.java @@ -0,0 +1,52 @@ +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 + */ + // TODO: unit test + 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 -> String.valueOf(entry.getCitationKey()), + Function.identity())); + + // 3. Iterate original remote entries + for (BibEntry entry : remote.getDatabase().getEntries()) { + String citationKey = entry.getCitationKey().orElse(null); + + if (citationKey != null && resolvedMap.containsKey(citationKey)) { + // Skip: this entry will be replaced + continue; + } + + // Clone the entry and add it to new DB + newDatabase.insertEntry((BibEntry) entry.clone()); + } + + // 4. Insert all resolved entries (cloned for safety) + for (BibEntry resolved : resolvedEntries) { + newDatabase.insertEntry((BibEntry) resolved.clone()); + } + + // 5. Construct a new BibDatabaseContext with this new database and same metadata + return new BibDatabaseContext(newDatabase, remote.getMetaData()); + } +} diff --git a/jablib/src/main/java/org/jabref/logic/git/merge/GitSemanticMergeExecutor.java b/jablib/src/main/java/org/jabref/logic/git/merge/GitSemanticMergeExecutor.java new file mode 100644 index 00000000000..6067cbc8353 --- /dev/null +++ b/jablib/src/main/java/org/jabref/logic/git/merge/GitSemanticMergeExecutor.java @@ -0,0 +1,22 @@ +package org.jabref.logic.git.merge; + +import java.io.IOException; +import java.nio.file.Path; + +import org.jabref.logic.git.model.MergeResult; +import org.jabref.model.database.BibDatabaseContext; + +public interface GitSemanticMergeExecutor { + + /** + * Applies semantic merge of remote into local, based on base version. + * Assumes conflicts have already been resolved (if any). + * + * @param base The common ancestor version + * @param local The current local version (to be updated) + * @param remote The incoming remote version (can be resolved or raw) + * @param bibFilePath The path to the target bib file (used for write-back) + * @return MergeResult object containing merge status + */ + MergeResult merge(BibDatabaseContext base, BibDatabaseContext local, BibDatabaseContext remote, Path bibFilePath) throws IOException; +} diff --git a/jablib/src/main/java/org/jabref/logic/git/merge/GitSemanticMergeExecutorImpl.java b/jablib/src/main/java/org/jabref/logic/git/merge/GitSemanticMergeExecutorImpl.java new file mode 100644 index 00000000000..6a6539bf6e0 --- /dev/null +++ b/jablib/src/main/java/org/jabref/logic/git/merge/GitSemanticMergeExecutorImpl.java @@ -0,0 +1,33 @@ +package org.jabref.logic.git.merge; + +import java.io.IOException; +import java.nio.file.Path; + +import org.jabref.logic.git.conflicts.SemanticConflictDetector; +import org.jabref.logic.git.io.GitFileWriter; +import org.jabref.logic.git.model.MergeResult; +import org.jabref.logic.importer.ImportFormatPreferences; +import org.jabref.model.database.BibDatabaseContext; + +public class GitSemanticMergeExecutorImpl implements GitSemanticMergeExecutor { + + private final ImportFormatPreferences importFormatPreferences; + + public GitSemanticMergeExecutorImpl(ImportFormatPreferences importFormatPreferences) { + this.importFormatPreferences = importFormatPreferences; + } + + @Override + public MergeResult merge(BibDatabaseContext base, BibDatabaseContext local, BibDatabaseContext remote, Path bibFilePath) throws IOException, IOException { + // 1. extract merge plan from base -> remote + MergePlan plan = SemanticConflictDetector.extractMergePlan(base, remote); + + // 2. apply remote changes to local + SemanticMerger.applyMergePlan(local, plan); + + // 3. write back merged content + GitFileWriter.write(bibFilePath, local, importFormatPreferences); + + return MergeResult.success(); + } +} diff --git a/jablib/src/main/java/org/jabref/logic/git/util/MergePlan.java b/jablib/src/main/java/org/jabref/logic/git/merge/MergePlan.java similarity index 73% rename from jablib/src/main/java/org/jabref/logic/git/util/MergePlan.java rename to jablib/src/main/java/org/jabref/logic/git/merge/MergePlan.java index c8a501a1e68..438b636f8d4 100644 --- a/jablib/src/main/java/org/jabref/logic/git/util/MergePlan.java +++ b/jablib/src/main/java/org/jabref/logic/git/merge/MergePlan.java @@ -1,4 +1,4 @@ -package org.jabref.logic.git.util; +package org.jabref.logic.git.merge; import java.util.List; import java.util.Map; @@ -8,8 +8,6 @@ /** * A data structure representing the result of semantic diffing between base and remote entries. - * Currently placed in the logic package as a merge-specific value object since it's not a persistent or user-visible concept. - * may be moved to model in the future * * @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 diff --git a/jablib/src/main/java/org/jabref/logic/git/util/SemanticMerger.java b/jablib/src/main/java/org/jabref/logic/git/merge/SemanticMerger.java similarity index 90% rename from jablib/src/main/java/org/jabref/logic/git/util/SemanticMerger.java rename to jablib/src/main/java/org/jabref/logic/git/merge/SemanticMerger.java index 5f83cccb403..efc906391c7 100644 --- a/jablib/src/main/java/org/jabref/logic/git/util/SemanticMerger.java +++ b/jablib/src/main/java/org/jabref/logic/git/merge/SemanticMerger.java @@ -1,8 +1,9 @@ -package org.jabref.logic.git.util; +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; @@ -15,7 +16,8 @@ public class SemanticMerger { /** * 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 SemanticConflictDetector + * 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()); diff --git a/jablib/src/main/java/org/jabref/logic/git/util/MergeResult.java b/jablib/src/main/java/org/jabref/logic/git/model/MergeResult.java similarity index 93% rename from jablib/src/main/java/org/jabref/logic/git/util/MergeResult.java rename to jablib/src/main/java/org/jabref/logic/git/model/MergeResult.java index edc235e3424..23348aad514 100644 --- a/jablib/src/main/java/org/jabref/logic/git/util/MergeResult.java +++ b/jablib/src/main/java/org/jabref/logic/git/model/MergeResult.java @@ -1,4 +1,4 @@ -package org.jabref.logic.git.util; +package org.jabref.logic.git.model; import java.util.List; 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 850231bb67f..385d4b592d3 100644 --- a/jablib/src/test/java/org/jabref/logic/git/GitHandlerTest.java +++ b/jablib/src/test/java/org/jabref/logic/git/GitHandlerTest.java @@ -17,6 +17,7 @@ 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 @@ -93,9 +94,23 @@ void fetchOnCurrentBranch() throws IOException, GitAPIException, URISyntaxExcept gitHandler.fetchOnCurrentBranch(); try (Git git = Git.open(repositoryPath.toFile())) { - assertEquals(true, git.getRepository().getRefDatabase().hasRefs()); - assertEquals(true, git.getRepository().exactRef("refs/remotes/origin/main") != null); + assertTrue(git.getRepository().getRefDatabase().hasRefs()); + assertTrue(git.getRepository().exactRef("refs/remotes/origin/main") != null); } } } + + @Test + void fromAnyPathFindsGitRootFromNestedPath() throws IOException { + // Arrange: create a nested directory structure inside the temp Git repo + Path nested = repositoryPath.resolve("src/org/jabref"); + Files.createDirectories(nested); + + // Act: attempt to construct GitHandler from nested path + var 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 index 885f81e5d3c..b501a36c4ce 100644 --- a/jablib/src/test/java/org/jabref/logic/git/GitSyncServiceTest.java +++ b/jablib/src/test/java/org/jabref/logic/git/GitSyncServiceTest.java @@ -5,14 +5,13 @@ import java.nio.file.Path; import java.util.List; -import org.jabref.logic.git.util.GitFileReader; -import org.jabref.logic.git.util.MergeResult; +import org.jabref.logic.git.io.GitFileReader; +import org.jabref.logic.git.model.MergeResult; import org.jabref.logic.importer.ImportFormatPreferences; 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.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; @@ -101,7 +100,7 @@ void aliceBobSimple(@TempDir Path tempDir) throws Exception { // Alice: initial commit baseCommit = writeAndCommit(initialContent, "Inital commit", alice, library, aliceGit); - git.push().setRemote("origin").setRefSpecs(new RefSpec("main")).call(); + git.push().setRemote("origin").call(); // Bob clone remote Path bobDir = tempDir.resolve("bob"); @@ -113,7 +112,7 @@ void aliceBobSimple(@TempDir Path tempDir) throws Exception { .call(); Path bobLibrary = bobDir.resolve("library.bib"); bobCommit = writeAndCommit(bobUpdatedContent, "Exchange a with b", bob, bobLibrary, bobGit); - bobGit.push().setRemote("origin").setRefSpecs(new RefSpec("main")).call(); + bobGit.push().setRemote("origin").call(); // back to Alice's branch, fetch remote aliceCommit = writeAndCommit(aliceUpdatedContent, "Fix author of a", alice, library, aliceGit); 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..ca40f88febe --- /dev/null +++ b/jablib/src/test/java/org/jabref/logic/git/merge/GitSemanticMergeExecutorTest.java @@ -0,0 +1,58 @@ +package org.jabref.logic.git.merge; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +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 static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; + +public class GitSemanticMergeExecutorTest { + + private BibDatabaseContext base; + private BibDatabaseContext local; + private BibDatabaseContext remote; + private ImportFormatPreferences preferences; + private GitSemanticMergeExecutor executor; + private Path tempFile; + + @BeforeEach + public void setup() throws IOException { + base = new BibDatabaseContext(); + local = new BibDatabaseContext(); + remote = new BibDatabaseContext(); + + BibEntry baseEntry = new BibEntry().withCitationKey("Smith2020").withField(StandardField.TITLE, "Old Title"); + BibEntry localEntry = (BibEntry) baseEntry.clone(); + BibEntry remoteEntry = (BibEntry) baseEntry.clone(); + remoteEntry.setField(StandardField.TITLE, "New Title"); + + base.getDatabase().insertEntry(baseEntry); + local.getDatabase().insertEntry(localEntry); + remote.getDatabase().insertEntry(remoteEntry); + + preferences = mock(ImportFormatPreferences.class); + executor = new GitSemanticMergeExecutorImpl(preferences); + + tempFile = Files.createTempFile("merged", ".bib"); + tempFile.toFile().deleteOnExit(); + } + + @Test + public void successfulMergeAndWrite() throws IOException { + MergeResult result = executor.merge(base, local, remote, tempFile); + + assertTrue(result.isSuccessful()); + String content = Files.readString(tempFile); + assertTrue(content.contains("New Title")); + } +} diff --git a/jablib/src/test/java/org/jabref/logic/git/util/GitBibParserTest.java b/jablib/src/test/java/org/jabref/logic/git/util/GitBibParserTest.java index e7346b9a405..cb9434527af 100644 --- a/jablib/src/test/java/org/jabref/logic/git/util/GitBibParserTest.java +++ b/jablib/src/test/java/org/jabref/logic/git/util/GitBibParserTest.java @@ -6,6 +6,8 @@ import java.util.List; import java.util.Optional; +import org.jabref.logic.git.io.GitBibParser; +import org.jabref.logic.git.io.GitFileReader; import org.jabref.logic.importer.ImportFormatPreferences; import org.jabref.model.database.BibDatabaseContext; import org.jabref.model.entry.BibEntry; 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 index 599dc2fd9af..2cd9bd0924c 100644 --- a/jablib/src/test/java/org/jabref/logic/git/util/GitFileWriterTest.java +++ b/jablib/src/test/java/org/jabref/logic/git/util/GitFileWriterTest.java @@ -4,6 +4,8 @@ import java.nio.file.Path; import java.util.List; +import org.jabref.logic.git.io.GitBibParser; +import org.jabref.logic.git.io.GitFileWriter; import org.jabref.logic.importer.ImportFormatPreferences; import org.jabref.model.database.BibDatabaseContext; import org.jabref.model.entry.BibEntry; @@ -26,7 +28,7 @@ void setUp() { } @Test - void testWriteThenReadBack() throws Exception { + void writeThenReadBack() throws Exception { BibDatabaseContext inputDatabaseContext = GitBibParser.parseBibFromGit( """ @article{a, 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 index 602d2f3edf0..a5e5d2d2b4c 100644 --- a/jablib/src/test/java/org/jabref/logic/git/util/GitRevisionLocatorTest.java +++ b/jablib/src/test/java/org/jabref/logic/git/util/GitRevisionLocatorTest.java @@ -4,6 +4,9 @@ import java.nio.file.Files; import java.nio.file.Path; +import org.jabref.logic.git.io.GitRevisionLocator; +import org.jabref.logic.git.io.RevisionTriple; + import org.eclipse.jgit.api.Git; import org.eclipse.jgit.revwalk.RevCommit; import org.junit.jupiter.api.Test; @@ -13,7 +16,7 @@ class GitRevisionLocatorTest { @Test - void testLocateMergeCommits(@TempDir Path tempDir) throws Exception { + void locateMergeCommits(@TempDir Path tempDir) throws Exception { Path bibFile = tempDir.resolve("library.bib"); Git git = Git.init().setDirectory(tempDir.toFile()).setInitialBranch("main").call(); 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 1bed281f58a..ec1c3c79639 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 @@ -7,7 +7,11 @@ import java.util.Map; import java.util.stream.Stream; -import org.jabref.logic.bibtex.comparator.BibEntryDiff; +import org.jabref.logic.git.conflicts.SemanticConflictDetector; +import org.jabref.logic.git.conflicts.ThreeWayEntryConflict; +import org.jabref.logic.git.io.GitBibParser; +import org.jabref.logic.git.io.GitFileReader; +import org.jabref.logic.git.merge.MergePlan; import org.jabref.logic.importer.ImportFormatPreferences; import org.jabref.model.database.BibDatabaseContext; import org.jabref.model.entry.field.Field; @@ -52,7 +56,7 @@ void setup(@TempDir Path tempDir) throws Exception { @ParameterizedTest(name = "{0}") @MethodSource("provideConflictCases") - void testSemanticConflicts(String description, String base, String local, String remote, boolean expectConflict) throws Exception { + 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); @@ -61,7 +65,7 @@ void testSemanticConflicts(String description, String base, String local, String BibDatabaseContext localDatabaseContext = parse(localCommit); BibDatabaseContext remoteDatabaseContext = parse(remoteCommit); - List diffs = SemanticConflictDetector.detectConflicts(baseDatabaseContext, localDatabaseContext, remoteDatabaseContext); + List diffs = SemanticConflictDetector.detectConflicts(baseDatabaseContext, localDatabaseContext, remoteDatabaseContext); if (expectConflict) { assertEquals(1, diffs.size(), "Expected a conflict but found none"); @@ -412,12 +416,44 @@ static Stream provideConflictCases() { } """, 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 testExtractMergePlan_T10_onlyRemoteChangedEntryB() throws Exception { + void extractMergePlanT10OnlyRemoteChangedEntryB() throws Exception { String base = """ @article{a, author = {lala}, @@ -454,7 +490,7 @@ void testExtractMergePlan_T10_onlyRemoteChangedEntryB() throws Exception { } @Test - void testExtractMergePlan_T11_remoteAddsField() throws Exception { + void extractMergePlanT11RemoteAddsField() throws Exception { String base = """ @article{a, author = {lala}, 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 index 2e6f4b88b96..dad6a29f16e 100644 --- a/jablib/src/test/java/org/jabref/logic/git/util/SemanticMergerTest.java +++ b/jablib/src/test/java/org/jabref/logic/git/util/SemanticMergerTest.java @@ -2,6 +2,10 @@ import java.util.stream.Stream; +import org.jabref.logic.git.conflicts.SemanticConflictDetector; +import org.jabref.logic.git.io.GitBibParser; +import org.jabref.logic.git.merge.MergePlan; +import org.jabref.logic.git.merge.SemanticMerger; import org.jabref.logic.importer.ImportFormatPreferences; import org.jabref.model.database.BibDatabaseContext; import org.jabref.model.entry.BibEntry; @@ -28,7 +32,7 @@ void setup() { @ParameterizedTest(name = "Database patch: {0}") @MethodSource("provideDatabasePatchCases") - void testPatchDatabase(String description, String base, String local, String remote, String expectedAuthor) throws Exception { + void patchDatabase(String description, String base, String local, String remote, String expectedAuthor) throws Exception { BibDatabaseContext baseDatabaseContext = GitBibParser.parseBibFromGit(base, importFormatPreferences); BibDatabaseContext localDatabaseContext = GitBibParser.parseBibFromGit(local, importFormatPreferences); BibDatabaseContext remoteDatabaseContext = GitBibParser.parseBibFromGit(remote, importFormatPreferences); From ac66eedadf1ee938a58d9eb40067c6651e9cb714 Mon Sep 17 00:00:00 2001 From: Wanling Fu Date: Wed, 9 Jul 2025 11:50:53 +0100 Subject: [PATCH 06/37] chore(git): Add push test + Fix failing unit test #12350 --- docs/code-howtos/git.md | 2 +- .../org/jabref/gui/git/GitPullViewModel.java | 4 + .../java/org/jabref/logic/git/GitHandler.java | 10 +- .../org/jabref/logic/git/GitSyncService.java | 128 ++++++++++++---- .../jabref/logic/git/io/GitFileWriter.java | 4 +- .../logic/git/io/GitRevisionLocator.java | 23 ++- .../jabref/logic/git/model/MergeResult.java | 4 + .../logic/git/status/GitStatusChecker.java | 90 +++++++++++ .../logic/git/status/GitStatusSnapshot.java | 9 ++ .../jabref/logic/git/status/SyncStatus.java | 11 ++ .../jabref/logic/git/GitSyncServiceTest.java | 27 +++- .../merge/GitSemanticMergeExecutorTest.java | 9 +- .../git/status/GitStatusCheckerTest.java | 145 ++++++++++++++++++ 13 files changed, 423 insertions(+), 43 deletions(-) create mode 100644 jablib/src/main/java/org/jabref/logic/git/status/GitStatusChecker.java create mode 100644 jablib/src/main/java/org/jabref/logic/git/status/GitStatusSnapshot.java create mode 100644 jablib/src/main/java/org/jabref/logic/git/status/SyncStatus.java create mode 100644 jablib/src/test/java/org/jabref/logic/git/status/GitStatusCheckerTest.java diff --git a/docs/code-howtos/git.md b/docs/code-howtos/git.md index 9589e333fc8..630ebe030bf 100644 --- a/docs/code-howtos/git.md +++ b/docs/code-howtos/git.md @@ -1,6 +1,7 @@ # git ## Conflict Scenarios + - **T1.** Remote changed a field, local did not → No conflict. The local version remained unchanged, so the remote change can be safely applied. @@ -68,4 +69,3 @@ - **T17.** Both added the same entry key with identical values → No conflict. Both sides created a new entry with the same citation key and identical fields, so it can be merged safely. - diff --git a/jabgui/src/main/java/org/jabref/gui/git/GitPullViewModel.java b/jabgui/src/main/java/org/jabref/gui/git/GitPullViewModel.java index c580f668821..9687e49945d 100644 --- a/jabgui/src/main/java/org/jabref/gui/git/GitPullViewModel.java +++ b/jabgui/src/main/java/org/jabref/gui/git/GitPullViewModel.java @@ -29,6 +29,10 @@ import org.eclipse.jgit.api.errors.GitAPIException; import org.eclipse.jgit.revwalk.RevCommit; +/** + * ViewModel responsible for coordinating UI-bound Git Pull workflow, + * including conflict resolution. + */ public class GitPullViewModel extends AbstractViewModel { private final ImportFormatPreferences importFormatPreferences; private final GitConflictResolver conflictResolver; 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 4a8771f6e60..916b2dbbdd2 100644 --- a/jablib/src/main/java/org/jabref/logic/git/GitHandler.java +++ b/jablib/src/main/java/org/jabref/logic/git/GitHandler.java @@ -215,7 +215,7 @@ 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, 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 @@ -230,4 +230,12 @@ public static Optional fromAnyPath(Path anyPathInsideRepo) { } 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 index b427731a6a7..b021a9e3541 100644 --- a/jablib/src/main/java/org/jabref/logic/git/GitSyncService.java +++ b/jablib/src/main/java/org/jabref/logic/git/GitSyncService.java @@ -16,6 +16,9 @@ import org.jabref.logic.git.merge.MergePlan; import org.jabref.logic.git.merge.SemanticMerger; import org.jabref.logic.git.model.MergeResult; +import org.jabref.logic.git.status.GitStatusChecker; +import org.jabref.logic.git.status.GitStatusSnapshot; +import org.jabref.logic.git.status.SyncStatus; import org.jabref.logic.importer.ImportFormatPreferences; import org.jabref.model.database.BibDatabaseContext; import org.jabref.model.entry.BibEntry; @@ -27,11 +30,23 @@ import org.slf4j.LoggerFactory; /** - * Orchestrator for git sync service + * GitSyncService currently serves as an orchestrator for Git pull/push logic. * if (hasConflict) * → UI merge; * else * → autoMerge := local + remoteDiff + * + * NOTICE: + * - TODO:This class will be **deprecated** in the near future to avoid architecture violation (logic → gui)! + * - The underlying business logic will not change significantly. + * - Only the coordination responsibilities will shift to GUI/ViewModel layer. + * + * PLAN: + * - All orchestration logic (pull/push, merge, resolve, commit) + * will be **moved into corresponding ViewModels**, such as: + * - GitPullViewModel + * - GitPushViewModel + * - GitStatusViewModel */ public class GitSyncService { private static final Logger LOGGER = LoggerFactory.getLogger(GitSyncService.class); @@ -49,24 +64,42 @@ public GitSyncService(ImportFormatPreferences importFormatPreferences, GitHandle * Called when user clicks Pull */ public MergeResult fetchAndMerge(Path bibFilePath) throws GitAPIException, IOException, JabRefException { - Git git = Git.open(bibFilePath.getParent().toFile()); + GitStatusSnapshot status = GitStatusChecker.checkStatus(bibFilePath); - // 1. fetch latest remote branch - gitHandler.fetchOnCurrentBranch(); - - // 2. Locating the base / local / remote versions - GitRevisionLocator locator = new GitRevisionLocator(); - RevisionTriple triple = locator.locateMergeCommits(git); + if (!status.tracking()) { + LOGGER.warn("Pull aborted: The file is not under Git version control."); + return MergeResult.failure(); + } - // 3. Calling semantic merge logic - MergeResult result = performSemanticMerge(git, triple.base(), triple.local(), triple.remote(), bibFilePath); + if (status.conflict()) { + LOGGER.warn("Pull aborted: Local repository has unresolved merge conflicts."); + return MergeResult.failure(); + } - // 4. Automatic merge - if (result.isSuccessful()) { - gitHandler.createCommitOnCurrentBranch("Auto-merged by JabRef", !AMEND); + 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(); } - return result; + // Status is BEHIND or DIVERGED + try (Git git = gitHandler.open()) { + // 1. Fetch latest remote branch + gitHandler.fetchOnCurrentBranch(); + + // 2. Locate base / local / remote commits + GitRevisionLocator locator = new GitRevisionLocator(); + RevisionTriple triple = locator.locateMergeCommits(git); + + // 3. Perform semantic merge + MergeResult result = performSemanticMerge(git, triple.base(), triple.local(), triple.remote(), bibFilePath); + + // 4. Auto-commit merge result if successful + if (result.isSuccessful()) { + gitHandler.createCommitOnCurrentBranch("Auto-merged by JabRef", !AMEND); + } + + return result; + } } public MergeResult performSemanticMerge(Git git, @@ -80,7 +113,6 @@ public MergeResult performSemanticMerge(Git git, Path relativePath; // TODO: Validate that the .bib file is inside the Git repository earlier in the workflow. - // This check might be better placed before calling performSemanticMerge. if (!bibPath.startsWith(workTree)) { throw new IllegalStateException("Given .bib file is not inside repository"); } @@ -122,17 +154,61 @@ public MergeResult performSemanticMerge(Git git, return MergeResult.success(); } - // WIP - // TODO: add test - public void push(Path bibFilePath) throws GitAPIException, IOException { - // 1. Auto-commit: commit if there are changes - boolean committed = gitHandler.createCommitOnCurrentBranch("Changes committed by JabRef", !AMEND); - - // 2. push to remote - if (committed) { - gitHandler.pushCommitsToRemoteRepository(); - } else { - LOGGER.info("No changes to commit — skipping push"); + public void push(Path bibFilePath) throws GitAPIException, IOException, JabRefException { + GitStatusSnapshot status = GitStatusChecker.checkStatus(bibFilePath); + + if (!status.tracking()) { + LOGGER.warn("Push aborted: file is not tracked by Git"); + return; + } + + switch (status.syncStatus()) { + case UP_TO_DATE -> { + boolean committed = gitHandler.createCommitOnCurrentBranch("Changes committed by JabRef", !AMEND); + if (committed) { + gitHandler.pushCommitsToRemoteRepository(); + } else { + LOGGER.info("No changes to commit — skipping push"); + } + } + + case AHEAD -> { + gitHandler.pushCommitsToRemoteRepository(); + } + + case BEHIND -> { + LOGGER.warn("Push aborted: Local branch is behind remote. Please pull first."); + } + + case DIVERGED -> { + try (Git git = gitHandler.open()) { + GitRevisionLocator locator = new GitRevisionLocator(); + RevisionTriple triple = locator.locateMergeCommits(git); + + MergeResult mergeResult = performSemanticMerge(git, triple.base(), triple.local(), triple.remote(), bibFilePath); + + if (!mergeResult.isSuccessful()) { + LOGGER.warn("Semantic merge failed — aborting push"); + return; + } + + boolean committed = gitHandler.createCommitOnCurrentBranch("Merged changes", !AMEND); + + if (committed) { + gitHandler.pushCommitsToRemoteRepository(); + } else { + LOGGER.info("Nothing to commit after semantic merge — skipping push"); + } + } + } + + case CONFLICT -> { + LOGGER.warn("Push aborted: Local repository has unresolved merge conflicts."); + } + + case UNTRACKED, UNKNOWN -> { + LOGGER.warn("Push aborted: Untracked or unknown Git status."); + } } } } diff --git a/jablib/src/main/java/org/jabref/logic/git/io/GitFileWriter.java b/jablib/src/main/java/org/jabref/logic/git/io/GitFileWriter.java index b2f249ef9ef..4f0c8d2076b 100644 --- a/jablib/src/main/java/org/jabref/logic/git/io/GitFileWriter.java +++ b/jablib/src/main/java/org/jabref/logic/git/io/GitFileWriter.java @@ -6,8 +6,8 @@ import java.nio.file.Path; import org.jabref.logic.exporter.AtomicFileWriter; +import org.jabref.logic.exporter.BibDatabaseWriter; import org.jabref.logic.exporter.BibWriter; -import org.jabref.logic.exporter.BibtexDatabaseWriter; import org.jabref.logic.exporter.SelfContainedSaveConfiguration; import org.jabref.logic.importer.ImportFormatPreferences; import org.jabref.model.database.BibDatabaseContext; @@ -21,7 +21,7 @@ public static void write(Path file, BibDatabaseContext bibDatabaseContext, Impor synchronized (bibDatabaseContext) { try (AtomicFileWriter fileWriter = new AtomicFileWriter(file, encoding, saveConfiguration.shouldMakeBackup())) { BibWriter bibWriter = new BibWriter(fileWriter, bibDatabaseContext.getDatabase().getNewLineSeparator()); - BibtexDatabaseWriter writer = new BibtexDatabaseWriter( + BibDatabaseWriter writer = new BibDatabaseWriter( bibWriter, saveConfiguration, importPrefs.fieldPreferences(), 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 index 93d01500623..015d73dc05b 100644 --- a/jablib/src/main/java/org/jabref/logic/git/io/GitRevisionLocator.java +++ b/jablib/src/main/java/org/jabref/logic/git/io/GitRevisionLocator.java @@ -7,8 +7,10 @@ import org.eclipse.jgit.api.Git; import org.eclipse.jgit.api.errors.GitAPIException; import org.eclipse.jgit.lib.ObjectId; +import org.eclipse.jgit.lib.Repository; import org.eclipse.jgit.revwalk.RevCommit; import org.eclipse.jgit.revwalk.RevWalk; +import org.eclipse.jgit.revwalk.filter.RevFilter; /** * Find the base/local/remote three commits: @@ -21,11 +23,12 @@ public class GitRevisionLocator { private static final String REMOTE = "refs/remotes/origin/main"; public RevisionTriple locateMergeCommits(Git git) throws GitAPIException, IOException, JabRefException { + Repository repo = git.getRepository(); // assumes the remote branch is 'origin/main' - ObjectId headId = git.getRepository().resolve(HEAD); + ObjectId headId = repo.resolve(HEAD); // and uses the default remote tracking reference // does not support multiple remotes or custom remote branch names so far - ObjectId remoteId = git.getRepository().resolve(REMOTE); + ObjectId remoteId = repo.resolve(REMOTE); if (remoteId == null) { throw new IllegalStateException("Remote branch missing origin/main."); } @@ -33,14 +36,18 @@ public RevisionTriple locateMergeCommits(Git git) throws GitAPIException, IOExce try (RevWalk walk = new RevWalk(git.getRepository())) { RevCommit local = walk.parseCommit(headId); RevCommit remote = walk.parseCommit(remoteId); - - walk.setRevFilter(org.eclipse.jgit.revwalk.filter.RevFilter.MERGE_BASE); - walk.markStart(local); - walk.markStart(remote); - - RevCommit base = walk.next(); + RevCommit base = findMergeBase(repo, local, remote); return new RevisionTriple(base, local, remote); } } + + public static RevCommit findMergeBase(Repository repo, RevCommit a, RevCommit b) throws IOException { + try (RevWalk walk = new RevWalk(repo)) { + walk.setRevFilter(RevFilter.MERGE_BASE); + walk.markStart(a); + walk.markStart(b); + return walk.next(); + } + } } diff --git a/jablib/src/main/java/org/jabref/logic/git/model/MergeResult.java b/jablib/src/main/java/org/jabref/logic/git/model/MergeResult.java index 23348aad514..ef2dd4b0391 100644 --- a/jablib/src/main/java/org/jabref/logic/git/model/MergeResult.java +++ b/jablib/src/main/java/org/jabref/logic/git/model/MergeResult.java @@ -14,6 +14,10 @@ public static MergeResult success() { return new MergeResult(SUCCESS, List.of()); } + public static MergeResult failure() { + return new MergeResult(false, List.of()); + } + public boolean hasConflicts() { return !conflicts.isEmpty(); } diff --git a/jablib/src/main/java/org/jabref/logic/git/status/GitStatusChecker.java b/jablib/src/main/java/org/jabref/logic/git/status/GitStatusChecker.java new file mode 100644 index 00000000000..0bb288389e9 --- /dev/null +++ b/jablib/src/main/java/org/jabref/logic/git/status/GitStatusChecker.java @@ -0,0 +1,90 @@ +package org.jabref.logic.git.status; + +import java.io.IOException; +import java.nio.file.Path; +import java.util.Optional; + +import org.jabref.logic.git.GitHandler; +import org.jabref.logic.git.io.GitRevisionLocator; + +import org.eclipse.jgit.api.Git; +import org.eclipse.jgit.api.Status; +import org.eclipse.jgit.api.errors.GitAPIException; +import org.eclipse.jgit.lib.ObjectId; +import org.eclipse.jgit.lib.Repository; +import org.eclipse.jgit.revwalk.RevCommit; +import org.eclipse.jgit.revwalk.RevWalk; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * This class is used to determine the status of a Git repository from any given path inside it. + * If no repository is found, it returns a {@link GitStatusSnapshot} with tracking = false. + * Otherwise, it returns a full snapshot including tracking status, sync status, and conflict state. + */ +public class GitStatusChecker { + private static final Logger LOGGER = LoggerFactory.getLogger(GitStatusChecker.class); + + public static GitStatusSnapshot checkStatus(Path anyPathInsideRepo) { + Optional maybeHandler = GitHandler.fromAnyPath(anyPathInsideRepo); + + if (maybeHandler.isEmpty()) { + return new GitStatusSnapshot(false, SyncStatus.UNTRACKED, false, Optional.empty()); + } + GitHandler handler = maybeHandler.get(); + + try (Git git = Git.open(handler.getRepositoryPathAsFile())) { + Repository repo = git.getRepository(); + Status status = git.status().call(); + boolean hasConflict = !status.getConflicting().isEmpty(); + + ObjectId localHead = repo.resolve("HEAD"); + ObjectId remoteHead = repo.resolve("refs/remotes/origin/main"); + SyncStatus syncStatus = determineSyncStatus(repo, localHead, remoteHead); + + return new GitStatusSnapshot( + true, + syncStatus, + hasConflict, + Optional.ofNullable(localHead).map(ObjectId::getName) + ); + } catch (IOException | GitAPIException e) { + LOGGER.warn("Failed to check Git status: " + e.getMessage()); + return new GitStatusSnapshot( + true, + SyncStatus.UNKNOWN, + false, + Optional.empty() + ); + } + } + + private static SyncStatus determineSyncStatus(Repository repo, ObjectId localHead, ObjectId remoteHead) throws IOException { + if (localHead == null || remoteHead == null) { + return SyncStatus.UNKNOWN; + } + + if (localHead.equals(remoteHead)) { + return SyncStatus.UP_TO_DATE; + } + + try (RevWalk walk = new RevWalk(repo)) { + RevCommit localCommit = walk.parseCommit(localHead); + RevCommit remoteCommit = walk.parseCommit(remoteHead); + RevCommit mergeBase = GitRevisionLocator.findMergeBase(repo, localCommit, remoteCommit); + + boolean ahead = !localCommit.equals(mergeBase); + boolean behind = !remoteCommit.equals(mergeBase); + + if (ahead && behind) { + return SyncStatus.DIVERGED; + } else if (ahead) { + return SyncStatus.AHEAD; + } else if (behind) { + return SyncStatus.BEHIND; + } else { + return SyncStatus.UNKNOWN; + } + } + } +} diff --git a/jablib/src/main/java/org/jabref/logic/git/status/GitStatusSnapshot.java b/jablib/src/main/java/org/jabref/logic/git/status/GitStatusSnapshot.java new file mode 100644 index 00000000000..2b28d23ee47 --- /dev/null +++ b/jablib/src/main/java/org/jabref/logic/git/status/GitStatusSnapshot.java @@ -0,0 +1,9 @@ +package org.jabref.logic.git.status; + +import java.util.Optional; + +public record GitStatusSnapshot( + boolean tracking, + SyncStatus syncStatus, + boolean conflict, + Optional lastPulledCommit) { } diff --git a/jablib/src/main/java/org/jabref/logic/git/status/SyncStatus.java b/jablib/src/main/java/org/jabref/logic/git/status/SyncStatus.java new file mode 100644 index 00000000000..fbd3691bacb --- /dev/null +++ b/jablib/src/main/java/org/jabref/logic/git/status/SyncStatus.java @@ -0,0 +1,11 @@ +package org.jabref.logic.git.status; + +public enum SyncStatus { + UP_TO_DATE, // Local and remote are in sync + BEHIND, // Local is behind remote, pull needed + AHEAD, // Local is ahead of remote, push needed + DIVERGED, // Both local and remote have new commits; merge required + CONFLICT, // Merge conflict detected + UNTRACKED, // Not under Git control + UNKNOWN // Status couldn't be determined +} diff --git a/jablib/src/test/java/org/jabref/logic/git/GitSyncServiceTest.java b/jablib/src/test/java/org/jabref/logic/git/GitSyncServiceTest.java index b501a36c4ce..887c8880e26 100644 --- a/jablib/src/test/java/org/jabref/logic/git/GitSyncServiceTest.java +++ b/jablib/src/test/java/org/jabref/logic/git/GitSyncServiceTest.java @@ -118,16 +118,13 @@ void aliceBobSimple(@TempDir Path tempDir) throws Exception { aliceCommit = writeAndCommit(aliceUpdatedContent, "Fix author of a", alice, library, aliceGit); git.fetch().setRemote("origin").call(); - // ToDo: Replace by call to GitSyncService crafting a merge commit -// git.merge().include(aliceCommit).include(bobCommit).call(); // Will throw exception bc of merge conflict - // Debug hint: Show the created git graph on the command line // git log --graph --oneline --decorate --all --reflog } @Test void pullTriggersSemanticMergeWhenNoConflicts() throws Exception { - GitHandler gitHandler = mock(GitHandler.class); + GitHandler gitHandler = new GitHandler(library.getParent()); GitSyncService syncService = new GitSyncService(importFormatPreferences, gitHandler); MergeResult result = syncService.fetchAndMerge(library); @@ -149,6 +146,28 @@ void pullTriggersSemanticMergeWhenNoConflicts() throws Exception { assertEquals(normalize(expected), normalize(merged)); } + @Test + void pushTriggersMergeAndPushWhenNoConflicts() throws Exception { + GitHandler gitHandler = new GitHandler(library.getParent()); + GitSyncService syncService = new GitSyncService(importFormatPreferences, gitHandler); + syncService.push(library); + + String pushedContent = GitFileReader.readFileFromCommit(git, git.log().setMaxCount(1).call().iterator().next(), Path.of("library.bib")); + String expected = """ + @article{a, + author = {author-a}, + doi = {xya}, + } + + @article{b, + author = {author-b}, + doi = {xyz}, + } + """; + + assertEquals(normalize(expected), normalize(pushedContent)); + } + @Test void readFromCommits() throws Exception { String base = GitFileReader.readFileFromCommit(git, baseCommit, Path.of("library.bib")); 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 index ca40f88febe..13ed348013d 100644 --- a/jablib/src/test/java/org/jabref/logic/git/merge/GitSemanticMergeExecutorTest.java +++ b/jablib/src/test/java/org/jabref/logic/git/merge/GitSemanticMergeExecutorTest.java @@ -4,6 +4,8 @@ import java.nio.file.Files; import java.nio.file.Path; +import javafx.collections.FXCollections; + import org.jabref.logic.git.model.MergeResult; import org.jabref.logic.importer.ImportFormatPreferences; import org.jabref.model.database.BibDatabaseContext; @@ -12,9 +14,11 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.mockito.Answers; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; public class GitSemanticMergeExecutorTest { @@ -40,7 +44,10 @@ public void setup() throws IOException { local.getDatabase().insertEntry(localEntry); remote.getDatabase().insertEntry(remoteEntry); - preferences = mock(ImportFormatPreferences.class); + preferences = mock(ImportFormatPreferences.class, Answers.RETURNS_DEEP_STUBS); + when(preferences.fieldPreferences().getNonWrappableFields()) + .thenReturn(FXCollections.emptyObservableList()); + executor = new GitSemanticMergeExecutorImpl(preferences); tempFile = Files.createTempFile("merged", ".bib"); 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..c3cfa4b5597 --- /dev/null +++ b/jablib/src/test/java/org/jabref/logic/git/status/GitStatusCheckerTest.java @@ -0,0 +1,145 @@ +package org.jabref.logic.git.status; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; + +import org.eclipse.jgit.api.Git; +import org.eclipse.jgit.lib.PersonIdent; +import org.eclipse.jgit.revwalk.RevCommit; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class GitStatusCheckerTest { + private Path localLibrary; + private Git localGit; + private Git remoteGit; + + private final PersonIdent author = new PersonIdent("Tester", "tester@example.org"); + + private final String baseContent = """ + @article{a, + author = {initial-author}, + doi = {xya}, + } + + @article{b, + author = {initial-author}, + doi = {xyz}, + } + """; + + private final String remoteUpdatedContent = """ + @article{a, + author = {initial-author}, + doi = {xya}, + } + + @article{b, + author = {remote-update}, + doi = {xyz}, + } + """; + + private final String localUpdatedContent = """ + @article{a, + author = {local-update}, + doi = {xya}, + } + + @article{b, + author = {initial-author}, + doi = {xyz}, + } + """; + + @BeforeEach + void setup(@TempDir Path tempDir) throws Exception { + Path remoteDir = tempDir.resolve("remote.git"); + remoteGit = Git.init().setBare(true).setDirectory(remoteDir.toFile()).call(); + + Path localDir = tempDir.resolve("local"); + localGit = Git.cloneRepository() + .setURI(remoteDir.toUri().toString()) + .setDirectory(localDir.toFile()) + .setBranch("main") + .call(); + + this.localLibrary = localDir.resolve("library.bib"); + + // Initial commit + commitFile(localGit, baseContent, "Initial commit"); + + // Push to remote + localGit.push().setRemote("origin").call(); + } + + @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()) + .call(); + Path remoteFile = remoteWork.resolve("library.bib"); + commitFile(remoteClone, remoteUpdatedContent, "Remote update"); + remoteClone.push().setRemote("origin").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()) + .call(); + Path remoteFile = remoteWork.resolve("library.bib"); + commitFile(remoteClone, remoteUpdatedContent, "Remote update"); + remoteClone.push().setRemote("origin").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(); + } +} From 69575b549584111b64ac3fbc82100244a532cd5c Mon Sep 17 00:00:00 2001 From: Wanling Fu Date: Sun, 13 Jul 2025 01:21:25 +0100 Subject: [PATCH 07/37] chore(git): Fix variable names + refactoring (moving the GitConflictResolver interface to the logic module) #12350 --- .../org/jabref/gui/LibraryTabContainer.java | 2 +- .../gui/autosaveandbackup/BackupManager.java | 2 +- .../java/org/jabref/gui/desktop/os/Linux.java | 4 +- .../gui/exporter/SaveDatabaseAction.java | 6 +- ...og.java => GitConflictResolverDialog.java} | 5 +- .../org/jabref/gui/git/GitPullAction.java | 2 +- .../org/jabref/gui/git/GitPullViewModel.java | 39 ++++---- .../gui/openoffice/OOBibBaseConnect.java | 2 +- .../shared/SharedDatabaseLoginDialogView.java | 2 +- .../bibtex/comparator/EntryComparator.java | 2 +- .../bibtex/comparator/FieldComparator.java | 2 +- .../SearchCitationsRelationsService.java | 2 +- .../exporter/AtomicFileOutputStream.java | 4 +- .../org/jabref/logic/git/GitSyncService.java | 24 +++-- .../git/conflicts}/GitConflictResolver.java | 3 +- .../fetcher/MergingIdBasedFetcher.java | 2 +- .../fileformat/pdf/PdfContentImporter.java | 2 +- .../GrobidPlainCitationParser.java | 2 +- .../logic/remote/client/RemoteClient.java | 4 +- .../org/jabref/logic/util/BackgroundTask.java | 2 +- .../logic/util/ExternalLinkCreator.java | 2 +- .../jabref/logic/git/GitSyncServiceTest.java | 93 ++++++++++++++++++- 22 files changed, 153 insertions(+), 55 deletions(-) rename jabgui/src/main/java/org/jabref/gui/git/{GitConflictResolverViaDialog.java => GitConflictResolverDialog.java} (88%) rename {jabgui/src/main/java/org/jabref/gui/git => jablib/src/main/java/org/jabref/logic/git/conflicts}/GitConflictResolver.java (67%) diff --git a/jabgui/src/main/java/org/jabref/gui/LibraryTabContainer.java b/jabgui/src/main/java/org/jabref/gui/LibraryTabContainer.java index 4ed838c9f00..b572d190e6d 100644 --- a/jabgui/src/main/java/org/jabref/gui/LibraryTabContainer.java +++ b/jabgui/src/main/java/org/jabref/gui/LibraryTabContainer.java @@ -27,7 +27,7 @@ public interface LibraryTabContainer { * Closes a designated libraryTab * * @param tab to be closed. - * @return true if closing the tab was isSuccessful + * @return true if closing the tab was successful */ boolean closeTab(@Nullable LibraryTab tab); diff --git a/jabgui/src/main/java/org/jabref/gui/autosaveandbackup/BackupManager.java b/jabgui/src/main/java/org/jabref/gui/autosaveandbackup/BackupManager.java index efed58ae162..2ccf1492940 100644 --- a/jabgui/src/main/java/org/jabref/gui/autosaveandbackup/BackupManager.java +++ b/jabgui/src/main/java/org/jabref/gui/autosaveandbackup/BackupManager.java @@ -275,7 +275,7 @@ void performBackup(Path backupPath) { BibDatabaseContext bibDatabaseContextClone = new BibDatabaseContext(bibDatabaseClone, bibDatabaseContext.getMetaData()); Charset encoding = bibDatabaseContext.getMetaData().getEncoding().orElse(StandardCharsets.UTF_8); - // We want to have isSuccessful backups only + // We want to have successful backups only // Thus, we do not use a plain "FileWriter", but the "AtomicFileWriter" // Example: What happens if one hard powers off the machine (or kills the jabref process) during writing of the backup? // This MUST NOT create a broken backup file that then jabref wants to "restore" from? diff --git a/jabgui/src/main/java/org/jabref/gui/desktop/os/Linux.java b/jabgui/src/main/java/org/jabref/gui/desktop/os/Linux.java index 66f313ab1db..aae1d826d3e 100644 --- a/jabgui/src/main/java/org/jabref/gui/desktop/os/Linux.java +++ b/jabgui/src/main/java/org/jabref/gui/desktop/os/Linux.java @@ -47,10 +47,10 @@ private void nativeOpenFile(String filePath) { String[] cmd = {"xdg-open", filePath}; Runtime.getRuntime().exec(cmd); } catch (Exception e2) { - LoggerFactory.getLogger(Linux.class).warn("Open operation not isSuccessful: ", e2); + LoggerFactory.getLogger(Linux.class).warn("Open operation not successful: ", e2); } } catch (IOException e) { - LoggerFactory.getLogger(Linux.class).warn("Native open operation not isSuccessful: ", e); + LoggerFactory.getLogger(Linux.class).warn("Native open operation not successful: ", e); } }); } diff --git a/jabgui/src/main/java/org/jabref/gui/exporter/SaveDatabaseAction.java b/jabgui/src/main/java/org/jabref/gui/exporter/SaveDatabaseAction.java index ed2790b9cba..bfb39ee2e45 100644 --- a/jabgui/src/main/java/org/jabref/gui/exporter/SaveDatabaseAction.java +++ b/jabgui/src/main/java/org/jabref/gui/exporter/SaveDatabaseAction.java @@ -52,7 +52,7 @@ * when closing a database or quitting the applications. *

* The save operation is loaded off of the GUI thread using {@link BackgroundTask}. Callers can query whether the - * operation was canceled, or whether it was isSuccessful. + * operation was canceled, or whether it was successful. */ public class SaveDatabaseAction { private static final Logger LOGGER = LoggerFactory.getLogger(SaveDatabaseAction.class); @@ -134,8 +134,8 @@ public void saveSelectedAsPlain() { /** * @param file the new file name to save the database to. This is stored in the database context of the panel upon - * isSuccessful save. - * @return true on isSuccessful save + * successful save. + * @return true on successful save */ boolean saveAs(Path file, SaveDatabaseMode mode) { BibDatabaseContext context = libraryTab.getBibDatabaseContext(); diff --git a/jabgui/src/main/java/org/jabref/gui/git/GitConflictResolverViaDialog.java b/jabgui/src/main/java/org/jabref/gui/git/GitConflictResolverDialog.java similarity index 88% rename from jabgui/src/main/java/org/jabref/gui/git/GitConflictResolverViaDialog.java rename to jabgui/src/main/java/org/jabref/gui/git/GitConflictResolverDialog.java index bc2a315c357..0df944c5d44 100644 --- a/jabgui/src/main/java/org/jabref/gui/git/GitConflictResolverViaDialog.java +++ b/jabgui/src/main/java/org/jabref/gui/git/GitConflictResolverDialog.java @@ -8,6 +8,7 @@ import org.jabref.gui.mergeentries.newmergedialog.diffhighlighter.DiffHighlighter; import org.jabref.gui.mergeentries.newmergedialog.toolbar.ThreeWayMergeToolbar; import org.jabref.gui.preferences.GuiPreferences; +import org.jabref.logic.git.conflicts.GitConflictResolver; import org.jabref.logic.git.conflicts.ThreeWayEntryConflict; import org.jabref.model.entry.BibEntry; @@ -15,11 +16,11 @@ * UI wrapper * Receives a semantic conflict (ThreeWayEntryConflict), pops up an interactive GUI (belonging to mergeentries), and returns a user-confirmed BibEntry merge result. */ -public class GitConflictResolverViaDialog implements GitConflictResolver { +public class GitConflictResolverDialog implements GitConflictResolver { private final DialogService dialogService; private final GuiPreferences preferences; - public GitConflictResolverViaDialog(DialogService dialogService, GuiPreferences preferences) { + public GitConflictResolverDialog(DialogService dialogService, GuiPreferences preferences) { this.dialogService = dialogService; this.preferences = preferences; } 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 5701734a234..c317adf910d 100644 --- a/jabgui/src/main/java/org/jabref/gui/git/GitPullAction.java +++ b/jabgui/src/main/java/org/jabref/gui/git/GitPullAction.java @@ -55,7 +55,7 @@ public void execute() { try { GitPullViewModel viewModel = new GitPullViewModel( guiPreferences.getImportFormatPreferences(), - new GitConflictResolverViaDialog(dialogService, guiPreferences), + new GitConflictResolverDialog(dialogService, guiPreferences), dialogService ); MergeResult result = viewModel.pull(bibFilePath); diff --git a/jabgui/src/main/java/org/jabref/gui/git/GitPullViewModel.java b/jabgui/src/main/java/org/jabref/gui/git/GitPullViewModel.java index 9687e49945d..ec47a7f0dff 100644 --- a/jabgui/src/main/java/org/jabref/gui/git/GitPullViewModel.java +++ b/jabgui/src/main/java/org/jabref/gui/git/GitPullViewModel.java @@ -9,7 +9,9 @@ import org.jabref.gui.AbstractViewModel; import org.jabref.gui.DialogService; import org.jabref.logic.JabRefException; +import org.jabref.logic.git.GitConflictResolver; import org.jabref.logic.git.GitHandler; +import org.jabref.logic.git.conflicts.GitConflictResolver; import org.jabref.logic.git.conflicts.SemanticConflictDetector; import org.jabref.logic.git.conflicts.ThreeWayEntryConflict; import org.jabref.logic.git.io.GitBibParser; @@ -29,31 +31,30 @@ import org.eclipse.jgit.api.errors.GitAPIException; import org.eclipse.jgit.revwalk.RevCommit; -/** - * ViewModel responsible for coordinating UI-bound Git Pull workflow, - * including conflict resolution. - */ public class GitPullViewModel extends AbstractViewModel { private final ImportFormatPreferences importFormatPreferences; private final GitConflictResolver conflictResolver; private final DialogService dialogService; + private final GitHandler gitHandler; + private final GitStatusViewModel gitStatusViewModel; + private final Path bibFilePath; public GitPullViewModel(ImportFormatPreferences importFormatPreferences, - GitConflictResolver conflictResolver, - DialogService dialogService) { + GitConflictResolver conflictResolver, + DialogService dialogService, + GitHandler gitHandler, + GitStatusViewModel gitStatusViewModel) { this.importFormatPreferences = importFormatPreferences; this.conflictResolver = conflictResolver; this.dialogService = dialogService; + this.gitHandler = gitHandler; + this.gitStatusViewModel = gitStatusViewModel; + this.bibFilePath = gitStatusViewModel.getCurrentBibFile(); } - public MergeResult pull(Path bibFilePath) throws IOException, GitAPIException, JabRefException { + public MergeResult pull() throws IOException, GitAPIException, JabRefException { // Open the Git repository from the parent folder of the .bib file - Git git = Git.open(bibFilePath.getParent().toFile()); - - // Fetch latest changes from remote - // TODO: Temporary — GitHandler should be injected from GitStatusViewModel once centralized git status is implemented. - GitHandler gitHandler = GitHandler.fromAnyPath(bibFilePath) - .orElseThrow(() -> new IllegalStateException("Not inside a Git repository")); + Git git = Git.open(gitHandler.getRepositoryPathAsFile()); gitHandler.fetchOnCurrentBranch(); @@ -66,12 +67,12 @@ public MergeResult pull(Path bibFilePath) throws IOException, GitAPIException, J RevCommit remoteCommit = triple.remote(); // Ensure file is inside the Git working tree - Path bibPath = bibFilePath.toRealPath(); - Path workTree = git.getRepository().getWorkTree().toPath().toRealPath(); - if (!bibPath.startsWith(workTree)) { - throw new IllegalStateException("Given .bib file is not inside repository"); + Path repoRoot = gitHandler.getRepositoryPathAsFile().toPath().toRealPath(); + Path resolvedBibPath = bibFilePath.toRealPath(); + if (!resolvedBibPath.startsWith(repoRoot)) { + throw new JabRefException("The provided .bib file is not inside the Git repository."); } - Path relativePath = workTree.relativize(bibPath); + Path relativePath = repoRoot.relativize(resolvedBibPath); // 1. Load three versions String baseContent = GitFileReader.readFileFromCommit(git, baseCommit, relativePath); @@ -112,6 +113,8 @@ public MergeResult pull(Path bibFilePath) throws IOException, GitAPIException, J // Create Git commit for the merged result gitHandler.createCommitOnCurrentBranch("Auto-merged by JabRef", true); + + gitStatusViewModel.updateStatusFromPath(bibFilePath); return MergeResult.success(); } } diff --git a/jabgui/src/main/java/org/jabref/gui/openoffice/OOBibBaseConnect.java b/jabgui/src/main/java/org/jabref/gui/openoffice/OOBibBaseConnect.java index b2e55c9e8b0..333c9ed3d49 100644 --- a/jabgui/src/main/java/org/jabref/gui/openoffice/OOBibBaseConnect.java +++ b/jabgui/src/main/java/org/jabref/gui/openoffice/OOBibBaseConnect.java @@ -173,7 +173,7 @@ public String toString() { *

* If there is a single document to choose from, selects that. If there are more than one, shows selection dialog. If there are none, throws NoDocumentFoundException *

- * After isSuccessful selection connects to the selected document and extracts some frequently used parts (starting points for managing its content). + * After successful selection connects to the selected document and extracts some frequently used parts (starting points for managing its content). *

* Finally initializes this.xTextDocument with the selected document and parts extracted. */ diff --git a/jabgui/src/main/java/org/jabref/gui/shared/SharedDatabaseLoginDialogView.java b/jabgui/src/main/java/org/jabref/gui/shared/SharedDatabaseLoginDialogView.java index dc6903bf8f5..ed30a868a2b 100644 --- a/jabgui/src/main/java/org/jabref/gui/shared/SharedDatabaseLoginDialogView.java +++ b/jabgui/src/main/java/org/jabref/gui/shared/SharedDatabaseLoginDialogView.java @@ -34,7 +34,7 @@ /** * This offers the user to connect to a remove SQL database. - * Moreover, it directly opens the shared database after isSuccessful connection. + * Moreover, it directly opens the shared database after successful connection. */ public class SharedDatabaseLoginDialogView extends BaseDialog { @FXML private ComboBox databaseType; diff --git a/jablib/src/main/java/org/jabref/logic/bibtex/comparator/EntryComparator.java b/jablib/src/main/java/org/jabref/logic/bibtex/comparator/EntryComparator.java index 664b0ba9e30..f80bd483709 100644 --- a/jablib/src/main/java/org/jabref/logic/bibtex/comparator/EntryComparator.java +++ b/jablib/src/main/java/org/jabref/logic/bibtex/comparator/EntryComparator.java @@ -86,7 +86,7 @@ public int compare(BibEntry e1, BibEntry e2) { try { int i1 = Integer.parseInt((String) f1); int i2 = Integer.parseInt((String) f2); - // Ok, parsing was isSuccessful. Update f1 and f2: + // Ok, parsing was successful. Update f1 and f2: f1 = i1; f2 = i2; } catch (NumberFormatException ex) { diff --git a/jablib/src/main/java/org/jabref/logic/bibtex/comparator/FieldComparator.java b/jablib/src/main/java/org/jabref/logic/bibtex/comparator/FieldComparator.java index f2dbac25ceb..e1e2ff77782 100644 --- a/jablib/src/main/java/org/jabref/logic/bibtex/comparator/FieldComparator.java +++ b/jablib/src/main/java/org/jabref/logic/bibtex/comparator/FieldComparator.java @@ -154,7 +154,7 @@ public int compare(BibEntry e1, BibEntry e2) { } if (i1present && i2present) { - // Ok, parsing was isSuccessful. Update f1 and f2: + // Ok, parsing was successful. Update f1 and f2: return Integer.compare(i1, i2) * multiplier; } else if (i1present) { // The first one was parsable, but not the second one. diff --git a/jablib/src/main/java/org/jabref/logic/citation/SearchCitationsRelationsService.java b/jablib/src/main/java/org/jabref/logic/citation/SearchCitationsRelationsService.java index 499ff8f6cfd..95db8900c1b 100644 --- a/jablib/src/main/java/org/jabref/logic/citation/SearchCitationsRelationsService.java +++ b/jablib/src/main/java/org/jabref/logic/citation/SearchCitationsRelationsService.java @@ -66,7 +66,7 @@ public List searchReferences(BibEntry referenced) { /** * If the store was empty and nothing was fetch in any case (empty fetch, or error) then yes => empty list - * If the store was not empty and nothing was fetched after a isSuccessful fetch => the store will be erased and the returned collection will be empty + * If the store was not empty and nothing was fetched after a successful fetch => the store will be erased and the returned collection will be empty * If the store was not empty and an error occurs while fetching => will return the content of the store */ public List searchCitations(BibEntry cited) { diff --git a/jablib/src/main/java/org/jabref/logic/exporter/AtomicFileOutputStream.java b/jablib/src/main/java/org/jabref/logic/exporter/AtomicFileOutputStream.java index bfd163517bc..a52633c975a 100644 --- a/jablib/src/main/java/org/jabref/logic/exporter/AtomicFileOutputStream.java +++ b/jablib/src/main/java/org/jabref/logic/exporter/AtomicFileOutputStream.java @@ -76,7 +76,7 @@ public class AtomicFileOutputStream extends FilterOutputStream { * Creates a new output stream to write to or replace the file at the specified path. * * @param path the path of the file to write to or replace - * @param keepBackup whether to keep the backup file (.sav) after a isSuccessful write process + * @param keepBackup whether to keep the backup file (.sav) after a successful write process */ public AtomicFileOutputStream(Path path, boolean keepBackup) throws IOException { // Files.newOutputStream(getPathOfTemporaryFile(path)) leads to a "sun.nio.ch.ChannelOutputStream", which does not offer "lock" @@ -85,7 +85,7 @@ public AtomicFileOutputStream(Path path, boolean keepBackup) throws IOException /** * Creates a new output stream to write to or replace the file at the specified path. - * The backup file (.sav) is deleted when write was isSuccessful. + * The backup file (.sav) is deleted when write was successful. * * @param path the path of the file to write to or replace */ 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 b021a9e3541..272fa09a6dc 100644 --- a/jablib/src/main/java/org/jabref/logic/git/GitSyncService.java +++ b/jablib/src/main/java/org/jabref/logic/git/GitSyncService.java @@ -4,8 +4,10 @@ import java.nio.file.Path; import java.util.ArrayList; import java.util.List; +import java.util.Optional; import org.jabref.logic.JabRefException; +import org.jabref.logic.git.conflicts.GitConflictResolver; import org.jabref.logic.git.conflicts.SemanticConflictDetector; import org.jabref.logic.git.conflicts.ThreeWayEntryConflict; import org.jabref.logic.git.io.GitBibParser; @@ -13,6 +15,7 @@ import org.jabref.logic.git.io.GitFileWriter; import org.jabref.logic.git.io.GitRevisionLocator; import org.jabref.logic.git.io.RevisionTriple; +import org.jabref.logic.git.merge.GitMergeUtil; import org.jabref.logic.git.merge.MergePlan; import org.jabref.logic.git.merge.SemanticMerger; import org.jabref.logic.git.model.MergeResult; @@ -54,10 +57,12 @@ public class GitSyncService { private static final boolean AMEND = true; private final ImportFormatPreferences importFormatPreferences; private final GitHandler gitHandler; + private final GitConflictResolver gitConflictResolver; - public GitSyncService(ImportFormatPreferences importFormatPreferences, GitHandler gitHandler) { + public GitSyncService(ImportFormatPreferences importFormatPreferences, GitHandler gitHandler, GitConflictResolver gitConflictResolver) { this.importFormatPreferences = importFormatPreferences; this.gitHandler = gitHandler; + this.gitConflictResolver = gitConflictResolver; } /** @@ -134,14 +139,15 @@ public MergeResult performSemanticMerge(Git git, BibDatabaseContext effectiveRemote = remote; if (!conflicts.isEmpty()) { List resolvedRemoteEntries = new ArrayList<>(); - -// for (ThreeWayEntryConflict conflict : conflicts) { -// // Uses a GUI dialog to let the user merge entries interactively -// BibEntry resolvedEntry = this.conflictResolver.resolveConflict(conflict, prefs, dialogService); -// resolvedRemoteEntries.add(resolvedEntry); -// } -// // Replace conflicted entries in remote with user-resolved ones -// effectiveRemote = GitMergeUtil.replaceEntries(remote, resolvedRemoteEntries); + for (ThreeWayEntryConflict conflict : conflicts) { + Optional maybeResolved = gitConflictResolver.resolveConflict(conflict); + if (maybeResolved.isEmpty()) { + LOGGER.warn("User canceled conflict resolution."); + return MergeResult.failure(); + } + resolvedRemoteEntries.add(maybeResolved.get()); + } + effectiveRemote = GitMergeUtil.replaceEntries(remote, resolvedRemoteEntries); } // 4. Apply resolved remote (either original or conflict-resolved) to local diff --git a/jabgui/src/main/java/org/jabref/gui/git/GitConflictResolver.java b/jablib/src/main/java/org/jabref/logic/git/conflicts/GitConflictResolver.java similarity index 67% rename from jabgui/src/main/java/org/jabref/gui/git/GitConflictResolver.java rename to jablib/src/main/java/org/jabref/logic/git/conflicts/GitConflictResolver.java index c62ae0187ad..f93b8501165 100644 --- a/jabgui/src/main/java/org/jabref/gui/git/GitConflictResolver.java +++ b/jablib/src/main/java/org/jabref/logic/git/conflicts/GitConflictResolver.java @@ -1,8 +1,7 @@ -package org.jabref.gui.git; +package org.jabref.logic.git.conflicts; import java.util.Optional; -import org.jabref.logic.git.conflicts.ThreeWayEntryConflict; import org.jabref.model.entry.BibEntry; public interface GitConflictResolver { diff --git a/jablib/src/main/java/org/jabref/logic/importer/fetcher/MergingIdBasedFetcher.java b/jablib/src/main/java/org/jabref/logic/importer/fetcher/MergingIdBasedFetcher.java index d94c95fdf64..26af88a915f 100644 --- a/jablib/src/main/java/org/jabref/logic/importer/fetcher/MergingIdBasedFetcher.java +++ b/jablib/src/main/java/org/jabref/logic/importer/fetcher/MergingIdBasedFetcher.java @@ -19,7 +19,7 @@ /// Fetches and merges bibliographic information from external sources into existing BibEntry objects. /// Supports multiple identifier types (DOI, ISBN, Eprint) and attempts fetching in a defined order -/// until isSuccessful. +/// until successful. /// The merging only adds new fields from the fetched entry and does not modify existing fields /// in the library entry. public class MergingIdBasedFetcher { diff --git a/jablib/src/main/java/org/jabref/logic/importer/fileformat/pdf/PdfContentImporter.java b/jablib/src/main/java/org/jabref/logic/importer/fileformat/pdf/PdfContentImporter.java index 50b7aa77512..d848c3725d6 100644 --- a/jablib/src/main/java/org/jabref/logic/importer/fileformat/pdf/PdfContentImporter.java +++ b/jablib/src/main/java/org/jabref/logic/importer/fileformat/pdf/PdfContentImporter.java @@ -318,7 +318,7 @@ private boolean isThereSpace(TextPosition previous, TextPosition current) { * @param titleByFontSize An optional title string determined by font size; if provided, this overrides the * default title parsing. * @return An {@link Optional} containing a {@link BibEntry} with the parsed bibliographic data if extraction - * is isSuccessful. Otherwise, an empty {@link Optional}. + * is successful. Otherwise, an empty {@link Optional}. */ @VisibleForTesting Optional getEntryFromPDFContent(String firstpageContents, String lineSeparator, Optional titleByFontSize) { diff --git a/jablib/src/main/java/org/jabref/logic/importer/plaincitation/GrobidPlainCitationParser.java b/jablib/src/main/java/org/jabref/logic/importer/plaincitation/GrobidPlainCitationParser.java index ae126b32bbd..34f1db1ec26 100644 --- a/jablib/src/main/java/org/jabref/logic/importer/plaincitation/GrobidPlainCitationParser.java +++ b/jablib/src/main/java/org/jabref/logic/importer/plaincitation/GrobidPlainCitationParser.java @@ -35,7 +35,7 @@ public GrobidPlainCitationParser(GrobidPreferences grobidPreferences, ImportForm * Passes request to grobid server, using consolidateCitations option to improve result. Takes a while, since the * server has to look up the entry. * - * @return A BibTeX string if extraction is isSuccessful + * @return A BibTeX string if extraction is successful */ @Override public Optional parsePlainCitation(String text) throws FetcherException { diff --git a/jablib/src/main/java/org/jabref/logic/remote/client/RemoteClient.java b/jablib/src/main/java/org/jabref/logic/remote/client/RemoteClient.java index c0211451413..38d1047d224 100644 --- a/jablib/src/main/java/org/jabref/logic/remote/client/RemoteClient.java +++ b/jablib/src/main/java/org/jabref/logic/remote/client/RemoteClient.java @@ -50,7 +50,7 @@ public boolean ping() { * Attempt to send command line arguments to already running JabRef instance. * * @param args command line arguments. - * @return true if isSuccessful, false otherwise. + * @return true if successful, false otherwise. */ public boolean sendCommandLineArguments(String[] args) { try (Protocol protocol = openNewConnection()) { @@ -66,7 +66,7 @@ public boolean sendCommandLineArguments(String[] args) { /** * Attempt to send a focus command to already running JabRef instance. * - * @return true if isSuccessful, false otherwise. + * @return true if successful, false otherwise. */ public boolean sendFocus() { try (Protocol protocol = openNewConnection()) { diff --git a/jablib/src/main/java/org/jabref/logic/util/BackgroundTask.java b/jablib/src/main/java/org/jabref/logic/util/BackgroundTask.java index e20dd3b9775..0a576c74659 100644 --- a/jablib/src/main/java/org/jabref/logic/util/BackgroundTask.java +++ b/jablib/src/main/java/org/jabref/logic/util/BackgroundTask.java @@ -215,7 +215,7 @@ public Future scheduleWith(TaskExecutor taskExecutor, long delay, TimeUnit un } /** - * Sets the {@link Runnable} that is invoked after the task is finished, irrespectively if it was isSuccessful or + * Sets the {@link Runnable} that is invoked after the task is finished, irrespectively if it was successful or * failed with an error. */ public BackgroundTask onFinished(Runnable onFinished) { diff --git a/jablib/src/main/java/org/jabref/logic/util/ExternalLinkCreator.java b/jablib/src/main/java/org/jabref/logic/util/ExternalLinkCreator.java index f5562d5f89a..ed9a6a0362b 100644 --- a/jablib/src/main/java/org/jabref/logic/util/ExternalLinkCreator.java +++ b/jablib/src/main/java/org/jabref/logic/util/ExternalLinkCreator.java @@ -14,7 +14,7 @@ public class ExternalLinkCreator { /** * Get a URL to the search results of ShortScience for the BibEntry's title * - * @param entry The entry to search for. Expects the BibEntry's title to be set for isSuccessful return. + * @param entry The entry to search for. Expects the BibEntry's title to be set for successful return. * @return The URL if it was successfully created */ public static Optional getShortScienceSearchURL(BibEntry entry) { 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 887c8880e26..6b93483dd06 100644 --- a/jablib/src/test/java/org/jabref/logic/git/GitSyncServiceTest.java +++ b/jablib/src/test/java/org/jabref/logic/git/GitSyncServiceTest.java @@ -4,10 +4,15 @@ import java.nio.file.Files; import java.nio.file.Path; import java.util.List; +import java.util.Optional; +import org.jabref.logic.git.conflicts.GitConflictResolver; +import org.jabref.logic.git.conflicts.ThreeWayEntryConflict; import org.jabref.logic.git.io.GitFileReader; import org.jabref.logic.git.model.MergeResult; import org.jabref.logic.importer.ImportFormatPreferences; +import org.jabref.model.entry.BibEntry; +import org.jabref.model.entry.field.StandardField; import org.eclipse.jgit.api.Git; import org.eclipse.jgit.lib.PersonIdent; @@ -19,13 +24,16 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; class GitSyncServiceTest { private Git git; private Path library; private ImportFormatPreferences importFormatPreferences; + private GitConflictResolver gitConflictResolver; // These are setup by alieBobSetting private RevCommit baseCommit; @@ -83,6 +91,7 @@ class GitSyncServiceTest { void aliceBobSimple(@TempDir Path tempDir) throws Exception { importFormatPreferences = mock(ImportFormatPreferences.class, Answers.RETURNS_DEEP_STUBS); when(importFormatPreferences.bibEntryPreferences().getKeywordSeparator()).thenReturn(','); + gitConflictResolver = mock(GitConflictResolver.class); // create fake remote repo Path remoteDir = tempDir.resolve("remote.git"); @@ -125,7 +134,7 @@ void aliceBobSimple(@TempDir Path tempDir) throws Exception { @Test void pullTriggersSemanticMergeWhenNoConflicts() throws Exception { GitHandler gitHandler = new GitHandler(library.getParent()); - GitSyncService syncService = new GitSyncService(importFormatPreferences, gitHandler); + GitSyncService syncService = new GitSyncService(importFormatPreferences, gitHandler, gitConflictResolver); MergeResult result = syncService.fetchAndMerge(library); assertTrue(result.isSuccessful()); @@ -149,7 +158,7 @@ void pullTriggersSemanticMergeWhenNoConflicts() throws Exception { @Test void pushTriggersMergeAndPushWhenNoConflicts() throws Exception { GitHandler gitHandler = new GitHandler(library.getParent()); - GitSyncService syncService = new GitSyncService(importFormatPreferences, gitHandler); + GitSyncService syncService = new GitSyncService(importFormatPreferences, gitHandler, gitConflictResolver); syncService.push(library); String pushedContent = GitFileReader.readFileFromCommit(git, git.log().setMaxCount(1).call().iterator().next(), Path.of("library.bib")); @@ -168,6 +177,86 @@ void pushTriggersMergeAndPushWhenNoConflicts() throws Exception { assertEquals(normalize(expected), normalize(pushedContent)); } + @Test + void mergeConflictOnSameFieldTriggersDialogAndUsesUserResolution(@TempDir Path tempDir) throws Exception { + // === Setup remote bare repo === + Path remoteDir = tempDir.resolve("remote.git"); + Git remoteGit = Git.init().setBare(true).setDirectory(remoteDir.toFile()).call(); + + // === Clone to local working directory === + Path localDir = tempDir.resolve("local"); + Git localGit = Git.cloneRepository() + .setURI(remoteDir.toUri().toString()) + .setDirectory(localDir.toFile()) + .setBranch("main") + .call(); + Path bibFile = localDir.resolve("library.bib"); + + PersonIdent user = new PersonIdent("User", "user@example.com"); + + String baseContent = """ + @article{a, + author = {unknown}, + doi = {xya}, + } + """; + + writeAndCommit(baseContent, "Initial commit", user, bibFile, localGit); + localGit.push().setRemote("origin").call(); + + // === Clone again to simulate "remote user" making conflicting change === + Path remoteUserDir = tempDir.resolve("remoteUser"); + Git remoteUserGit = Git.cloneRepository() + .setURI(remoteDir.toUri().toString()) + .setDirectory(remoteUserDir.toFile()) + .setBranch("main") + .call(); + Path remoteUserFile = remoteUserDir.resolve("library.bib"); + + String remoteContent = """ + @article{a, + author = {remote-author}, + doi = {xya}, + } + """; + + writeAndCommit(remoteContent, "Remote change", user, remoteUserFile, remoteUserGit); + remoteUserGit.push().setRemote("origin").call(); + + // === Back to local, make conflicting change === + String localContent = """ + @article{a, + author = {local-author}, + doi = {xya}, + } + """; + writeAndCommit(localContent, "Local change", user, bibFile, localGit); + localGit.fetch().setRemote("origin").call(); + + // === Setup GitSyncService === + GitConflictResolver resolver = mock(GitConflictResolver.class); + when(resolver.resolveConflict(any())).thenAnswer(invocation -> { + ThreeWayEntryConflict conflict = invocation.getArgument(0); + BibEntry merged = (BibEntry) conflict.base().clone(); + merged.setField(StandardField.AUTHOR, "merged-author"); + return Optional.of(merged); + }); + + GitHandler handler = new GitHandler(localDir); + ImportFormatPreferences prefs = mock(ImportFormatPreferences.class, Answers.RETURNS_DEEP_STUBS); + when(prefs.bibEntryPreferences().getKeywordSeparator()).thenReturn(','); + + GitSyncService service = new GitSyncService(prefs, handler, resolver); + + // === Trigger semantic merge === + MergeResult result = service.fetchAndMerge(bibFile); + + assertTrue(result.isSuccessful()); + String finalContent = Files.readString(bibFile); + assertTrue(finalContent.contains("merged-author")); + verify(resolver).resolveConflict(any()); + } + @Test void readFromCommits() throws Exception { String base = GitFileReader.readFileFromCommit(git, baseCommit, Path.of("library.bib")); From 3fc49c3844a5988dfc2f9261bd6187f85a087350 Mon Sep 17 00:00:00 2001 From: Wanling Fu Date: Sun, 13 Jul 2025 23:23:46 +0100 Subject: [PATCH 08/37] refactor(git): Apply strategy pattern for conflict resolution + add GitMergeUtil tests #12350 --- .../gui/git/GitConflictResolverDialog.java | 4 +- .../org/jabref/gui/git/GitPullAction.java | 18 ++- .../org/jabref/gui/git/GitPullViewModel.java | 103 ++------------ .../jabref/gui/git/GitStatusViewModel.java | 130 ++++++++++++++++++ .../gui/git/GuiConflictResolverStrategy.java | 32 +++++ jablib/src/main/java/module-info.java | 1 + .../org/jabref/logic/git/GitSyncService.java | 42 ++---- .../CliConflictResolverStrategy.java | 14 ++ .../git/conflicts/GitConflictResolver.java | 9 -- .../GitConflictResolverStrategy.java | 18 +++ .../jabref/logic/git/merge/GitMergeUtil.java | 3 +- .../jabref/logic/git/GitSyncServiceTest.java | 41 +++--- .../logic/git/merge/GitMergeUtilTest.java | 62 +++++++++ 13 files changed, 313 insertions(+), 164 deletions(-) create mode 100644 jabgui/src/main/java/org/jabref/gui/git/GitStatusViewModel.java create mode 100644 jabgui/src/main/java/org/jabref/gui/git/GuiConflictResolverStrategy.java create mode 100644 jablib/src/main/java/org/jabref/logic/git/conflicts/CliConflictResolverStrategy.java delete mode 100644 jablib/src/main/java/org/jabref/logic/git/conflicts/GitConflictResolver.java create mode 100644 jablib/src/main/java/org/jabref/logic/git/conflicts/GitConflictResolverStrategy.java create mode 100644 jablib/src/test/java/org/jabref/logic/git/merge/GitMergeUtilTest.java diff --git a/jabgui/src/main/java/org/jabref/gui/git/GitConflictResolverDialog.java b/jabgui/src/main/java/org/jabref/gui/git/GitConflictResolverDialog.java index 0df944c5d44..485f9076afc 100644 --- a/jabgui/src/main/java/org/jabref/gui/git/GitConflictResolverDialog.java +++ b/jabgui/src/main/java/org/jabref/gui/git/GitConflictResolverDialog.java @@ -8,7 +8,6 @@ import org.jabref.gui.mergeentries.newmergedialog.diffhighlighter.DiffHighlighter; import org.jabref.gui.mergeentries.newmergedialog.toolbar.ThreeWayMergeToolbar; import org.jabref.gui.preferences.GuiPreferences; -import org.jabref.logic.git.conflicts.GitConflictResolver; import org.jabref.logic.git.conflicts.ThreeWayEntryConflict; import org.jabref.model.entry.BibEntry; @@ -16,7 +15,7 @@ * UI wrapper * Receives a semantic conflict (ThreeWayEntryConflict), pops up an interactive GUI (belonging to mergeentries), and returns a user-confirmed BibEntry merge result. */ -public class GitConflictResolverDialog implements GitConflictResolver { +public class GitConflictResolverDialog { private final DialogService dialogService; private final GuiPreferences preferences; @@ -25,7 +24,6 @@ public GitConflictResolverDialog(DialogService dialogService, GuiPreferences pre this.preferences = preferences; } - @Override public Optional resolveConflict(ThreeWayEntryConflict conflict) { BibEntry base = conflict.base(); BibEntry local = conflict.local(); 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 c317adf910d..17d110ac4a9 100644 --- a/jabgui/src/main/java/org/jabref/gui/git/GitPullAction.java +++ b/jabgui/src/main/java/org/jabref/gui/git/GitPullAction.java @@ -10,6 +10,9 @@ import org.jabref.gui.actions.SimpleCommand; import org.jabref.gui.preferences.GuiPreferences; import org.jabref.logic.JabRefException; +import org.jabref.logic.git.GitHandler; +import org.jabref.logic.git.GitSyncService; +import org.jabref.logic.git.conflicts.GitConflictResolverStrategy; import org.jabref.logic.git.model.MergeResult; import org.jabref.model.database.BibDatabaseContext; @@ -53,12 +56,15 @@ public void execute() { Path bibFilePath = database.getDatabasePath().get(); try { - GitPullViewModel viewModel = new GitPullViewModel( - guiPreferences.getImportFormatPreferences(), - new GitConflictResolverDialog(dialogService, guiPreferences), - dialogService - ); - MergeResult result = viewModel.pull(bibFilePath); + GitHandler handler = new GitHandler(bibFilePath.getParent()); + GitConflictResolverDialog dialog = new GitConflictResolverDialog(dialogService, guiPreferences); + GitConflictResolverStrategy resolver = new GuiConflictResolverStrategy(dialog); + + GitSyncService syncService = new GitSyncService(guiPreferences.getImportFormatPreferences(), handler, resolver); + GitStatusViewModel statusViewModel = new GitStatusViewModel(bibFilePath); + + GitPullViewModel viewModel = new GitPullViewModel(syncService, statusViewModel); + MergeResult result = viewModel.pull(); if (result.isSuccessful()) { dialogService.showInformationDialogAndWait("Git Pull", "Successfully merged and updated."); diff --git a/jabgui/src/main/java/org/jabref/gui/git/GitPullViewModel.java b/jabgui/src/main/java/org/jabref/gui/git/GitPullViewModel.java index ec47a7f0dff..ddaf0e9e3cc 100644 --- a/jabgui/src/main/java/org/jabref/gui/git/GitPullViewModel.java +++ b/jabgui/src/main/java/org/jabref/gui/git/GitPullViewModel.java @@ -2,119 +2,32 @@ import java.io.IOException; import java.nio.file.Path; -import java.util.ArrayList; -import java.util.List; -import java.util.Optional; import org.jabref.gui.AbstractViewModel; -import org.jabref.gui.DialogService; import org.jabref.logic.JabRefException; -import org.jabref.logic.git.GitConflictResolver; -import org.jabref.logic.git.GitHandler; -import org.jabref.logic.git.conflicts.GitConflictResolver; -import org.jabref.logic.git.conflicts.SemanticConflictDetector; -import org.jabref.logic.git.conflicts.ThreeWayEntryConflict; -import org.jabref.logic.git.io.GitBibParser; -import org.jabref.logic.git.io.GitFileReader; -import org.jabref.logic.git.io.GitFileWriter; -import org.jabref.logic.git.io.GitRevisionLocator; -import org.jabref.logic.git.io.RevisionTriple; -import org.jabref.logic.git.merge.GitMergeUtil; -import org.jabref.logic.git.merge.MergePlan; -import org.jabref.logic.git.merge.SemanticMerger; +import org.jabref.logic.git.GitSyncService; 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.eclipse.jgit.api.Git; import org.eclipse.jgit.api.errors.GitAPIException; -import org.eclipse.jgit.revwalk.RevCommit; public class GitPullViewModel extends AbstractViewModel { - private final ImportFormatPreferences importFormatPreferences; - private final GitConflictResolver conflictResolver; - private final DialogService dialogService; - private final GitHandler gitHandler; + private final GitSyncService syncService; private final GitStatusViewModel gitStatusViewModel; private final Path bibFilePath; - public GitPullViewModel(ImportFormatPreferences importFormatPreferences, - GitConflictResolver conflictResolver, - DialogService dialogService, - GitHandler gitHandler, - GitStatusViewModel gitStatusViewModel) { - this.importFormatPreferences = importFormatPreferences; - this.conflictResolver = conflictResolver; - this.dialogService = dialogService; - this.gitHandler = gitHandler; + public GitPullViewModel(GitSyncService syncService, GitStatusViewModel gitStatusViewModel) { + this.syncService = syncService; this.gitStatusViewModel = gitStatusViewModel; this.bibFilePath = gitStatusViewModel.getCurrentBibFile(); } public MergeResult pull() throws IOException, GitAPIException, JabRefException { - // Open the Git repository from the parent folder of the .bib file - Git git = Git.open(gitHandler.getRepositoryPathAsFile()); + MergeResult result = syncService.fetchAndMerge(bibFilePath); - gitHandler.fetchOnCurrentBranch(); - - // Determine the three-way merge base, local, and remote commits - GitRevisionLocator locator = new GitRevisionLocator(); - RevisionTriple triple = locator.locateMergeCommits(git); - - RevCommit baseCommit = triple.base(); - RevCommit localCommit = triple.local(); - RevCommit remoteCommit = triple.remote(); - - // Ensure file is inside the Git working tree - Path repoRoot = gitHandler.getRepositoryPathAsFile().toPath().toRealPath(); - Path resolvedBibPath = bibFilePath.toRealPath(); - if (!resolvedBibPath.startsWith(repoRoot)) { - throw new JabRefException("The provided .bib file is not inside the Git repository."); + if (result.isSuccessful()) { + gitStatusViewModel.updateStatusFromPath(bibFilePath); } - Path relativePath = repoRoot.relativize(resolvedBibPath); - - // 1. Load three versions - String baseContent = GitFileReader.readFileFromCommit(git, baseCommit, relativePath); - String localContent = GitFileReader.readFileFromCommit(git, localCommit, relativePath); - String remoteContent = GitFileReader.readFileFromCommit(git, remoteCommit, relativePath); - - BibDatabaseContext base = GitBibParser.parseBibFromGit(baseContent, importFormatPreferences); - BibDatabaseContext local = GitBibParser.parseBibFromGit(localContent, importFormatPreferences); - BibDatabaseContext remote = GitBibParser.parseBibFromGit(remoteContent, importFormatPreferences); - - // 2. Conflict detection - List conflicts = SemanticConflictDetector.detectConflicts(base, local, remote); - - // 3. If there are conflicts, prompt user to resolve them via GUI - BibDatabaseContext effectiveRemote = remote; - if (!conflicts.isEmpty()) { - List resolvedRemoteEntries = new ArrayList<>(); - for (ThreeWayEntryConflict conflict : conflicts) { - // Ask user to resolve this conflict via GUI dialog - Optional maybeResolved = conflictResolver.resolveConflict(conflict); - if (maybeResolved.isPresent()) { - resolvedRemoteEntries.add(maybeResolved.get()); - } else { - // User canceled the merge dialog → abort the whole merge - throw new JabRefException("Merge aborted: Not all conflicts were resolved by user."); - } - } - // Replace original conflicting entries in remote with resolved versions - effectiveRemote = GitMergeUtil.replaceEntries(remote, resolvedRemoteEntries); - } - - // Extract merge plan and apply it to the local database - MergePlan plan = SemanticConflictDetector.extractMergePlan(base, effectiveRemote); - SemanticMerger.applyMergePlan(local, plan); - - // Save merged result to .bib file - GitFileWriter.write(bibFilePath, local, importFormatPreferences); - - // Create Git commit for the merged result - gitHandler.createCommitOnCurrentBranch("Auto-merged by JabRef", true); - gitStatusViewModel.updateStatusFromPath(bibFilePath); - return MergeResult.success(); + 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..fda6e092d50 --- /dev/null +++ b/jabgui/src/main/java/org/jabref/gui/git/GitStatusViewModel.java @@ -0,0 +1,130 @@ +package org.jabref.gui.git; + +import java.nio.file.Path; +import java.util.Optional; + +import javafx.beans.property.BooleanProperty; +import javafx.beans.property.ObjectProperty; +import javafx.beans.property.SimpleBooleanProperty; +import javafx.beans.property.SimpleObjectProperty; +import javafx.beans.property.SimpleStringProperty; +import javafx.beans.property.StringProperty; + +import org.jabref.gui.AbstractViewModel; +import org.jabref.logic.git.GitHandler; +import org.jabref.logic.git.status.GitStatusChecker; +import org.jabref.logic.git.status.GitStatusSnapshot; +import org.jabref.logic.git.status.SyncStatus; + +/** + * ViewModel that holds current Git sync status for the open .bib database. + * 统一维护当前路径绑定的 GitHandler 状态,包括: + * - 是否是 Git 仓库 + * - 当前是否被 Git 跟踪 + * - 是否存在冲突 + * - 当前同步状态(UP_TO_DATE、DIVERGED 等) + */ +public class GitStatusViewModel extends AbstractViewModel { + private final Path currentBibFile; + private final ObjectProperty syncStatus = new SimpleObjectProperty<>(SyncStatus.UNTRACKED); + private final BooleanProperty isTracking = new SimpleBooleanProperty(false); + private final BooleanProperty conflictDetected = new SimpleBooleanProperty(false); + private final StringProperty lastPulledCommit = new SimpleStringProperty(""); + private GitHandler activeHandler = null; + + public GitStatusViewModel(Path bibFilePath) { + this.currentBibFile = bibFilePath; + updateStatusFromPath(bibFilePath); + } + + /** + * Try to detect Git repository status from the given file or folder path. + * + * @param fileOrFolderInRepo Any path (file or folder) assumed to be inside a Git repository + */ + public void updateStatusFromPath(Path fileOrFolderInRepo) { + Optional maybeHandler = GitHandler.fromAnyPath(fileOrFolderInRepo); + + if (!maybeHandler.isPresent()) { + reset(); + return; + } + + GitHandler handler = maybeHandler.get(); + this.activeHandler = handler; + + GitStatusSnapshot snapshot = GitStatusChecker.checkStatus(fileOrFolderInRepo); + + setTracking(snapshot.tracking()); + setSyncStatus(snapshot.syncStatus()); + setConflictDetected(snapshot.conflict()); + snapshot.lastPulledCommit().ifPresent(this::setLastPulledCommit); + } + + /** + * Clears all internal state to defaults. + * Should be called when switching projects or Git context is lost + */ + public void reset() { + setSyncStatus(SyncStatus.UNTRACKED); + setTracking(false); + setConflictDetected(false); + setLastPulledCommit(""); + } + + public ObjectProperty syncStatusProperty() { + return syncStatus; + } + + public SyncStatus getSyncStatus() { + return syncStatus.get(); + } + + public void setSyncStatus(SyncStatus status) { + this.syncStatus.set(status); + } + + public BooleanProperty isTrackingProperty() { + return isTracking; + } + + public boolean isTracking() { + return isTracking.get(); + } + + public void setTracking(boolean tracking) { + this.isTracking.set(tracking); + } + + public BooleanProperty conflictDetectedProperty() { + return conflictDetected; + } + + public boolean isConflictDetected() { + return conflictDetected.get(); + } + + public void setConflictDetected(boolean conflict) { + this.conflictDetected.set(conflict); + } + + public StringProperty lastPulledCommitProperty() { + return lastPulledCommit; + } + + public String getLastPulledCommit() { + return lastPulledCommit.get(); + } + + public void setLastPulledCommit(String commitHash) { + this.lastPulledCommit.set(commitHash); + } + + public Optional getActiveHandler() { + return Optional.ofNullable(activeHandler); + } + + public Path getCurrentBibFile() { + return currentBibFile; + } +} diff --git a/jabgui/src/main/java/org/jabref/gui/git/GuiConflictResolverStrategy.java b/jabgui/src/main/java/org/jabref/gui/git/GuiConflictResolverStrategy.java new file mode 100644 index 00000000000..ce3178ab0d6 --- /dev/null +++ b/jabgui/src/main/java/org/jabref/gui/git/GuiConflictResolverStrategy.java @@ -0,0 +1,32 @@ +package org.jabref.gui.git; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +import org.jabref.logic.git.conflicts.GitConflictResolverStrategy; +import org.jabref.logic.git.conflicts.ThreeWayEntryConflict; +import org.jabref.logic.git.merge.GitMergeUtil; +import org.jabref.model.database.BibDatabaseContext; +import org.jabref.model.entry.BibEntry; + +public class GuiConflictResolverStrategy implements GitConflictResolverStrategy { + private final GitConflictResolverDialog dialog; + + public GuiConflictResolverStrategy(GitConflictResolverDialog dialog) { + this.dialog = dialog; + } + + @Override + public Optional resolveConflicts(List conflicts, BibDatabaseContext remote) { + List resolved = new ArrayList<>(); + for (ThreeWayEntryConflict conflict : conflicts) { + Optional maybeConflict = dialog.resolveConflict(conflict); + if (maybeConflict.isEmpty()) { + return Optional.empty(); + } + resolved.add(maybeConflict.get()); + } + return Optional.of(GitMergeUtil.replaceEntries(remote, resolved)); + } +} diff --git a/jablib/src/main/java/module-info.java b/jablib/src/main/java/module-info.java index a777c9bbad1..af09654af97 100644 --- a/jablib/src/main/java/module-info.java +++ b/jablib/src/main/java/module-info.java @@ -110,6 +110,7 @@ exports org.jabref.logic.git.merge; exports org.jabref.logic.git.io; exports org.jabref.logic.git.model; + exports org.jabref.logic.git.status; requires java.base; diff --git a/jablib/src/main/java/org/jabref/logic/git/GitSyncService.java b/jablib/src/main/java/org/jabref/logic/git/GitSyncService.java index 272fa09a6dc..bf2cbb4220a 100644 --- a/jablib/src/main/java/org/jabref/logic/git/GitSyncService.java +++ b/jablib/src/main/java/org/jabref/logic/git/GitSyncService.java @@ -2,12 +2,11 @@ import java.io.IOException; import java.nio.file.Path; -import java.util.ArrayList; import java.util.List; import java.util.Optional; import org.jabref.logic.JabRefException; -import org.jabref.logic.git.conflicts.GitConflictResolver; +import org.jabref.logic.git.conflicts.GitConflictResolverStrategy; import org.jabref.logic.git.conflicts.SemanticConflictDetector; import org.jabref.logic.git.conflicts.ThreeWayEntryConflict; import org.jabref.logic.git.io.GitBibParser; @@ -15,7 +14,6 @@ import org.jabref.logic.git.io.GitFileWriter; import org.jabref.logic.git.io.GitRevisionLocator; import org.jabref.logic.git.io.RevisionTriple; -import org.jabref.logic.git.merge.GitMergeUtil; import org.jabref.logic.git.merge.MergePlan; import org.jabref.logic.git.merge.SemanticMerger; import org.jabref.logic.git.model.MergeResult; @@ -24,7 +22,6 @@ 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; @@ -38,18 +35,6 @@ * → UI merge; * else * → autoMerge := local + remoteDiff - * - * NOTICE: - * - TODO:This class will be **deprecated** in the near future to avoid architecture violation (logic → gui)! - * - The underlying business logic will not change significantly. - * - Only the coordination responsibilities will shift to GUI/ViewModel layer. - * - * PLAN: - * - All orchestration logic (pull/push, merge, resolve, commit) - * will be **moved into corresponding ViewModels**, such as: - * - GitPullViewModel - * - GitPushViewModel - * - GitStatusViewModel */ public class GitSyncService { private static final Logger LOGGER = LoggerFactory.getLogger(GitSyncService.class); @@ -57,12 +42,12 @@ public class GitSyncService { private static final boolean AMEND = true; private final ImportFormatPreferences importFormatPreferences; private final GitHandler gitHandler; - private final GitConflictResolver gitConflictResolver; + private final GitConflictResolverStrategy gitConflictResolverStrategy; - public GitSyncService(ImportFormatPreferences importFormatPreferences, GitHandler gitHandler, GitConflictResolver gitConflictResolver) { + public GitSyncService(ImportFormatPreferences importFormatPreferences, GitHandler gitHandler, GitConflictResolverStrategy gitConflictResolverStrategy) { this.importFormatPreferences = importFormatPreferences; this.gitHandler = gitHandler; - this.gitConflictResolver = gitConflictResolver; + this.gitConflictResolverStrategy = gitConflictResolverStrategy; } /** @@ -135,20 +120,13 @@ public MergeResult performSemanticMerge(Git git, // 2. Conflict detection List conflicts = SemanticConflictDetector.detectConflicts(base, local, remote); - // 3. If there are conflicts, prompt user to resolve them via GUI - BibDatabaseContext effectiveRemote = remote; - if (!conflicts.isEmpty()) { - List resolvedRemoteEntries = new ArrayList<>(); - for (ThreeWayEntryConflict conflict : conflicts) { - Optional maybeResolved = gitConflictResolver.resolveConflict(conflict); - if (maybeResolved.isEmpty()) { - LOGGER.warn("User canceled conflict resolution."); - return MergeResult.failure(); - } - resolvedRemoteEntries.add(maybeResolved.get()); - } - effectiveRemote = GitMergeUtil.replaceEntries(remote, resolvedRemoteEntries); + // 3. If there are conflicts, ask strategy to resolve + Optional maybeRemote = gitConflictResolverStrategy.resolveConflicts(conflicts, remote); + if (maybeRemote.isEmpty()) { + LOGGER.warn("Merge aborted: Conflict resolution was canceled or denied."); + return MergeResult.failure(); } + BibDatabaseContext effectiveRemote = maybeRemote.get(); // 4. Apply resolved remote (either original or conflict-resolved) to local MergePlan plan = SemanticConflictDetector.extractMergePlan(base, effectiveRemote); diff --git a/jablib/src/main/java/org/jabref/logic/git/conflicts/CliConflictResolverStrategy.java b/jablib/src/main/java/org/jabref/logic/git/conflicts/CliConflictResolverStrategy.java new file mode 100644 index 00000000000..24b6afd30c4 --- /dev/null +++ b/jablib/src/main/java/org/jabref/logic/git/conflicts/CliConflictResolverStrategy.java @@ -0,0 +1,14 @@ +package org.jabref.logic.git.conflicts; + +import java.util.List; +import java.util.Optional; + +import org.jabref.model.database.BibDatabaseContext; + +public class CliConflictResolverStrategy implements GitConflictResolverStrategy { + + @Override + public Optional resolveConflicts(List conflicts, BibDatabaseContext remote) { + return Optional.empty(); + } +} diff --git a/jablib/src/main/java/org/jabref/logic/git/conflicts/GitConflictResolver.java b/jablib/src/main/java/org/jabref/logic/git/conflicts/GitConflictResolver.java deleted file mode 100644 index f93b8501165..00000000000 --- a/jablib/src/main/java/org/jabref/logic/git/conflicts/GitConflictResolver.java +++ /dev/null @@ -1,9 +0,0 @@ -package org.jabref.logic.git.conflicts; - -import java.util.Optional; - -import org.jabref.model.entry.BibEntry; - -public interface GitConflictResolver { - Optional resolveConflict(ThreeWayEntryConflict conflict); -} diff --git a/jablib/src/main/java/org/jabref/logic/git/conflicts/GitConflictResolverStrategy.java b/jablib/src/main/java/org/jabref/logic/git/conflicts/GitConflictResolverStrategy.java new file mode 100644 index 00000000000..392497ce819 --- /dev/null +++ b/jablib/src/main/java/org/jabref/logic/git/conflicts/GitConflictResolverStrategy.java @@ -0,0 +1,18 @@ +package org.jabref.logic.git.conflicts; + +import java.util.List; +import java.util.Optional; + +import org.jabref.model.database.BibDatabaseContext; + +public interface GitConflictResolverStrategy { + /** + * Resolves all given entry-level semantic conflicts, and produces a new, resolved remote state. + * + * @param conflicts the list of detected three-way entry conflicts + * @param remote the original remote state + * @return the modified BibDatabaseContext containing resolved entries, + * or empty if user canceled merge or CLI refuses to merge. + */ + Optional resolveConflicts(List conflicts, BibDatabaseContext remote); +} diff --git a/jablib/src/main/java/org/jabref/logic/git/merge/GitMergeUtil.java b/jablib/src/main/java/org/jabref/logic/git/merge/GitMergeUtil.java index c515ba7d792..462dfc06a64 100644 --- a/jablib/src/main/java/org/jabref/logic/git/merge/GitMergeUtil.java +++ b/jablib/src/main/java/org/jabref/logic/git/merge/GitMergeUtil.java @@ -17,7 +17,6 @@ public class GitMergeUtil { * @param resolvedEntries list of entries that the user has manually resolved via GUI * @return a new BibDatabaseContext with resolved entries replacing original ones */ - // TODO: unit test public static BibDatabaseContext replaceEntries(BibDatabaseContext remote, List resolvedEntries) { // 1. make a copy of the remote database BibDatabase newDatabase = new BibDatabase(); @@ -25,7 +24,7 @@ public static BibDatabaseContext replaceEntries(BibDatabaseContext remote, List< Map resolvedMap = resolvedEntries.stream() .filter(entry -> entry.getCitationKey().isPresent()) .collect(Collectors.toMap( - entry -> String.valueOf(entry.getCitationKey()), + entry -> entry.getCitationKey().get(), Function.identity())); // 3. Iterate original remote entries 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 6b93483dd06..8ae8cec2ab9 100644 --- a/jablib/src/test/java/org/jabref/logic/git/GitSyncServiceTest.java +++ b/jablib/src/test/java/org/jabref/logic/git/GitSyncServiceTest.java @@ -6,11 +6,13 @@ import java.util.List; import java.util.Optional; -import org.jabref.logic.git.conflicts.GitConflictResolver; +import org.jabref.logic.git.conflicts.GitConflictResolverStrategy; import org.jabref.logic.git.conflicts.ThreeWayEntryConflict; import org.jabref.logic.git.io.GitFileReader; +import org.jabref.logic.git.merge.GitMergeUtil; import org.jabref.logic.git.model.MergeResult; import org.jabref.logic.importer.ImportFormatPreferences; +import org.jabref.model.database.BibDatabaseContext; import org.jabref.model.entry.BibEntry; import org.jabref.model.entry.field.StandardField; @@ -25,6 +27,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyList; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -33,7 +36,7 @@ class GitSyncServiceTest { private Git git; private Path library; private ImportFormatPreferences importFormatPreferences; - private GitConflictResolver gitConflictResolver; + private GitConflictResolverStrategy gitConflictResolverStrategy; // These are setup by alieBobSetting private RevCommit baseCommit; @@ -91,7 +94,7 @@ class GitSyncServiceTest { void aliceBobSimple(@TempDir Path tempDir) throws Exception { importFormatPreferences = mock(ImportFormatPreferences.class, Answers.RETURNS_DEEP_STUBS); when(importFormatPreferences.bibEntryPreferences().getKeywordSeparator()).thenReturn(','); - gitConflictResolver = mock(GitConflictResolver.class); + gitConflictResolverStrategy = mock(GitConflictResolverStrategy.class); // create fake remote repo Path remoteDir = tempDir.resolve("remote.git"); @@ -134,7 +137,7 @@ void aliceBobSimple(@TempDir Path tempDir) throws Exception { @Test void pullTriggersSemanticMergeWhenNoConflicts() throws Exception { GitHandler gitHandler = new GitHandler(library.getParent()); - GitSyncService syncService = new GitSyncService(importFormatPreferences, gitHandler, gitConflictResolver); + GitSyncService syncService = new GitSyncService(importFormatPreferences, gitHandler, gitConflictResolverStrategy); MergeResult result = syncService.fetchAndMerge(library); assertTrue(result.isSuccessful()); @@ -158,7 +161,7 @@ void pullTriggersSemanticMergeWhenNoConflicts() throws Exception { @Test void pushTriggersMergeAndPushWhenNoConflicts() throws Exception { GitHandler gitHandler = new GitHandler(library.getParent()); - GitSyncService syncService = new GitSyncService(importFormatPreferences, gitHandler, gitConflictResolver); + GitSyncService syncService = new GitSyncService(importFormatPreferences, gitHandler, gitConflictResolverStrategy); syncService.push(library); String pushedContent = GitFileReader.readFileFromCommit(git, git.log().setMaxCount(1).call().iterator().next(), Path.of("library.bib")); @@ -179,11 +182,11 @@ void pushTriggersMergeAndPushWhenNoConflicts() throws Exception { @Test void mergeConflictOnSameFieldTriggersDialogAndUsesUserResolution(@TempDir Path tempDir) throws Exception { - // === Setup remote bare repo === + // Setup remote bare repo Path remoteDir = tempDir.resolve("remote.git"); Git remoteGit = Git.init().setBare(true).setDirectory(remoteDir.toFile()).call(); - // === Clone to local working directory === + // Clone to local working directory Path localDir = tempDir.resolve("local"); Git localGit = Git.cloneRepository() .setURI(remoteDir.toUri().toString()) @@ -204,7 +207,7 @@ void mergeConflictOnSameFieldTriggersDialogAndUsesUserResolution(@TempDir Path t writeAndCommit(baseContent, "Initial commit", user, bibFile, localGit); localGit.push().setRemote("origin").call(); - // === Clone again to simulate "remote user" making conflicting change === + // Clone again to simulate "remote user" making conflicting change Path remoteUserDir = tempDir.resolve("remoteUser"); Git remoteUserGit = Git.cloneRepository() .setURI(remoteDir.toUri().toString()) @@ -223,7 +226,7 @@ void mergeConflictOnSameFieldTriggersDialogAndUsesUserResolution(@TempDir Path t writeAndCommit(remoteContent, "Remote change", user, remoteUserFile, remoteUserGit); remoteUserGit.push().setRemote("origin").call(); - // === Back to local, make conflicting change === + // Back to local, make conflicting change String localContent = """ @article{a, author = {local-author}, @@ -233,12 +236,16 @@ void mergeConflictOnSameFieldTriggersDialogAndUsesUserResolution(@TempDir Path t writeAndCommit(localContent, "Local change", user, bibFile, localGit); localGit.fetch().setRemote("origin").call(); - // === Setup GitSyncService === - GitConflictResolver resolver = mock(GitConflictResolver.class); - when(resolver.resolveConflict(any())).thenAnswer(invocation -> { - ThreeWayEntryConflict conflict = invocation.getArgument(0); - BibEntry merged = (BibEntry) conflict.base().clone(); - merged.setField(StandardField.AUTHOR, "merged-author"); + // Setup GitSyncService + GitConflictResolverStrategy resolver = mock(GitConflictResolverStrategy.class); + when(resolver.resolveConflicts(anyList(), any())).thenAnswer(invocation -> { + List conflicts = invocation.getArgument(0); + BibDatabaseContext remote = invocation.getArgument(1); + + BibEntry resolved = (BibEntry) conflicts.getFirst().base().clone(); + resolved.setField(StandardField.AUTHOR, "merged-author"); + + BibDatabaseContext merged = GitMergeUtil.replaceEntries(remote, List.of(resolved)); return Optional.of(merged); }); @@ -248,13 +255,13 @@ void mergeConflictOnSameFieldTriggersDialogAndUsesUserResolution(@TempDir Path t GitSyncService service = new GitSyncService(prefs, handler, resolver); - // === Trigger semantic merge === + // Trigger semantic merge MergeResult result = service.fetchAndMerge(bibFile); assertTrue(result.isSuccessful()); String finalContent = Files.readString(bibFile); assertTrue(finalContent.contains("merged-author")); - verify(resolver).resolveConflict(any()); + verify(resolver).resolveConflicts(anyList(), any()); } @Test 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..371f8a58b30 --- /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().get(0).getField(StandardField.TITLE).orElse("")); + } +} From 32c30a0366ac0e0ec062ff74e2ad2e00930b71fe Mon Sep 17 00:00:00 2001 From: Wanling Fu Date: Mon, 14 Jul 2025 00:48:12 +0100 Subject: [PATCH 09/37] fix: Repair failing unit tests and CI integration tests #12350 --- .../jabref/gui/git/GitStatusViewModel.java | 12 +++--- jablib/src/main/abbrv.jabref.org | 2 +- .../org/jabref/logic/git/GitSyncService.java | 17 +++++--- .../logic/git/status/GitStatusChecker.java | 2 +- jablib/src/main/resources/csl-locales | 2 +- jablib/src/main/resources/csl-styles | 2 +- .../org/jabref/logic/git/GitHandlerTest.java | 30 +++++++++++++- .../jabref/logic/git/GitSyncServiceTest.java | 20 ++++++--- .../logic/git/merge/GitMergeUtilTest.java | 2 +- .../git/status/GitStatusCheckerTest.java | 41 +++++++++++++++---- 10 files changed, 98 insertions(+), 32 deletions(-) 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 fda6e092d50..b22531b7cf4 100644 --- a/jabgui/src/main/java/org/jabref/gui/git/GitStatusViewModel.java +++ b/jabgui/src/main/java/org/jabref/gui/git/GitStatusViewModel.java @@ -18,11 +18,11 @@ /** * ViewModel that holds current Git sync status for the open .bib database. - * 统一维护当前路径绑定的 GitHandler 状态,包括: - * - 是否是 Git 仓库 - * - 当前是否被 Git 跟踪 - * - 是否存在冲突 - * - 当前同步状态(UP_TO_DATE、DIVERGED 等) + * It maintains the state of the GitHandler bound to the current file path, including: + * - Whether the current file is inside a Git repository + * - Whether the file is tracked by Git + * - Whether there are unresolved merge conflicts + * - The current sync status (e.g., UP_TO_DATE, DIVERGED, etc.) */ public class GitStatusViewModel extends AbstractViewModel { private final Path currentBibFile; @@ -45,7 +45,7 @@ public GitStatusViewModel(Path bibFilePath) { public void updateStatusFromPath(Path fileOrFolderInRepo) { Optional maybeHandler = GitHandler.fromAnyPath(fileOrFolderInRepo); - if (!maybeHandler.isPresent()) { + if (maybeHandler.isEmpty()) { reset(); return; } diff --git a/jablib/src/main/abbrv.jabref.org b/jablib/src/main/abbrv.jabref.org index 193b23f48f1..6926b834375 160000 --- a/jablib/src/main/abbrv.jabref.org +++ b/jablib/src/main/abbrv.jabref.org @@ -1 +1 @@ -Subproject commit 193b23f48f1f137fe849781c2ecab6d32e27a86d +Subproject commit 6926b83437568b3a36fc1239a33c341dd733536b 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 bf2cbb4220a..37c200763b1 100644 --- a/jablib/src/main/java/org/jabref/logic/git/GitSyncService.java +++ b/jablib/src/main/java/org/jabref/logic/git/GitSyncService.java @@ -120,13 +120,18 @@ public MergeResult performSemanticMerge(Git git, // 2. Conflict detection List conflicts = SemanticConflictDetector.detectConflicts(base, local, remote); - // 3. If there are conflicts, ask strategy to resolve - Optional maybeRemote = gitConflictResolverStrategy.resolveConflicts(conflicts, remote); - if (maybeRemote.isEmpty()) { - LOGGER.warn("Merge aborted: Conflict resolution was canceled or denied."); - return MergeResult.failure(); + BibDatabaseContext effectiveRemote; + if (conflicts.isEmpty()) { + effectiveRemote = remote; + } else { + // 3. If there are conflicts, ask strategy to resolve + Optional maybeRemote = gitConflictResolverStrategy.resolveConflicts(conflicts, remote); + if (maybeRemote.isEmpty()) { + LOGGER.warn("Merge aborted: Conflict resolution was canceled or denied."); + return MergeResult.failure(); + } + effectiveRemote = maybeRemote.get(); } - BibDatabaseContext effectiveRemote = maybeRemote.get(); // 4. Apply resolved remote (either original or conflict-resolved) to local MergePlan plan = SemanticConflictDetector.extractMergePlan(base, effectiveRemote); 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 0bb288389e9..a80c2ecd49a 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 @@ -49,7 +49,7 @@ public static GitStatusSnapshot checkStatus(Path anyPathInsideRepo) { Optional.ofNullable(localHead).map(ObjectId::getName) ); } catch (IOException | GitAPIException e) { - LOGGER.warn("Failed to check Git status: " + e.getMessage()); + LOGGER.warn("Failed to check Git status: {}", e.getMessage(), e); return new GitStatusSnapshot( true, SyncStatus.UNKNOWN, diff --git a/jablib/src/main/resources/csl-locales b/jablib/src/main/resources/csl-locales index 7e137db2a55..e27762505af 160000 --- a/jablib/src/main/resources/csl-locales +++ b/jablib/src/main/resources/csl-locales @@ -1 +1 @@ -Subproject commit 7e137db2a55a724dbc7c406eb158f656f9a0f4ab +Subproject commit e27762505af6bfeedb68e0fb02c444b5f310b4e2 diff --git a/jablib/src/main/resources/csl-styles b/jablib/src/main/resources/csl-styles index 704ff9ffba5..c1f8f60439c 160000 --- a/jablib/src/main/resources/csl-styles +++ b/jablib/src/main/resources/csl-styles @@ -1 +1 @@ -Subproject commit 704ff9ffba533dd67bb40607ef27514c2869fa09 +Subproject commit c1f8f60439c1b54bbc0b8dd144745af440581099 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 385d4b592d3..2547f1fa3ae 100644 --- a/jablib/src/test/java/org/jabref/logic/git/GitHandlerTest.java +++ b/jablib/src/test/java/org/jabref/logic/git/GitHandlerTest.java @@ -11,6 +11,7 @@ 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; @@ -22,11 +23,38 @@ class GitHandlerTest { @TempDir Path repositoryPath; + Path remoteRepoPath; private GitHandler gitHandler; @BeforeEach - void setUpGitHandler() { + void setUpGitHandler() throws IOException, GitAPIException, URISyntaxException { gitHandler = new GitHandler(repositoryPath); + + remoteRepoPath = Files.createTempDirectory("remote-repo"); + try (Git remoteGit = Git.init() + .setBare(true) + .setDirectory(remoteRepoPath.toFile()) + .call()) { + // Remote repo initialized + } + Path testFile = repositoryPath.resolve("initial.txt"); + Files.writeString(testFile, "init"); + + gitHandler.createCommitOnCurrentBranch("Initial commit", false); + + try (Git localGit = Git.open(repositoryPath.toFile())) { + localGit.remoteAdd() + .setName("origin") + .setUri(new URIish(remoteRepoPath.toUri().toString())) + .call(); + + localGit.push() + .setRemote("origin") + .setRefSpecs(new RefSpec("refs/heads/main:refs/heads/main")) + .call(); + } + + Files.writeString(remoteRepoPath.resolve("HEAD"), "ref: refs/heads/main"); } @Test 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 8ae8cec2ab9..6d46beb45af 100644 --- a/jablib/src/test/java/org/jabref/logic/git/GitSyncServiceTest.java +++ b/jablib/src/test/java/org/jabref/logic/git/GitSyncServiceTest.java @@ -83,12 +83,11 @@ class GitSyncServiceTest { } """; - /** * 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 + * 2. Bob clone remote -> update b → push + * 3. Alice update a → pull */ @BeforeEach void aliceBobSimple(@TempDir Path tempDir) throws Exception { @@ -98,7 +97,11 @@ void aliceBobSimple(@TempDir Path tempDir) throws Exception { // create fake remote repo Path remoteDir = tempDir.resolve("remote.git"); - Git remoteGit = Git.init().setBare(true).setDirectory(remoteDir.toFile()).call(); + Git remoteGit = Git.init() + .setBare(true) + .setInitialBranch("main") + .setDirectory(remoteDir.toFile()) + .call(); // Alice clone remote -> local repository Path aliceDir = tempDir.resolve("alice"); @@ -120,7 +123,7 @@ void aliceBobSimple(@TempDir Path tempDir) throws Exception { .setURI(remoteDir.toUri().toString()) .setDirectory(bobDir.toFile()) .setBranchesToClone(List.of("refs/heads/main")) - .setBranch("refs/heads/main") + .setBranch("main") .call(); Path bobLibrary = bobDir.resolve("library.bib"); bobCommit = writeAndCommit(bobUpdatedContent, "Exchange a with b", bob, bobLibrary, bobGit); @@ -184,7 +187,12 @@ void pushTriggersMergeAndPushWhenNoConflicts() throws Exception { void mergeConflictOnSameFieldTriggersDialogAndUsesUserResolution(@TempDir Path tempDir) throws Exception { // Setup remote bare repo Path remoteDir = tempDir.resolve("remote.git"); - Git remoteGit = Git.init().setBare(true).setDirectory(remoteDir.toFile()).call(); + Git remoteGit = Git.init() + .setBare(true) + .setInitialBranch("main") + .setDirectory(remoteDir.toFile()) + .call(); + Files.writeString(remoteDir.resolve("HEAD"), "ref: refs/heads/main"); // Clone to local working directory Path localDir = tempDir.resolve("local"); 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 index 371f8a58b30..f97aecb00ae 100644 --- a/jablib/src/test/java/org/jabref/logic/git/merge/GitMergeUtilTest.java +++ b/jablib/src/test/java/org/jabref/logic/git/merge/GitMergeUtilTest.java @@ -57,6 +57,6 @@ void replaceEntriesIgnoresResolvedWithoutCitationKey() { .withField(StandardField.TITLE, "New Title"); BibDatabaseContext result = GitMergeUtil.replaceEntries(remote, List.of(resolved)); - assertEquals("Original Title", result.getDatabase().getEntries().get(0).getField(StandardField.TITLE).orElse("")); + assertEquals("Original Title", result.getDatabase().getEntries().getFirst().getField(StandardField.TITLE).orElse("")); } } 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 c3cfa4b5597..178033ee1b9 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 @@ -3,10 +3,13 @@ import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; +import java.util.List; import org.eclipse.jgit.api.Git; import org.eclipse.jgit.lib.PersonIdent; import org.eclipse.jgit.revwalk.RevCommit; +import org.eclipse.jgit.transport.RefSpec; +import org.eclipse.jgit.transport.URIish; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; @@ -63,6 +66,24 @@ void setup(@TempDir Path tempDir) throws Exception { Path remoteDir = tempDir.resolve("remote.git"); remoteGit = Git.init().setBare(true).setDirectory(remoteDir.toFile()).call(); + Path seedDir = tempDir.resolve("seed"); + Git seedGit = Git.init().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.branchCreate().setName("master").call(); + + seedGit.remoteAdd() + .setName("origin") + .setUri(new URIish(remoteDir.toUri().toString())) + .call(); + seedGit.push() + .setRemote("origin") + .setRefSpecs(new RefSpec("refs/heads/master:refs/heads/main")) + .call(); + Path localDir = tempDir.resolve("local"); localGit = Git.cloneRepository() .setURI(remoteDir.toUri().toString()) @@ -71,12 +92,6 @@ void setup(@TempDir Path tempDir) throws Exception { .call(); this.localLibrary = localDir.resolve("library.bib"); - - // Initial commit - commitFile(localGit, baseContent, "Initial commit"); - - // Push to remote - localGit.push().setRemote("origin").call(); } @Test @@ -100,10 +115,15 @@ void behindStatusWhenRemoteHasNewCommit(@TempDir Path tempDir) throws Exception 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").call(); + remoteClone.push() + .setRemote("origin") + .setRefSpecs(new RefSpec("refs/heads/main:refs/heads/main")) + .call(); localGit.fetch().setRemote("origin").call(); GitStatusSnapshot snapshot = GitStatusChecker.checkStatus(localLibrary); @@ -125,10 +145,15 @@ void divergedStatusWhenBothSidesHaveCommits(@TempDir Path tempDir) throws Except 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").call(); + remoteClone.push() + .setRemote("origin") + .setRefSpecs(new RefSpec("refs/heads/main:refs/heads/main")) + .call(); localGit.fetch().setRemote("origin").call(); GitStatusSnapshot snapshot = GitStatusChecker.checkStatus(localLibrary); From f8250d17a29dae2eaba7c094f84e70bc2a75834b Mon Sep 17 00:00:00 2001 From: Wanling Fu Date: Mon, 14 Jul 2025 02:09:55 +0100 Subject: [PATCH 10/37] Fix submodules --- jablib/src/main/abbrv.jabref.org | 2 +- jablib/src/main/resources/csl-locales | 2 +- jablib/src/main/resources/csl-styles | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/jablib/src/main/abbrv.jabref.org b/jablib/src/main/abbrv.jabref.org index 6926b834375..193b23f48f1 160000 --- a/jablib/src/main/abbrv.jabref.org +++ b/jablib/src/main/abbrv.jabref.org @@ -1 +1 @@ -Subproject commit 6926b83437568b3a36fc1239a33c341dd733536b +Subproject commit 193b23f48f1f137fe849781c2ecab6d32e27a86d diff --git a/jablib/src/main/resources/csl-locales b/jablib/src/main/resources/csl-locales index e27762505af..7e137db2a55 160000 --- a/jablib/src/main/resources/csl-locales +++ b/jablib/src/main/resources/csl-locales @@ -1 +1 @@ -Subproject commit e27762505af6bfeedb68e0fb02c444b5f310b4e2 +Subproject commit 7e137db2a55a724dbc7c406eb158f656f9a0f4ab diff --git a/jablib/src/main/resources/csl-styles b/jablib/src/main/resources/csl-styles index c1f8f60439c..704ff9ffba5 160000 --- a/jablib/src/main/resources/csl-styles +++ b/jablib/src/main/resources/csl-styles @@ -1 +1 @@ -Subproject commit c1f8f60439c1b54bbc0b8dd144745af440581099 +Subproject commit 704ff9ffba533dd67bb40607ef27514c2869fa09 From 61a19b89db038097824fb16a23a84bbebb19349a Mon Sep 17 00:00:00 2001 From: Wanling Fu Date: Tue, 15 Jul 2025 17:38:55 +0100 Subject: [PATCH 11/37] test: Try to fix GitHandlerTest by ensuring remote main branch exists #12350 --- .../org/jabref/logic/git/GitHandlerTest.java | 52 ++++++------------- 1 file changed, 16 insertions(+), 36 deletions(-) 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 2547f1fa3ae..901dac1f354 100644 --- a/jablib/src/test/java/org/jabref/logic/git/GitHandlerTest.java +++ b/jablib/src/test/java/org/jabref/logic/git/GitHandlerTest.java @@ -89,42 +89,22 @@ void getCurrentlyCheckedOutBranch() throws IOException { @Test void fetchOnCurrentBranch() throws IOException, GitAPIException, URISyntaxException { - Path remoteRepoPath = Files.createTempDirectory("remote-repo"); - try (Git remoteGit = Git.init() - .setDirectory(remoteRepoPath.toFile()) - .setBare(true) - .call()) { - try (Git localGit = Git.open(repositoryPath.toFile())) { - localGit.remoteAdd() - .setName("origin") - .setUri(new URIish(remoteRepoPath.toUri().toString())) - .call(); - } - - Path testFile = repositoryPath.resolve("test.txt"); - Files.writeString(testFile, "hello"); - gitHandler.createCommitOnCurrentBranch("First commit", false); - try (Git localGit = Git.open(repositoryPath.toFile())) { - localGit.push().setRemote("origin").call(); - } - - Path clonePath = Files.createTempDirectory("clone-of-remote"); - 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); - } + Path clonePath = Files.createTempDirectory("clone-of-remote"); + 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); } } From 7e36b2ec92599e512a04248f02517353e69c314c Mon Sep 17 00:00:00 2001 From: Wanling Fu Date: Tue, 15 Jul 2025 18:22:17 +0100 Subject: [PATCH 12/37] test: Try to fix GitSyncServiceTest/GitStatusCheckerTest by ensuring remote main branch exists #12350 --- .../org/jabref/logic/git/GitSyncService.java | 7 ++++++- .../org/jabref/logic/git/GitHandlerTest.java | 21 ++++++++++--------- .../jabref/logic/git/GitSyncServiceTest.java | 2 ++ .../git/status/GitStatusCheckerTest.java | 9 +++++--- 4 files changed, 25 insertions(+), 14 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 37c200763b1..567cfd4390a 100644 --- a/jablib/src/main/java/org/jabref/logic/git/GitSyncService.java +++ b/jablib/src/main/java/org/jabref/logic/git/GitSyncService.java @@ -54,6 +54,12 @@ public GitSyncService(ImportFormatPreferences importFormatPreferences, GitHandle * Called when user clicks Pull */ public MergeResult fetchAndMerge(Path bibFilePath) throws GitAPIException, IOException, JabRefException { + Optional maybeHandler = GitHandler.fromAnyPath(bibFilePath); + if (maybeHandler.isEmpty()) { + LOGGER.warn("Pull aborted: The file is not inside a Git repository."); + return MergeResult.failure(); + } + GitStatusSnapshot status = GitStatusChecker.checkStatus(bibFilePath); if (!status.tracking()) { @@ -102,7 +108,6 @@ public MergeResult performSemanticMerge(Git git, Path workTree = git.getRepository().getWorkTree().toPath().toRealPath(); Path relativePath; - // TODO: Validate that the .bib file is inside the Git repository earlier in the workflow. if (!bibPath.startsWith(workTree)) { throw new IllegalStateException("Given .bib file is not inside repository"); } 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 901dac1f354..9ce4e226857 100644 --- a/jablib/src/test/java/org/jabref/logic/git/GitHandlerTest.java +++ b/jablib/src/test/java/org/jabref/logic/git/GitHandlerTest.java @@ -5,6 +5,7 @@ import java.nio.file.Files; import java.nio.file.Path; import java.util.Iterator; +import java.util.Optional; import org.eclipse.jgit.api.Git; import org.eclipse.jgit.api.errors.GitAPIException; @@ -23,7 +24,10 @@ class GitHandlerTest { @TempDir Path repositoryPath; + @TempDir Path remoteRepoPath; + @TempDir + Path clonePath; private GitHandler gitHandler; @BeforeEach @@ -31,12 +35,10 @@ void setUpGitHandler() throws IOException, GitAPIException, URISyntaxException { gitHandler = new GitHandler(repositoryPath); remoteRepoPath = Files.createTempDirectory("remote-repo"); - try (Git remoteGit = Git.init() - .setBare(true) - .setDirectory(remoteRepoPath.toFile()) - .call()) { - // Remote repo initialized - } + Git remoteGit = Git.init() + .setBare(true) + .setDirectory(remoteRepoPath.toFile()) + .call(); Path testFile = repositoryPath.resolve("initial.txt"); Files.writeString(testFile, "init"); @@ -89,7 +91,8 @@ void getCurrentlyCheckedOutBranch() throws IOException { @Test void fetchOnCurrentBranch() throws IOException, GitAPIException, URISyntaxException { - Path clonePath = Files.createTempDirectory("clone-of-remote"); + clonePath = Files.createTempDirectory("clone-of-remote"); + try (Git cloneGit = Git.cloneRepository() .setURI(remoteRepoPath.toUri().toString()) .setDirectory(clonePath.toFile()) @@ -110,12 +113,10 @@ void fetchOnCurrentBranch() throws IOException, GitAPIException, URISyntaxExcept @Test void fromAnyPathFindsGitRootFromNestedPath() throws IOException { - // Arrange: create a nested directory structure inside the temp Git repo Path nested = repositoryPath.resolve("src/org/jabref"); Files.createDirectories(nested); - // Act: attempt to construct GitHandler from nested path - var handlerOpt = GitHandler.fromAnyPath(nested); + Optional handlerOpt = GitHandler.fromAnyPath(nested); assertTrue(handlerOpt.isPresent(), "Expected GitHandler to be created"); assertEquals(repositoryPath.toRealPath(), handlerOpt.get().repositoryPath.toRealPath(), 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 6d46beb45af..17e4b418ae1 100644 --- a/jablib/src/test/java/org/jabref/logic/git/GitSyncServiceTest.java +++ b/jablib/src/test/java/org/jabref/logic/git/GitSyncServiceTest.java @@ -117,6 +117,8 @@ void aliceBobSimple(@TempDir Path tempDir) throws Exception { baseCommit = writeAndCommit(initialContent, "Inital commit", alice, library, aliceGit); git.push().setRemote("origin").call(); + Files.writeString(remoteDir.resolve("HEAD"), "ref: refs/heads/main"); + // Bob clone remote Path bobDir = tempDir.resolve("bob"); Git bobGit = Git.cloneRepository() 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 178033ee1b9..2c1e75a6e47 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 @@ -67,13 +67,15 @@ void setup(@TempDir Path tempDir) throws Exception { remoteGit = Git.init().setBare(true).setDirectory(remoteDir.toFile()).call(); Path seedDir = tempDir.resolve("seed"); - Git seedGit = Git.init().setDirectory(seedDir.toFile()).call(); + Git seedGit = Git.init() + .setInitialBranch("main") + .setDirectory(seedDir.toFile()) + .call(); Path seedFile = seedDir.resolve("library.bib"); Files.writeString(seedFile, baseContent, StandardCharsets.UTF_8); seedGit.add().addFilepattern("library.bib").call(); seedGit.commit().setAuthor(author).setMessage("Initial commit").call(); - seedGit.branchCreate().setName("master").call(); seedGit.remoteAdd() .setName("origin") @@ -81,8 +83,9 @@ void setup(@TempDir Path tempDir) throws Exception { .call(); seedGit.push() .setRemote("origin") - .setRefSpecs(new RefSpec("refs/heads/master:refs/heads/main")) + .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() From cf60c023fabee7882f87247ac0a3ec3267215ba1 Mon Sep 17 00:00:00 2001 From: Wanling Fu Date: Tue, 15 Jul 2025 18:38:02 +0100 Subject: [PATCH 13/37] test: Try to fix GitSyncServiceTest by explicitly pushing refspec #12350 --- .../org/jabref/logic/git/GitSyncService.java | 3 --- .../org/jabref/logic/git/GitHandlerTest.java | 3 --- .../jabref/logic/git/GitSyncServiceTest.java | 21 +++++++++++++++---- 3 files changed, 17 insertions(+), 10 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 567cfd4390a..08cbf22750d 100644 --- a/jablib/src/main/java/org/jabref/logic/git/GitSyncService.java +++ b/jablib/src/main/java/org/jabref/logic/git/GitSyncService.java @@ -50,9 +50,6 @@ public GitSyncService(ImportFormatPreferences importFormatPreferences, GitHandle this.gitConflictResolverStrategy = gitConflictResolverStrategy; } - /** - * Called when user clicks Pull - */ public MergeResult fetchAndMerge(Path bibFilePath) throws GitAPIException, IOException, JabRefException { Optional maybeHandler = GitHandler.fromAnyPath(bibFilePath); if (maybeHandler.isEmpty()) { 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 9ce4e226857..cf3ec0fbdb0 100644 --- a/jablib/src/test/java/org/jabref/logic/git/GitHandlerTest.java +++ b/jablib/src/test/java/org/jabref/logic/git/GitHandlerTest.java @@ -34,7 +34,6 @@ class GitHandlerTest { void setUpGitHandler() throws IOException, GitAPIException, URISyntaxException { gitHandler = new GitHandler(repositoryPath); - remoteRepoPath = Files.createTempDirectory("remote-repo"); Git remoteGit = Git.init() .setBare(true) .setDirectory(remoteRepoPath.toFile()) @@ -91,8 +90,6 @@ void getCurrentlyCheckedOutBranch() throws IOException { @Test void fetchOnCurrentBranch() throws IOException, GitAPIException, URISyntaxException { - clonePath = Files.createTempDirectory("clone-of-remote"); - try (Git cloneGit = Git.cloneRepository() .setURI(remoteRepoPath.toUri().toString()) .setDirectory(clonePath.toFile()) 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 17e4b418ae1..ec02979d410 100644 --- a/jablib/src/test/java/org/jabref/logic/git/GitSyncServiceTest.java +++ b/jablib/src/test/java/org/jabref/logic/git/GitSyncServiceTest.java @@ -19,6 +19,7 @@ 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.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; @@ -115,7 +116,10 @@ void aliceBobSimple(@TempDir Path tempDir) throws Exception { // Alice: initial commit baseCommit = writeAndCommit(initialContent, "Inital commit", alice, library, aliceGit); - git.push().setRemote("origin").call(); + git.push() + .setRemote("origin") + .setRefSpecs(new RefSpec("refs/heads/main:refs/heads/main")) + .call(); Files.writeString(remoteDir.resolve("HEAD"), "ref: refs/heads/main"); @@ -129,7 +133,10 @@ void aliceBobSimple(@TempDir Path tempDir) throws Exception { .call(); Path bobLibrary = bobDir.resolve("library.bib"); bobCommit = writeAndCommit(bobUpdatedContent, "Exchange a with b", bob, bobLibrary, bobGit); - bobGit.push().setRemote("origin").call(); + 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); @@ -215,7 +222,10 @@ void mergeConflictOnSameFieldTriggersDialogAndUsesUserResolution(@TempDir Path t """; writeAndCommit(baseContent, "Initial commit", user, bibFile, localGit); - localGit.push().setRemote("origin").call(); + localGit.push() + .setRemote("origin") + .setRefSpecs(new RefSpec("refs/heads/main:refs/heads/main")) + .call(); // Clone again to simulate "remote user" making conflicting change Path remoteUserDir = tempDir.resolve("remoteUser"); @@ -234,7 +244,10 @@ void mergeConflictOnSameFieldTriggersDialogAndUsesUserResolution(@TempDir Path t """; writeAndCommit(remoteContent, "Remote change", user, remoteUserFile, remoteUserGit); - remoteUserGit.push().setRemote("origin").call(); + remoteUserGit.push() + .setRemote("origin") + .setRefSpecs(new RefSpec("refs/heads/main:refs/heads/main")) + .call(); // Back to local, make conflicting change String localContent = """ From 579948317415f7e29b0166821ec26a7ce4b376cf Mon Sep 17 00:00:00 2001 From: Wanling Fu Date: Tue, 15 Jul 2025 23:24:13 +0100 Subject: [PATCH 14/37] test: Try to fix GitSyncServiceTest by explicitly checking out to the main branch #12350 --- .../main/java/org/jabref/logic/git/GitSyncService.java | 1 - .../java/org/jabref/logic/git/GitSyncServiceTest.java | 9 +++++++++ 2 files changed, 9 insertions(+), 1 deletion(-) 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 08cbf22750d..6e3a75b7800 100644 --- a/jablib/src/main/java/org/jabref/logic/git/GitSyncService.java +++ b/jablib/src/main/java/org/jabref/logic/git/GitSyncService.java @@ -74,7 +74,6 @@ public MergeResult fetchAndMerge(Path bibFilePath) throws GitAPIException, IOExc return MergeResult.success(); } - // Status is BEHIND or DIVERGED try (Git git = gitHandler.open()) { // 1. Fetch latest remote branch gitHandler.fetchOnCurrentBranch(); 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 ec02979d410..2450df286a3 100644 --- a/jablib/src/test/java/org/jabref/logic/git/GitSyncServiceTest.java +++ b/jablib/src/test/java/org/jabref/logic/git/GitSyncServiceTest.java @@ -116,6 +116,10 @@ void aliceBobSimple(@TempDir Path tempDir) throws Exception { // Alice: initial commit baseCommit = writeAndCommit(initialContent, "Inital commit", alice, library, aliceGit); + git.checkout() + .setName("main") + .call(); + git.push() .setRemote("origin") .setRefSpecs(new RefSpec("refs/heads/main:refs/heads/main")) @@ -222,6 +226,11 @@ void mergeConflictOnSameFieldTriggersDialogAndUsesUserResolution(@TempDir Path t """; writeAndCommit(baseContent, "Initial commit", user, bibFile, localGit); + + localGit.checkout() + .setName("main") + .call(); + localGit.push() .setRemote("origin") .setRefSpecs(new RefSpec("refs/heads/main:refs/heads/main")) From 9107ddf3f3c83ea5ea67c4c8221d415bf1dea0ac Mon Sep 17 00:00:00 2001 From: Wanling Fu Date: Tue, 15 Jul 2025 23:46:02 +0100 Subject: [PATCH 15/37] test: Try to fix GitSyncServiceTest #12350 --- .../org/jabref/logic/git/GitSyncServiceTest.java | 12 ------------ 1 file changed, 12 deletions(-) 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 2450df286a3..0b4789b9785 100644 --- a/jablib/src/test/java/org/jabref/logic/git/GitSyncServiceTest.java +++ b/jablib/src/test/java/org/jabref/logic/git/GitSyncServiceTest.java @@ -109,24 +109,18 @@ void aliceBobSimple(@TempDir Path tempDir) throws Exception { Git aliceGit = Git.cloneRepository() .setURI(remoteDir.toUri().toString()) .setDirectory(aliceDir.toFile()) - .setBranch("main") .call(); this.git = aliceGit; this.library = aliceDir.resolve("library.bib"); // Alice: initial commit baseCommit = writeAndCommit(initialContent, "Inital commit", alice, library, aliceGit); - git.checkout() - .setName("main") - .call(); git.push() .setRemote("origin") .setRefSpecs(new RefSpec("refs/heads/main:refs/heads/main")) .call(); - Files.writeString(remoteDir.resolve("HEAD"), "ref: refs/heads/main"); - // Bob clone remote Path bobDir = tempDir.resolve("bob"); Git bobGit = Git.cloneRepository() @@ -205,14 +199,12 @@ void mergeConflictOnSameFieldTriggersDialogAndUsesUserResolution(@TempDir Path t .setInitialBranch("main") .setDirectory(remoteDir.toFile()) .call(); - Files.writeString(remoteDir.resolve("HEAD"), "ref: refs/heads/main"); // Clone to local working directory Path localDir = tempDir.resolve("local"); Git localGit = Git.cloneRepository() .setURI(remoteDir.toUri().toString()) .setDirectory(localDir.toFile()) - .setBranch("main") .call(); Path bibFile = localDir.resolve("library.bib"); @@ -227,10 +219,6 @@ void mergeConflictOnSameFieldTriggersDialogAndUsesUserResolution(@TempDir Path t writeAndCommit(baseContent, "Initial commit", user, bibFile, localGit); - localGit.checkout() - .setName("main") - .call(); - localGit.push() .setRemote("origin") .setRefSpecs(new RefSpec("refs/heads/main:refs/heads/main")) From 0ea08f126ba1a70f0e6237f4b948014b8abb6fb1 Mon Sep 17 00:00:00 2001 From: Oliver Kopp Date: Wed, 16 Jul 2025 15:23:39 +0200 Subject: [PATCH 16/37] Change exception logging --- .../src/main/kotlin/org.jabref.gradle.feature.test.gradle.kts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 { From a78c8c48277638718ff8f3465398267ee42dee93 Mon Sep 17 00:00:00 2001 From: Wanling Fu Date: Wed, 16 Jul 2025 14:26:05 +0100 Subject: [PATCH 17/37] test: Add debug output to GitSyncServiceTest #12350 --- .../jabref/logic/git/GitSyncServiceTest.java | 54 +++++++++++++++---- 1 file changed, 44 insertions(+), 10 deletions(-) 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 0b4789b9785..c24abcb85c3 100644 --- a/jablib/src/test/java/org/jabref/logic/git/GitSyncServiceTest.java +++ b/jablib/src/test/java/org/jabref/logic/git/GitSyncServiceTest.java @@ -39,7 +39,7 @@ class GitSyncServiceTest { private ImportFormatPreferences importFormatPreferences; private GitConflictResolverStrategy gitConflictResolverStrategy; - // These are setup by alieBobSetting + // These are setup by aliceBobSetting private RevCommit baseCommit; private RevCommit aliceCommit; private RevCommit bobCommit; @@ -116,10 +116,21 @@ void aliceBobSimple(@TempDir Path tempDir) throws Exception { // Alice: initial commit baseCommit = writeAndCommit(initialContent, "Inital commit", alice, library, aliceGit); - git.push() - .setRemote("origin") - .setRefSpecs(new RefSpec("refs/heads/main:refs/heads/main")) - .call(); + try { + git.push() + .setRemote("origin") + .setRefSpecs(new RefSpec("refs/heads/main:refs/heads/main")) + .call(); + } catch (Exception e) { + System.err.println(">>> GIT PUSH FAILED in @BeforeEach <<<"); + e.printStackTrace(); + Throwable cause = e.getCause(); + while (cause != null) { + System.err.println("Cause: " + cause.getClass().getName() + ": " + cause.getMessage()); + cause = cause.getCause(); + } + throw e; + } // Bob clone remote Path bobDir = tempDir.resolve("bob"); @@ -131,10 +142,22 @@ void aliceBobSimple(@TempDir Path tempDir) throws Exception { .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(); + + try { + bobGit.push() + .setRemote("origin") + .setRefSpecs(new RefSpec("refs/heads/main:refs/heads/main")) + .call(); + } catch (Exception e) { + System.err.println(">>> GIT PUSH FAILED in @BeforeEach <<<"); + e.printStackTrace(); + Throwable cause = e.getCause(); + while (cause != null) { + System.err.println("Cause: " + cause.getClass().getName() + ": " + cause.getMessage()); + cause = cause.getCause(); + } + throw e; + } // back to Alice's branch, fetch remote aliceCommit = writeAndCommit(aliceUpdatedContent, "Fix author of a", alice, library, aliceGit); @@ -172,7 +195,18 @@ void pullTriggersSemanticMergeWhenNoConflicts() throws Exception { void pushTriggersMergeAndPushWhenNoConflicts() throws Exception { GitHandler gitHandler = new GitHandler(library.getParent()); GitSyncService syncService = new GitSyncService(importFormatPreferences, gitHandler, gitConflictResolverStrategy); - syncService.push(library); + try { + syncService.push(library); + } catch (Exception e) { + System.err.println(">>> GIT PUSH FAILED in pushTriggersMergeAndPushWhenNoConflicts <<<"); + e.printStackTrace(); + Throwable cause = e.getCause(); + while (cause != null) { + System.err.println("Cause: " + cause.getClass().getName() + ": " + cause.getMessage()); + cause = cause.getCause(); + } + throw e; + } String pushedContent = GitFileReader.readFileFromCommit(git, git.log().setMaxCount(1).call().iterator().next(), Path.of("library.bib")); String expected = """ From d055947ab9ce77b925145b06d341d0a40cae2254 Mon Sep 17 00:00:00 2001 From: Wanling Fu Date: Thu, 17 Jul 2025 00:19:47 +0100 Subject: [PATCH 18/37] test: Fix GitSyncServiceTest by closing Git resources and improving conflict setup #12350 --- .../jabref/logic/git/GitSyncServiceTest.java | 212 +++++++----------- 1 file changed, 86 insertions(+), 126 deletions(-) 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 c24abcb85c3..115fa89cde5 100644 --- a/jablib/src/test/java/org/jabref/logic/git/GitSyncServiceTest.java +++ b/jablib/src/test/java/org/jabref/logic/git/GitSyncServiceTest.java @@ -20,6 +20,7 @@ import org.eclipse.jgit.lib.PersonIdent; import org.eclipse.jgit.revwalk.RevCommit; import org.eclipse.jgit.transport.RefSpec; +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; @@ -36,6 +37,11 @@ class GitSyncServiceTest { private Git git; private Path library; + private Path remoteDir; + private Path aliceDir; + private Path bobDir; + private Git aliceGit; + private Git bobGit; private ImportFormatPreferences importFormatPreferences; private GitConflictResolverStrategy gitConflictResolverStrategy; @@ -97,44 +103,38 @@ void aliceBobSimple(@TempDir Path tempDir) throws Exception { gitConflictResolverStrategy = mock(GitConflictResolverStrategy.class); // create fake remote repo - Path remoteDir = tempDir.resolve("remote.git"); + remoteDir = tempDir.resolve("remote.git"); Git remoteGit = Git.init() .setBare(true) .setInitialBranch("main") .setDirectory(remoteDir.toFile()) .call(); + remoteGit.close(); // Alice clone remote -> local repository - Path aliceDir = tempDir.resolve("alice"); - Git aliceGit = Git.cloneRepository() + aliceDir = tempDir.resolve("alice"); + aliceGit = Git.cloneRepository() .setURI(remoteDir.toUri().toString()) .setDirectory(aliceDir.toFile()) .call(); + this.git = aliceGit; this.library = aliceDir.resolve("library.bib"); + // Initial commit + baseCommit = writeAndCommit(initialContent, "Initial commit", alice, library, aliceGit); - // Alice: initial commit - baseCommit = writeAndCommit(initialContent, "Inital commit", alice, library, aliceGit); - - try { - git.push() - .setRemote("origin") - .setRefSpecs(new RefSpec("refs/heads/main:refs/heads/main")) - .call(); - } catch (Exception e) { - System.err.println(">>> GIT PUSH FAILED in @BeforeEach <<<"); - e.printStackTrace(); - Throwable cause = e.getCause(); - while (cause != null) { - System.err.println("Cause: " + cause.getClass().getName() + ": " + cause.getMessage()); - cause = cause.getCause(); - } - throw e; - } + git.push() + .setRemote("origin") + .setRefSpecs(new RefSpec("refs/heads/main:refs/heads/main")) + .call(); + + aliceGit.checkout() + .setName("main") + .call(); // Bob clone remote - Path bobDir = tempDir.resolve("bob"); - Git bobGit = Git.cloneRepository() + bobDir = tempDir.resolve("bob"); + bobGit = Git.cloneRepository() .setURI(remoteDir.toUri().toString()) .setDirectory(bobDir.toFile()) .setBranchesToClone(List.of("refs/heads/main")) @@ -142,22 +142,10 @@ void aliceBobSimple(@TempDir Path tempDir) throws Exception { .call(); Path bobLibrary = bobDir.resolve("library.bib"); bobCommit = writeAndCommit(bobUpdatedContent, "Exchange a with b", bob, bobLibrary, bobGit); - - try { - bobGit.push() - .setRemote("origin") - .setRefSpecs(new RefSpec("refs/heads/main:refs/heads/main")) - .call(); - } catch (Exception e) { - System.err.println(">>> GIT PUSH FAILED in @BeforeEach <<<"); - e.printStackTrace(); - Throwable cause = e.getCause(); - while (cause != null) { - System.err.println("Cause: " + cause.getClass().getName() + ": " + cause.getMessage()); - cause = cause.getCause(); - } - throw e; - } + 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); @@ -195,18 +183,7 @@ void pullTriggersSemanticMergeWhenNoConflicts() throws Exception { void pushTriggersMergeAndPushWhenNoConflicts() throws Exception { GitHandler gitHandler = new GitHandler(library.getParent()); GitSyncService syncService = new GitSyncService(importFormatPreferences, gitHandler, gitConflictResolverStrategy); - try { - syncService.push(library); - } catch (Exception e) { - System.err.println(">>> GIT PUSH FAILED in pushTriggersMergeAndPushWhenNoConflicts <<<"); - e.printStackTrace(); - Throwable cause = e.getCause(); - while (cause != null) { - System.err.println("Cause: " + cause.getClass().getName() + ": " + cause.getMessage()); - cause = cause.getCause(); - } - throw e; - } + syncService.push(library); String pushedContent = GitFileReader.readFileFromCommit(git, git.log().setMaxCount(1).call().iterator().next(), Path.of("library.bib")); String expected = """ @@ -226,95 +203,65 @@ void pushTriggersMergeAndPushWhenNoConflicts() throws Exception { @Test void mergeConflictOnSameFieldTriggersDialogAndUsesUserResolution(@TempDir Path tempDir) throws Exception { - // Setup remote bare repo - Path remoteDir = tempDir.resolve("remote.git"); - Git remoteGit = Git.init() - .setBare(true) - .setInitialBranch("main") - .setDirectory(remoteDir.toFile()) - .call(); - - // Clone to local working directory - Path localDir = tempDir.resolve("local"); - Git localGit = Git.cloneRepository() - .setURI(remoteDir.toUri().toString()) - .setDirectory(localDir.toFile()) - .call(); - Path bibFile = localDir.resolve("library.bib"); - - PersonIdent user = new PersonIdent("User", "user@example.com"); - - String baseContent = """ - @article{a, - author = {unknown}, - doi = {xya}, - } - """; - - writeAndCommit(baseContent, "Initial commit", user, bibFile, localGit); - - localGit.push() - .setRemote("origin") - .setRefSpecs(new RefSpec("refs/heads/main:refs/heads/main")) - .call(); - - // Clone again to simulate "remote user" making conflicting change - Path remoteUserDir = tempDir.resolve("remoteUser"); - Git remoteUserGit = Git.cloneRepository() - .setURI(remoteDir.toUri().toString()) - .setDirectory(remoteUserDir.toFile()) - .setBranch("main") - .call(); - Path remoteUserFile = remoteUserDir.resolve("library.bib"); - - String remoteContent = """ - @article{a, - author = {remote-author}, - doi = {xya}, - } - """; - - writeAndCommit(remoteContent, "Remote change", user, remoteUserFile, remoteUserGit); - remoteUserGit.push() - .setRemote("origin") - .setRefSpecs(new RefSpec("refs/heads/main:refs/heads/main")) - .call(); - - // Back to local, make conflicting change - String localContent = """ - @article{a, - author = {local-author}, - doi = {xya}, - } + // Bob adds entry c + Path bobLibrary = bobDir.resolve("library.bib"); + String bobEntry = """ + @article{b, + author = {author-b}, + doi = {xyz}, + } + @article{a, + author = {author-a}, + doi = {xya}, + } + @article{c, + author = {bob-c}, + title = {Title C}, + } + """; + writeAndCommit(bobEntry, "Bob adds article-c", bob, bobLibrary, bobGit); + bobGit.push().setRemote("origin").call(); + // Alice adds conflicting version of c + String aliceEntry = """ + @article{b, + author = {author-b}, + doi = {xyz}, + } + @article{a, + author = {author-a}, + doi = {xya}, + } + @article{c, + author = {alice-c}, + title = {Title C}, + } """; - writeAndCommit(localContent, "Local change", user, bibFile, localGit); - localGit.fetch().setRemote("origin").call(); + writeAndCommit(aliceEntry, "Alice adds conflicting article-c", alice, library, aliceGit); + git.fetch().setRemote("origin").call(); - // Setup GitSyncService + // Setup mock conflict resolver GitConflictResolverStrategy resolver = mock(GitConflictResolverStrategy.class); when(resolver.resolveConflicts(anyList(), any())).thenAnswer(invocation -> { List conflicts = invocation.getArgument(0); BibDatabaseContext remote = invocation.getArgument(1); - BibEntry resolved = (BibEntry) conflicts.getFirst().base().clone(); - resolved.setField(StandardField.AUTHOR, "merged-author"); + ThreeWayEntryConflict conflict = ((List) invocation.getArgument(0)).getFirst(); + // In this test, both Alice and Bob independently added a new entry 'c', so the base is null. + // We simulate conflict resolution by choosing the remote version and modifying the author field. + BibEntry resolved = ((BibEntry) conflict.remote().clone()); + resolved.setField(StandardField.AUTHOR, "alice-c + bob-c"); BibDatabaseContext merged = GitMergeUtil.replaceEntries(remote, List.of(resolved)); return Optional.of(merged); }); - GitHandler handler = new GitHandler(localDir); - ImportFormatPreferences prefs = mock(ImportFormatPreferences.class, Answers.RETURNS_DEEP_STUBS); - when(prefs.bibEntryPreferences().getKeywordSeparator()).thenReturn(','); - - GitSyncService service = new GitSyncService(prefs, handler, resolver); - - // Trigger semantic merge - MergeResult result = service.fetchAndMerge(bibFile); + GitHandler handler = new GitHandler(aliceDir); + GitSyncService service = new GitSyncService(importFormatPreferences, handler, resolver); + MergeResult result = service.fetchAndMerge(library); assertTrue(result.isSuccessful()); - String finalContent = Files.readString(bibFile); - assertTrue(finalContent.contains("merged-author")); + String content = Files.readString(library); + assertTrue(content.contains("alice-c + bob-c")); verify(resolver).resolveConflicts(anyList(), any()); } @@ -329,6 +276,19 @@ void readFromCommits() throws Exception { assertEquals(bobUpdatedContent, remote); } + @AfterEach + void cleanup() { + if (git != null) { + git.close(); + } + if (aliceGit != null && aliceGit != git) { + aliceGit.close(); + } + if (bobGit != null) { + bobGit.close(); + } + } + private RevCommit writeAndCommit(String content, String message, PersonIdent author, Path library, Git git) throws Exception { Files.writeString(library, content, StandardCharsets.UTF_8); String relativePath = git.getRepository().getWorkTree().toPath().relativize(library).toString(); From 22f9704bbd33501ab91c3e50d1f351b059582d81 Mon Sep 17 00:00:00 2001 From: Wanling Fu Date: Thu, 17 Jul 2025 00:50:54 +0100 Subject: [PATCH 19/37] test: Fix GitSyncServiceTest by switching to init() + remoteAdd() + push() for setup #12350 --- .../jabref/logic/git/GitSyncServiceTest.java | 22 +++++++++++-------- 1 file changed, 13 insertions(+), 9 deletions(-) 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 115fa89cde5..25d9f7c4146 100644 --- a/jablib/src/test/java/org/jabref/logic/git/GitSyncServiceTest.java +++ b/jablib/src/test/java/org/jabref/logic/git/GitSyncServiceTest.java @@ -20,6 +20,7 @@ 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; @@ -113,25 +114,27 @@ void aliceBobSimple(@TempDir Path tempDir) throws Exception { // Alice clone remote -> local repository aliceDir = tempDir.resolve("alice"); - aliceGit = Git.cloneRepository() - .setURI(remoteDir.toUri().toString()) - .setDirectory(aliceDir.toFile()) - .call(); + aliceGit = Git.init() + .setInitialBranch("main") + .setDirectory(aliceDir.toFile()) + .call(); this.git = aliceGit; 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(); git.push() .setRemote("origin") .setRefSpecs(new RefSpec("refs/heads/main:refs/heads/main")) .call(); - aliceGit.checkout() - .setName("main") - .call(); - // Bob clone remote bobDir = tempDir.resolve("bob"); bobGit = Git.cloneRepository() @@ -140,6 +143,7 @@ void aliceBobSimple(@TempDir Path tempDir) throws Exception { .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() @@ -248,7 +252,7 @@ void mergeConflictOnSameFieldTriggersDialogAndUsesUserResolution(@TempDir Path t ThreeWayEntryConflict conflict = ((List) invocation.getArgument(0)).getFirst(); // In this test, both Alice and Bob independently added a new entry 'c', so the base is null. // We simulate conflict resolution by choosing the remote version and modifying the author field. - BibEntry resolved = ((BibEntry) conflict.remote().clone()); + BibEntry resolved = (BibEntry) conflict.remote().clone(); resolved.setField(StandardField.AUTHOR, "alice-c + bob-c"); BibDatabaseContext merged = GitMergeUtil.replaceEntries(remote, List.of(resolved)); From a0a39cc56a7ee9337ce454d5c3069a6e0a6e245e Mon Sep 17 00:00:00 2001 From: Wanling Fu Date: Thu, 17 Jul 2025 14:18:46 +0100 Subject: [PATCH 20/37] test: Remove redundant git field in GitSyncServiceTest #12350 --- .../jabref/logic/git/GitSyncServiceTest.java | 23 ++++++++----------- 1 file changed, 9 insertions(+), 14 deletions(-) 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 25d9f7c4146..7d927dec951 100644 --- a/jablib/src/test/java/org/jabref/logic/git/GitSyncServiceTest.java +++ b/jablib/src/test/java/org/jabref/logic/git/GitSyncServiceTest.java @@ -36,7 +36,6 @@ import static org.mockito.Mockito.when; class GitSyncServiceTest { - private Git git; private Path library; private Path remoteDir; private Path aliceDir; @@ -112,14 +111,13 @@ void aliceBobSimple(@TempDir Path tempDir) throws Exception { .call(); remoteGit.close(); - // Alice clone remote -> local repository + // Alice init local repository aliceDir = tempDir.resolve("alice"); aliceGit = Git.init() .setInitialBranch("main") .setDirectory(aliceDir.toFile()) .call(); - this.git = aliceGit; this.library = aliceDir.resolve("library.bib"); // Initial commit @@ -130,7 +128,7 @@ void aliceBobSimple(@TempDir Path tempDir) throws Exception { .setUri(new URIish(remoteDir.toUri().toString())) .call(); - git.push() + aliceGit.push() .setRemote("origin") .setRefSpecs(new RefSpec("refs/heads/main:refs/heads/main")) .call(); @@ -153,7 +151,7 @@ void aliceBobSimple(@TempDir Path tempDir) throws Exception { // back to Alice's branch, fetch remote aliceCommit = writeAndCommit(aliceUpdatedContent, "Fix author of a", alice, library, aliceGit); - git.fetch().setRemote("origin").call(); + aliceGit.fetch().setRemote("origin").call(); // Debug hint: Show the created git graph on the command line // git log --graph --oneline --decorate --all --reflog @@ -189,7 +187,7 @@ void pushTriggersMergeAndPushWhenNoConflicts() throws Exception { GitSyncService syncService = new GitSyncService(importFormatPreferences, gitHandler, gitConflictResolverStrategy); syncService.push(library); - String pushedContent = GitFileReader.readFileFromCommit(git, git.log().setMaxCount(1).call().iterator().next(), Path.of("library.bib")); + String pushedContent = GitFileReader.readFileFromCommit(aliceGit, aliceGit.log().setMaxCount(1).call().iterator().next(), Path.of("library.bib")); String expected = """ @article{a, author = {author-a}, @@ -241,7 +239,7 @@ void mergeConflictOnSameFieldTriggersDialogAndUsesUserResolution(@TempDir Path t } """; writeAndCommit(aliceEntry, "Alice adds conflicting article-c", alice, library, aliceGit); - git.fetch().setRemote("origin").call(); + aliceGit.fetch().setRemote("origin").call(); // Setup mock conflict resolver GitConflictResolverStrategy resolver = mock(GitConflictResolverStrategy.class); @@ -271,9 +269,9 @@ void mergeConflictOnSameFieldTriggersDialogAndUsesUserResolution(@TempDir Path t @Test void readFromCommits() throws Exception { - String base = GitFileReader.readFileFromCommit(git, baseCommit, Path.of("library.bib")); - String local = GitFileReader.readFileFromCommit(git, aliceCommit, Path.of("library.bib")); - String remote = GitFileReader.readFileFromCommit(git, bobCommit, Path.of("library.bib")); + String base = GitFileReader.readFileFromCommit(aliceGit, baseCommit, Path.of("library.bib")); + String local = GitFileReader.readFileFromCommit(aliceGit, aliceCommit, Path.of("library.bib")); + String remote = GitFileReader.readFileFromCommit(aliceGit, bobCommit, Path.of("library.bib")); assertEquals(initialContent, base); assertEquals(aliceUpdatedContent, local); @@ -282,10 +280,7 @@ void readFromCommits() throws Exception { @AfterEach void cleanup() { - if (git != null) { - git.close(); - } - if (aliceGit != null && aliceGit != git) { + if (aliceGit != null) { aliceGit.close(); } if (bobGit != null) { From 15aca8488fad713a63e2a1f822085bcd0bd83322 Mon Sep 17 00:00:00 2001 From: Wanling Fu Date: Fri, 25 Jul 2025 12:36:05 +0100 Subject: [PATCH 21/37] fix: Apply trag bot suggestion and fix bug where newly merged entries were not written to file #12350 --- .../gui/git/GitConflictResolverDialog.java | 1 - .../org/jabref/gui/git/GitPullAction.java | 61 +++++++++------- .../org/jabref/gui/git/GitPullViewModel.java | 18 ++++- .../jabref/gui/git/GitStatusViewModel.java | 62 ++++++++++------ .../org/jabref/logic/git/GitSyncService.java | 43 ++++++----- .../conflicts/SemanticConflictDetector.java | 9 +-- .../git/conflicts/ThreeWayEntryConflict.java | 8 +- .../org/jabref/logic/git/io/GitBibParser.java | 12 +++ .../jabref/logic/git/io/GitFileReader.java | 8 +- .../jabref/logic/git/io/GitFileWriter.java | 7 +- .../logic/git/io/GitRevisionLocator.java | 11 ++- .../jabref/logic/git/io/RevisionTriple.java | 11 ++- .../git/merge/GitSemanticMergeExecutor.java | 7 +- .../logic/git/merge/SemanticMerger.java | 16 +++- .../jabref/logic/git/model/MergeResult.java | 2 +- .../logic/git/status/GitStatusChecker.java | 11 ++- .../logic/git/status/GitStatusSnapshot.java | 1 + .../jabref/logic/git/status/SyncStatus.java | 14 ++-- .../jabref/logic/git/GitSyncServiceTest.java | 73 +++++++++++++------ .../logic/git/util/GitBibParserTest.java | 2 +- .../util/SemanticConflictDetectorTest.java | 2 +- 21 files changed, 258 insertions(+), 121 deletions(-) diff --git a/jabgui/src/main/java/org/jabref/gui/git/GitConflictResolverDialog.java b/jabgui/src/main/java/org/jabref/gui/git/GitConflictResolverDialog.java index 485f9076afc..e89302588ff 100644 --- a/jabgui/src/main/java/org/jabref/gui/git/GitConflictResolverDialog.java +++ b/jabgui/src/main/java/org/jabref/gui/git/GitConflictResolverDialog.java @@ -29,7 +29,6 @@ public Optional resolveConflict(ThreeWayEntryConflict conflict) { BibEntry local = conflict.local(); BibEntry remote = conflict.remote(); - // Create Dialog + Set Title + Configure Diff Highlighting MergeEntriesDialog dialog = new MergeEntriesDialog(local, remote, preferences); dialog.setLeftHeaderText("Local"); dialog.setRightHeaderText("Remote"); 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 17d110ac4a9..a420570a818 100644 --- a/jabgui/src/main/java/org/jabref/gui/git/GitPullAction.java +++ b/jabgui/src/main/java/org/jabref/gui/git/GitPullAction.java @@ -13,7 +13,10 @@ import org.jabref.logic.git.GitHandler; import org.jabref.logic.git.GitSyncService; import org.jabref.logic.git.conflicts.GitConflictResolverStrategy; -import org.jabref.logic.git.model.MergeResult; +import org.jabref.logic.git.merge.GitSemanticMergeExecutor; +import org.jabref.logic.git.merge.GitSemanticMergeExecutorImpl; +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; @@ -29,20 +32,22 @@ public class GitPullAction extends SimpleCommand { private final StateManager stateManager; private final GuiPreferences guiPreferences; private final UndoManager undoManager; + private final TaskExecutor taskExecutor; public GitPullAction(DialogService dialogService, StateManager stateManager, GuiPreferences guiPreferences, - UndoManager undoManager) { + UndoManager undoManager, + TaskExecutor taskExecutor) { this.dialogService = dialogService; this.stateManager = stateManager; this.guiPreferences = guiPreferences; this.undoManager = undoManager; + this.taskExecutor = taskExecutor; } @Override public void execute() { - // TODO: reconsider error handling if (stateManager.getActiveDatabase().isEmpty()) { dialogService.showErrorDialogAndWait("No database open", "Please open a database before pulling."); return; @@ -55,29 +60,35 @@ public void execute() { } Path bibFilePath = database.getDatabasePath().get(); - try { - GitHandler handler = new GitHandler(bibFilePath.getParent()); - GitConflictResolverDialog dialog = new GitConflictResolverDialog(dialogService, guiPreferences); - GitConflictResolverStrategy resolver = new GuiConflictResolverStrategy(dialog); + GitHandler handler = new GitHandler(bibFilePath.getParent()); + GitConflictResolverDialog dialog = new GitConflictResolverDialog(dialogService, guiPreferences); + GitConflictResolverStrategy resolver = new GuiConflictResolverStrategy(dialog); + GitSemanticMergeExecutor mergeExecutor = new GitSemanticMergeExecutorImpl(guiPreferences.getImportFormatPreferences()); - GitSyncService syncService = new GitSyncService(guiPreferences.getImportFormatPreferences(), handler, resolver); - GitStatusViewModel statusViewModel = new GitStatusViewModel(bibFilePath); + GitSyncService syncService = new GitSyncService(guiPreferences.getImportFormatPreferences(), handler, resolver, mergeExecutor); + GitStatusViewModel statusViewModel = new GitStatusViewModel(stateManager, bibFilePath); + GitPullViewModel viewModel = new GitPullViewModel(syncService, statusViewModel); - GitPullViewModel viewModel = new GitPullViewModel(syncService, statusViewModel); - MergeResult result = viewModel.pull(); - - if (result.isSuccessful()) { - dialogService.showInformationDialogAndWait("Git Pull", "Successfully merged and updated."); - } else { - dialogService.showWarningDialogAndWait("Git Pull", "Merge completed with conflicts."); - } - } catch (JabRefException e) { - dialogService.showErrorDialogAndWait("Git Pull Failed", e); - // TODO: error handling - } catch (GitAPIException e) { - throw new RuntimeException(e); - } catch (IOException e) { - throw new RuntimeException(e); - } + BackgroundTask + .wrap(() -> viewModel.pull()) + .onSuccess(result -> { + if (result.isSuccessful()) { + dialogService.showInformationDialogAndWait("Git Pull", "Successfully merged and updated."); + } else { + dialogService.showWarningDialogAndWait("Git Pull", "Merge completed with conflicts."); + } + }) + .onFailure(ex -> { + if (ex instanceof JabRefException e) { + dialogService.showErrorDialogAndWait("Git Pull Failed", e.getLocalizedMessage()); + } else if (ex instanceof GitAPIException e) { + dialogService.showErrorDialogAndWait("Git Pull Failed", "An unexpected Git error occurred: " + e.getLocalizedMessage()); + } else if (ex instanceof IOException e) { + dialogService.showErrorDialogAndWait("Git Pull Failed", "I/O error: " + e.getLocalizedMessage()); + } else { + dialogService.showErrorDialogAndWait("Git Pull Failed", "Unexpected error: " + ex.getLocalizedMessage()); + } + }) + .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 index ddaf0e9e3cc..1e982fe2b45 100644 --- a/jabgui/src/main/java/org/jabref/gui/git/GitPullViewModel.java +++ b/jabgui/src/main/java/org/jabref/gui/git/GitPullViewModel.java @@ -2,30 +2,40 @@ 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.model.database.BibDatabaseContext; import org.eclipse.jgit.api.errors.GitAPIException; public class GitPullViewModel extends AbstractViewModel { private final GitSyncService syncService; private final GitStatusViewModel gitStatusViewModel; - private final Path bibFilePath; public GitPullViewModel(GitSyncService syncService, GitStatusViewModel gitStatusViewModel) { this.syncService = syncService; this.gitStatusViewModel = gitStatusViewModel; - this.bibFilePath = gitStatusViewModel.getCurrentBibFile(); } public MergeResult pull() throws IOException, GitAPIException, JabRefException { - MergeResult result = syncService.fetchAndMerge(bibFilePath); + Optional maybeContext = gitStatusViewModel.getDatabaseContext(); + if (maybeContext.isEmpty()) { + throw new JabRefException("Cannot pull: No active BibDatabaseContext."); + } + + BibDatabaseContext localBibDatabaseContext = maybeContext.get(); + Path bibFilePath = localBibDatabaseContext.getDatabasePath().orElseThrow(() -> + new JabRefException("Cannot pull: .bib file path missing in BibDatabaseContext.") + ); + + MergeResult result = syncService.fetchAndMerge(localBibDatabaseContext, bibFilePath); if (result.isSuccessful()) { - gitStatusViewModel.updateStatusFromPath(bibFilePath); + 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 index b22531b7cf4..a9106b157cb 100644 --- a/jabgui/src/main/java/org/jabref/gui/git/GitStatusViewModel.java +++ b/jabgui/src/main/java/org/jabref/gui/git/GitStatusViewModel.java @@ -11,10 +11,12 @@ 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; /** * ViewModel that holds current Git sync status for the open .bib database. @@ -25,36 +27,48 @@ * - The current sync status (e.g., UP_TO_DATE, DIVERGED, etc.) */ public class GitStatusViewModel extends AbstractViewModel { - private final Path currentBibFile; + 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); private final StringProperty lastPulledCommit = new SimpleStringProperty(""); private GitHandler activeHandler = null; - public GitStatusViewModel(Path bibFilePath) { - this.currentBibFile = bibFilePath; - updateStatusFromPath(bibFilePath); - } - - /** - * Try to detect Git repository status from the given file or folder path. - * - * @param fileOrFolderInRepo Any path (file or folder) assumed to be inside a Git repository - */ - public void updateStatusFromPath(Path fileOrFolderInRepo) { - Optional maybeHandler = GitHandler.fromAnyPath(fileOrFolderInRepo); + public GitStatusViewModel(StateManager stateManager, Path bibFilePath) { + this.stateManager = stateManager; + stateManager.activeDatabaseProperty().addListener((obs, oldDb, newDb) -> { + if (newDb != null && newDb.isPresent() && newDb.get().getDatabasePath().isPresent()) { + BibDatabaseContext ctx = newDb.get(); + databaseContext.set(ctx); + updateStatusFromContext(ctx); + } else { + reset(); + } + }); + + stateManager.getActiveDatabase().ifPresent(ctx -> { + databaseContext.set(ctx); + updateStatusFromContext(ctx); + }); + } + + protected void updateStatusFromContext(BibDatabaseContext context) { + Path path = context.getDatabasePath().orElse(null); + if (path == null) { + reset(); + return; + } + Optional maybeHandler = GitHandler.fromAnyPath(path); if (maybeHandler.isEmpty()) { reset(); return; } - GitHandler handler = maybeHandler.get(); - this.activeHandler = handler; - - GitStatusSnapshot snapshot = GitStatusChecker.checkStatus(fileOrFolderInRepo); + this.activeHandler = maybeHandler.get(); + GitStatusSnapshot snapshot = GitStatusChecker.checkStatus(path); setTracking(snapshot.tracking()); setSyncStatus(snapshot.syncStatus()); setConflictDetected(snapshot.conflict()); @@ -72,6 +86,16 @@ public void reset() { setLastPulledCommit(""); } + public Optional getDatabaseContext() { + return Optional.ofNullable(databaseContext.get()); + } + + public Path getCurrentBibFile() { + return getDatabaseContext() + .flatMap(BibDatabaseContext::getDatabasePath) + .orElse(null); + } + public ObjectProperty syncStatusProperty() { return syncStatus; } @@ -123,8 +147,4 @@ public void setLastPulledCommit(String commitHash) { public Optional getActiveHandler() { return Optional.ofNullable(activeHandler); } - - public Path getCurrentBibFile() { - return currentBibFile; - } } 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 6e3a75b7800..da0479e95c2 100644 --- a/jablib/src/main/java/org/jabref/logic/git/GitSyncService.java +++ b/jablib/src/main/java/org/jabref/logic/git/GitSyncService.java @@ -11,11 +11,9 @@ import org.jabref.logic.git.conflicts.ThreeWayEntryConflict; import org.jabref.logic.git.io.GitBibParser; import org.jabref.logic.git.io.GitFileReader; -import org.jabref.logic.git.io.GitFileWriter; import org.jabref.logic.git.io.GitRevisionLocator; import org.jabref.logic.git.io.RevisionTriple; -import org.jabref.logic.git.merge.MergePlan; -import org.jabref.logic.git.merge.SemanticMerger; +import org.jabref.logic.git.merge.GitSemanticMergeExecutor; import org.jabref.logic.git.model.MergeResult; import org.jabref.logic.git.status.GitStatusChecker; import org.jabref.logic.git.status.GitStatusSnapshot; @@ -43,14 +41,16 @@ public class GitSyncService { private final ImportFormatPreferences importFormatPreferences; private final GitHandler gitHandler; private final GitConflictResolverStrategy gitConflictResolverStrategy; + private final GitSemanticMergeExecutor mergeExecutor; - public GitSyncService(ImportFormatPreferences importFormatPreferences, GitHandler gitHandler, GitConflictResolverStrategy gitConflictResolverStrategy) { + 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(Path bibFilePath) throws GitAPIException, IOException, JabRefException { + public MergeResult fetchAndMerge(BibDatabaseContext localDatabaseContext, Path bibFilePath) throws GitAPIException, IOException, JabRefException { Optional maybeHandler = GitHandler.fromAnyPath(bibFilePath); if (maybeHandler.isEmpty()) { LOGGER.warn("Pull aborted: The file is not inside a Git repository."); @@ -69,6 +69,11 @@ public MergeResult fetchAndMerge(Path bibFilePath) throws GitAPIException, IOExc 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(); @@ -83,7 +88,7 @@ public MergeResult fetchAndMerge(Path bibFilePath) throws GitAPIException, IOExc RevisionTriple triple = locator.locateMergeCommits(git); // 3. Perform semantic merge - MergeResult result = performSemanticMerge(git, triple.base(), triple.local(), triple.remote(), bibFilePath); + MergeResult result = performSemanticMerge(git, triple.base(), triple.remote(), localDatabaseContext, bibFilePath); // 4. Auto-commit merge result if successful if (result.isSuccessful()) { @@ -96,8 +101,8 @@ public MergeResult fetchAndMerge(Path bibFilePath) throws GitAPIException, IOExc public MergeResult performSemanticMerge(Git git, RevCommit baseCommit, - RevCommit localCommit, RevCommit remoteCommit, + BibDatabaseContext localDatabaseContext, Path bibFilePath) throws IOException, JabRefException { Path bibPath = bibFilePath.toRealPath(); @@ -110,13 +115,12 @@ public MergeResult performSemanticMerge(Git git, relativePath = workTree.relativize(bibPath); // 1. Load three versions - String baseContent = GitFileReader.readFileFromCommit(git, baseCommit, relativePath); - String localContent = GitFileReader.readFileFromCommit(git, localCommit, relativePath); - String remoteContent = GitFileReader.readFileFromCommit(git, remoteCommit, relativePath); + Optional baseContent = GitFileReader.readFileFromCommit(git, baseCommit, relativePath); + Optional remoteContent = GitFileReader.readFileFromCommit(git, remoteCommit, relativePath); BibDatabaseContext base = GitBibParser.parseBibFromGit(baseContent, importFormatPreferences); - BibDatabaseContext local = GitBibParser.parseBibFromGit(localContent, importFormatPreferences); BibDatabaseContext remote = GitBibParser.parseBibFromGit(remoteContent, importFormatPreferences); + BibDatabaseContext local = localDatabaseContext; // 2. Conflict detection List conflicts = SemanticConflictDetector.detectConflicts(base, local, remote); @@ -135,16 +139,12 @@ public MergeResult performSemanticMerge(Git git, } // 4. Apply resolved remote (either original or conflict-resolved) to local - MergePlan plan = SemanticConflictDetector.extractMergePlan(base, effectiveRemote); - SemanticMerger.applyMergePlan(local, plan); - - // 5. Write back merged result - GitFileWriter.write(bibFilePath, local, importFormatPreferences); + MergeResult result = mergeExecutor.merge(base, local, effectiveRemote, bibFilePath); - return MergeResult.success(); + return result; } - public void push(Path bibFilePath) throws GitAPIException, IOException, JabRefException { + public void push(BibDatabaseContext localDatabaseContext, Path bibFilePath) throws GitAPIException, IOException, JabRefException { GitStatusSnapshot status = GitStatusChecker.checkStatus(bibFilePath); if (!status.tracking()) { @@ -152,6 +152,11 @@ public void push(Path bibFilePath) throws GitAPIException, IOException, JabRefEx 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); @@ -175,7 +180,7 @@ public void push(Path bibFilePath) throws GitAPIException, IOException, JabRefEx GitRevisionLocator locator = new GitRevisionLocator(); RevisionTriple triple = locator.locateMergeCommits(git); - MergeResult mergeResult = performSemanticMerge(git, triple.base(), triple.local(), triple.remote(), bibFilePath); + MergeResult mergeResult = performSemanticMerge(git, triple.base(), triple.remote(), localDatabaseContext, bibFilePath); if (!mergeResult.isSuccessful()) { LOGGER.warn("Semantic merge failed — aborting push"); 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 b10c6d2e6e6..c446164a626 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 @@ -27,9 +27,7 @@ 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(); - if (remoteDiffs == null) { - return List.of(); - } + // 2. map citation key to entry for local/remote diffs Map baseEntries = toEntryMap(base); Map localEntries = toEntryMap(local); @@ -48,12 +46,12 @@ public static List detectConflicts(BibDatabaseContext bas BibEntry localEntry = localEntries.get(citationKey); BibEntry remoteEntry = remoteDiff.newEntry(); - // Conflict 1: if the entry exists in all 3 versions + // 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)); } - // Conflict 2: base missing, but local + remote both added same citation key with different content + // 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)); @@ -126,7 +124,6 @@ public static MergePlan extractMergePlan(BibDatabaseContext base, BibDatabaseCon BibEntry baseEntry = baseMap.get(key); if (baseEntry == null) { - // New entry (not in base) newEntries.add(remoteEntry); } else { Map patch = computeFieldPatch(baseEntry, remoteEntry); 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 index 694d0e210f5..9f0238e1546 100644 --- a/jablib/src/main/java/org/jabref/logic/git/conflicts/ThreeWayEntryConflict.java +++ b/jablib/src/main/java/org/jabref/logic/git/conflicts/ThreeWayEntryConflict.java @@ -6,4 +6,10 @@ public record ThreeWayEntryConflict( BibEntry base, BibEntry local, BibEntry remote -) { } +) { + public ThreeWayEntryConflict { + if (local == null && remote == null) { + throw new IllegalArgumentException("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/GitBibParser.java b/jablib/src/main/java/org/jabref/logic/git/io/GitBibParser.java index 74b59691629..9cd4812b60b 100644 --- a/jablib/src/main/java/org/jabref/logic/git/io/GitBibParser.java +++ b/jablib/src/main/java/org/jabref/logic/git/io/GitBibParser.java @@ -2,12 +2,15 @@ import java.io.IOException; import java.io.Reader; +import java.util.Optional; import org.jabref.logic.JabRefException; import org.jabref.logic.importer.ImportFormatPreferences; import org.jabref.logic.importer.ParserResult; import org.jabref.logic.importer.fileformat.BibtexParser; +import org.jabref.model.database.BibDatabase; import org.jabref.model.database.BibDatabaseContext; +import org.jabref.model.metadata.MetaData; import org.jabref.model.util.DummyFileUpdateMonitor; public class GitBibParser { @@ -22,4 +25,13 @@ public static BibDatabaseContext parseBibFromGit(String bibContent, ImportFormat throw new JabRefException("Failed to parse BibTeX content from Git", e); } } + + public static BibDatabaseContext parseBibFromGit(Optional maybeContent, ImportFormatPreferences prefs) throws JabRefException { + if (maybeContent.isEmpty()) { + BibDatabase emptyDatabase = new BibDatabase(); + MetaData emptyMetaData = new MetaData(); + return new BibDatabaseContext(emptyDatabase, emptyMetaData, null); + } + return parseBibFromGit(maybeContent.get(), prefs); + } } 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 index f5474f01313..6a33a67707b 100644 --- a/jablib/src/main/java/org/jabref/logic/git/io/GitFileReader.java +++ b/jablib/src/main/java/org/jabref/logic/git/io/GitFileReader.java @@ -3,6 +3,7 @@ import java.io.IOException; import java.nio.charset.StandardCharsets; import java.nio.file.Path; +import java.util.Optional; import org.jabref.logic.JabRefException; @@ -18,8 +19,7 @@ import org.jspecify.annotations.NonNull; public class GitFileReader { - // Unit test is in the GitSyncServiceTest - public static String readFileFromCommit(Git git, RevCommit commit, @NonNull Path relativePath) throws JabRefException { + public static Optional readFileFromCommit(Git git, RevCommit commit, @NonNull Path relativePath) throws JabRefException { // ref: https://github.com/centic9/jgit-cookbook/blob/master/src/main/java/org/dstadler/jgit/api/ReadFileFromCommit.java // 1. get commit-pointing tree Repository repository = git.getRepository(); @@ -28,12 +28,12 @@ public static String readFileFromCommit(Git git, RevCommit commit, @NonNull Path // 2. setup TreeWalk + to the target file try (TreeWalk treeWalk = TreeWalk.forPath(repository, relativePath.toString(), commitTree)) { if (treeWalk == null) { - throw new JabRefException("File '" + relativePath + "' not found in commit " + commit.getName()); + return Optional.empty(); } // 3. load blob object ObjectId objectId = treeWalk.getObjectId(0); ObjectLoader loader = repository.open(objectId); - return new String(loader.getBytes(), StandardCharsets.UTF_8); + 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) { 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 index 4f0c8d2076b..357011034cd 100644 --- a/jablib/src/main/java/org/jabref/logic/git/io/GitFileWriter.java +++ b/jablib/src/main/java/org/jabref/logic/git/io/GitFileWriter.java @@ -4,6 +4,7 @@ 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; @@ -14,11 +15,15 @@ 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 (bibDatabaseContext) { + synchronized (lock) { try (AtomicFileWriter fileWriter = new AtomicFileWriter(file, encoding, saveConfiguration.shouldMakeBackup())) { BibWriter bibWriter = new BibWriter(fileWriter, bibDatabaseContext.getDatabase().getNewLineSeparator()); BibDatabaseWriter writer = new BibDatabaseWriter( 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 index 015d73dc05b..73cfdb2f8b8 100644 --- a/jablib/src/main/java/org/jabref/logic/git/io/GitRevisionLocator.java +++ b/jablib/src/main/java/org/jabref/logic/git/io/GitRevisionLocator.java @@ -24,11 +24,12 @@ public class GitRevisionLocator { public RevisionTriple locateMergeCommits(Git git) throws GitAPIException, IOException, JabRefException { Repository repo = git.getRepository(); - // assumes the remote branch is 'origin/main' ObjectId headId = repo.resolve(HEAD); - // and uses the default remote tracking reference - // does not support multiple remotes or custom remote branch names so far ObjectId remoteId = repo.resolve(REMOTE); + + if (headId == null) { + throw new IllegalStateException("Local HEAD commit is missing."); + } if (remoteId == null) { throw new IllegalStateException("Remote branch missing origin/main."); } @@ -38,6 +39,10 @@ public RevisionTriple locateMergeCommits(Git git) throws GitAPIException, IOExce RevCommit remote = walk.parseCommit(remoteId); RevCommit base = findMergeBase(repo, local, remote); + if (base == null) { + throw new IllegalStateException("Could not determine merge base between local and remote."); + } + return new RevisionTriple(base, local, remote); } } 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 index 8b9d38fdcf1..5a39aa35832 100644 --- a/jablib/src/main/java/org/jabref/logic/git/io/RevisionTriple.java +++ b/jablib/src/main/java/org/jabref/logic/git/io/RevisionTriple.java @@ -1,6 +1,7 @@ package org.jabref.logic.git.io; import org.eclipse.jgit.revwalk.RevCommit; +import org.jspecify.annotations.NonNull; /** * Holds the three relevant commits involved in a semantic three-way merge, @@ -10,4 +11,12 @@ * @param local the current local branch tip * @param remote the tip of the remote tracking branch (typically origin/main) */ -public record RevisionTriple(RevCommit base, RevCommit local, RevCommit remote) { } +public record RevisionTriple(RevCommit base, + @NonNull RevCommit local, + @NonNull RevCommit remote) { + public RevisionTriple { + if (local == null || remote == null) { + throw new NullPointerException("local and remote commits must not be null"); + } + } +} 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 index 6067cbc8353..615dc9360e8 100644 --- a/jablib/src/main/java/org/jabref/logic/git/merge/GitSemanticMergeExecutor.java +++ b/jablib/src/main/java/org/jabref/logic/git/merge/GitSemanticMergeExecutor.java @@ -6,6 +6,8 @@ import org.jabref.logic.git.model.MergeResult; import org.jabref.model.database.BibDatabaseContext; +import org.jspecify.annotations.NonNull; + public interface GitSemanticMergeExecutor { /** @@ -18,5 +20,8 @@ public interface GitSemanticMergeExecutor { * @param bibFilePath The path to the target bib file (used for write-back) * @return MergeResult object containing merge status */ - MergeResult merge(BibDatabaseContext base, BibDatabaseContext local, BibDatabaseContext remote, Path bibFilePath) throws IOException; + MergeResult merge(BibDatabaseContext base, + @NonNull BibDatabaseContext local, + @NonNull BibDatabaseContext remote, + @NonNull Path bibFilePath) throws IOException; } 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 index efc906391c7..55301cacf89 100644 --- a/jablib/src/main/java/org/jabref/logic/git/merge/SemanticMerger.java +++ b/jablib/src/main/java/org/jabref/logic/git/merge/SemanticMerger.java @@ -23,9 +23,21 @@ public static void applyMergePlan(BibDatabaseContext local, MergePlan plan) { applyPatchToDatabase(local, plan.fieldPatches()); for (BibEntry newEntry : plan.newEntries()) { - BibEntry clone = (BibEntry) newEntry.clone(); + BibEntry clone = new BibEntry(newEntry.getType()).withFields(newEntry.getFieldMap()); + newEntry.getCitationKey().ifPresent(clone::withCitationKey); + + 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 new entry '{}'", newEntry.getCitationKey().orElse("?")); + clone.setChanged(true); + LOGGER.debug("Inserted (or replaced) entry '{}', fields={}, marked as changed", + clone.getCitationKey().orElse("?"), + clone.getFieldMap()); } } 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 index ef2dd4b0391..8a581abf4c8 100644 --- a/jablib/src/main/java/org/jabref/logic/git/model/MergeResult.java +++ b/jablib/src/main/java/org/jabref/logic/git/model/MergeResult.java @@ -5,7 +5,7 @@ import org.jabref.logic.bibtex.comparator.BibEntryDiff; public record MergeResult(boolean isSuccessful, List conflicts) { - private static boolean SUCCESS = true; + private final static boolean SUCCESS = true; public static MergeResult withConflicts(List conflicts) { return new MergeResult(!SUCCESS, conflicts); } 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 a80c2ecd49a..0bae7a20ce3 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 @@ -29,7 +29,13 @@ public static GitStatusSnapshot checkStatus(Path anyPathInsideRepo) { Optional maybeHandler = GitHandler.fromAnyPath(anyPathInsideRepo); if (maybeHandler.isEmpty()) { - return new GitStatusSnapshot(false, SyncStatus.UNTRACKED, false, Optional.empty()); + return new GitStatusSnapshot( + false, + SyncStatus.UNTRACKED, + false, + false, + Optional.empty() + ); } GitHandler handler = maybeHandler.get(); @@ -37,6 +43,7 @@ public static GitStatusSnapshot checkStatus(Path anyPathInsideRepo) { Repository repo = git.getRepository(); Status status = git.status().call(); boolean hasConflict = !status.getConflicting().isEmpty(); + boolean hasUncommittedChanges = !status.isClean(); ObjectId localHead = repo.resolve("HEAD"); ObjectId remoteHead = repo.resolve("refs/remotes/origin/main"); @@ -46,6 +53,7 @@ public static GitStatusSnapshot checkStatus(Path anyPathInsideRepo) { true, syncStatus, hasConflict, + hasUncommittedChanges, Optional.ofNullable(localHead).map(ObjectId::getName) ); } catch (IOException | GitAPIException e) { @@ -54,6 +62,7 @@ public static GitStatusSnapshot checkStatus(Path anyPathInsideRepo) { true, SyncStatus.UNKNOWN, false, + false, Optional.empty() ); } 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 index 2b28d23ee47..d2a27749595 100644 --- a/jablib/src/main/java/org/jabref/logic/git/status/GitStatusSnapshot.java +++ b/jablib/src/main/java/org/jabref/logic/git/status/GitStatusSnapshot.java @@ -6,4 +6,5 @@ public record GitStatusSnapshot( boolean tracking, SyncStatus syncStatus, boolean conflict, + boolean uncommittedChanges, Optional lastPulledCommit) { } diff --git a/jablib/src/main/java/org/jabref/logic/git/status/SyncStatus.java b/jablib/src/main/java/org/jabref/logic/git/status/SyncStatus.java index fbd3691bacb..c70580eb27e 100644 --- a/jablib/src/main/java/org/jabref/logic/git/status/SyncStatus.java +++ b/jablib/src/main/java/org/jabref/logic/git/status/SyncStatus.java @@ -1,11 +1,11 @@ package org.jabref.logic.git.status; public enum SyncStatus { - UP_TO_DATE, // Local and remote are in sync - BEHIND, // Local is behind remote, pull needed - AHEAD, // Local is ahead of remote, push needed - DIVERGED, // Both local and remote have new commits; merge required - CONFLICT, // Merge conflict detected - UNTRACKED, // Not under Git control - UNKNOWN // Status couldn't be determined + UP_TO_DATE, + BEHIND, + AHEAD, + DIVERGED, + CONFLICT, + UNTRACKED, + UNKNOWN } 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 7d927dec951..b93ab3ed14e 100644 --- a/jablib/src/test/java/org/jabref/logic/git/GitSyncServiceTest.java +++ b/jablib/src/test/java/org/jabref/logic/git/GitSyncServiceTest.java @@ -1,5 +1,6 @@ package org.jabref.logic.git; +import java.io.StringReader; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; @@ -10,8 +11,12 @@ import org.jabref.logic.git.conflicts.ThreeWayEntryConflict; import org.jabref.logic.git.io.GitFileReader; import org.jabref.logic.git.merge.GitMergeUtil; +import org.jabref.logic.git.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; @@ -44,6 +49,8 @@ class GitSyncServiceTest { private Git bobGit; private ImportFormatPreferences importFormatPreferences; private GitConflictResolverStrategy gitConflictResolverStrategy; + private GitSemanticMergeExecutor mergeExecutor; + private BibDatabaseContext context; // These are setup by aliceBobSetting private RevCommit baseCommit; @@ -100,7 +107,9 @@ class GitSyncServiceTest { 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"); @@ -153,6 +162,11 @@ void aliceBobSimple(@TempDir Path tempDir) throws Exception { 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(new StringReader(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 } @@ -160,8 +174,8 @@ void aliceBobSimple(@TempDir Path tempDir) throws Exception { @Test void pullTriggersSemanticMergeWhenNoConflicts() throws Exception { GitHandler gitHandler = new GitHandler(library.getParent()); - GitSyncService syncService = new GitSyncService(importFormatPreferences, gitHandler, gitConflictResolverStrategy); - MergeResult result = syncService.fetchAndMerge(library); + GitSyncService syncService = new GitSyncService(importFormatPreferences, gitHandler, gitConflictResolverStrategy, mergeExecutor); + MergeResult result = syncService.fetchAndMerge(context, library); assertTrue(result.isSuccessful()); String merged = Files.readString(library); @@ -184,10 +198,12 @@ void pullTriggersSemanticMergeWhenNoConflicts() throws Exception { @Test void pushTriggersMergeAndPushWhenNoConflicts() throws Exception { GitHandler gitHandler = new GitHandler(library.getParent()); - GitSyncService syncService = new GitSyncService(importFormatPreferences, gitHandler, gitConflictResolverStrategy); - syncService.push(library); + 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")); + 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}, @@ -205,42 +221,49 @@ void pushTriggersMergeAndPushWhenNoConflicts() throws Exception { @Test void mergeConflictOnSameFieldTriggersDialogAndUsesUserResolution(@TempDir Path tempDir) throws Exception { - // Bob adds entry c Path bobLibrary = bobDir.resolve("library.bib"); String bobEntry = """ - @article{b, + @article{b, author = {author-b}, doi = {xyz}, } + @article{a, - author = {author-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(); - // Alice adds conflicting version of c String aliceEntry = """ - @article{b, - author = {author-b}, - doi = {xyz}, - } @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}, + 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(new StringReader(actualContent)); + context = new BibDatabaseContext(parsed.getDatabase(), parsed.getMetaData()); + context.setDatabasePath(library); + // Setup mock conflict resolver GitConflictResolverStrategy resolver = mock(GitConflictResolverStrategy.class); when(resolver.resolveConflicts(anyList(), any())).thenAnswer(invocation -> { @@ -258,8 +281,8 @@ void mergeConflictOnSameFieldTriggersDialogAndUsesUserResolution(@TempDir Path t }); GitHandler handler = new GitHandler(aliceDir); - GitSyncService service = new GitSyncService(importFormatPreferences, handler, resolver); - MergeResult result = service.fetchAndMerge(library); + GitSyncService service = new GitSyncService(importFormatPreferences, handler, resolver, mergeExecutor); + MergeResult result = service.fetchAndMerge(context, library); assertTrue(result.isSuccessful()); String content = Files.readString(library); @@ -269,9 +292,17 @@ void mergeConflictOnSameFieldTriggersDialogAndUsesUserResolution(@TempDir Path t @Test void readFromCommits() throws Exception { - String base = GitFileReader.readFileFromCommit(aliceGit, baseCommit, Path.of("library.bib")); - String local = GitFileReader.readFileFromCommit(aliceGit, aliceCommit, Path.of("library.bib")); - String remote = GitFileReader.readFileFromCommit(aliceGit, bobCommit, Path.of("library.bib")); + 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); diff --git a/jablib/src/test/java/org/jabref/logic/git/util/GitBibParserTest.java b/jablib/src/test/java/org/jabref/logic/git/util/GitBibParserTest.java index cb9434527af..36987211825 100644 --- a/jablib/src/test/java/org/jabref/logic/git/util/GitBibParserTest.java +++ b/jablib/src/test/java/org/jabref/logic/git/util/GitBibParserTest.java @@ -57,7 +57,7 @@ void setup(@TempDir Path tempDir) throws Exception { @Test void parsesBibContentFromCommit() throws Exception { - String rawBib = GitFileReader.readFileFromCommit(git, commit, Path.of("library.bib")); + String rawBib = GitFileReader.readFileFromCommit(git, commit, Path.of("library.bib")).orElse(""); BibDatabaseContext context = GitBibParser.parseBibFromGit(rawBib, importFormatPreferences); 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 ec1c3c79639..a8878012549 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 @@ -75,7 +75,7 @@ void semanticConflicts(String description, String base, String local, String rem } private BibDatabaseContext parse(RevCommit commit) throws Exception { - String content = GitFileReader.readFileFromCommit(git, commit, Path.of("library.bib")); + String content = GitFileReader.readFileFromCommit(git, commit, Path.of("library.bib")).orElse(""); return GitBibParser.parseBibFromGit(content, importFormatPreferences); } From 1e13ffd0763c9844be68aba2382067bac988f4ca Mon Sep 17 00:00:00 2001 From: Wanling Fu Date: Fri, 25 Jul 2025 12:53:27 +0100 Subject: [PATCH 22/37] fix: Resolve Modernizer violations #12350 --- .../test/java/org/jabref/logic/git/GitSyncServiceTest.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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 b93ab3ed14e..adfee8273f9 100644 --- a/jablib/src/test/java/org/jabref/logic/git/GitSyncServiceTest.java +++ b/jablib/src/test/java/org/jabref/logic/git/GitSyncServiceTest.java @@ -1,6 +1,6 @@ package org.jabref.logic.git; -import java.io.StringReader; +import java.io.Reader; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; @@ -163,7 +163,7 @@ void aliceBobSimple(@TempDir Path tempDir) throws Exception { aliceGit.fetch().setRemote("origin").call(); String actualContent = Files.readString(library); - ParserResult parsed = new BibtexParser(importFormatPreferences).parse(new StringReader(actualContent)); + ParserResult parsed = new BibtexParser(importFormatPreferences).parse(Reader.of(actualContent)); context = new BibDatabaseContext(parsed.getDatabase(), parsed.getMetaData()); context.setDatabasePath(library); @@ -260,7 +260,7 @@ void mergeConflictOnSameFieldTriggersDialogAndUsesUserResolution(@TempDir Path t aliceGit.fetch().setRemote("origin").call(); String actualContent = Files.readString(library); - ParserResult parsed = new BibtexParser(importFormatPreferences).parse(new StringReader(actualContent)); + ParserResult parsed = new BibtexParser(importFormatPreferences).parse(Reader.of(actualContent)); context = new BibDatabaseContext(parsed.getDatabase(), parsed.getMetaData()); context.setDatabasePath(library); From aaeac7c072f650bf1d6ba1c70ba694ceb1bc778f Mon Sep 17 00:00:00 2001 From: Wanling Fu Date: Fri, 25 Jul 2025 15:39:47 +0100 Subject: [PATCH 23/37] chore: Apply valid trag-bot suggestions #12350 --- .../org/jabref/gui/git/GitPullAction.java | 10 --- .../jabref/gui/git/GitStatusViewModel.java | 11 ++- .../gui/git/GuiConflictResolverStrategy.java | 6 +- .../org/jabref/logic/git/GitSyncService.java | 8 ++- .../CliConflictResolverStrategy.java | 5 +- .../GitConflictResolverStrategy.java | 5 +- .../conflicts/SemanticConflictDetector.java | 69 +++++++++---------- .../org/jabref/logic/git/io/GitBibParser.java | 2 +- .../logic/git/io/GitRevisionLocator.java | 8 +-- .../jabref/logic/git/io/RevisionTriple.java | 2 +- .../jabref/logic/git/GitSyncServiceTest.java | 14 ++-- 11 files changed, 57 insertions(+), 83 deletions(-) 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 a420570a818..ca9ff7d65ab 100644 --- a/jabgui/src/main/java/org/jabref/gui/git/GitPullAction.java +++ b/jabgui/src/main/java/org/jabref/gui/git/GitPullAction.java @@ -3,8 +3,6 @@ import java.io.IOException; import java.nio.file.Path; -import javax.swing.undo.UndoManager; - import org.jabref.gui.DialogService; import org.jabref.gui.StateManager; import org.jabref.gui.actions.SimpleCommand; @@ -21,28 +19,20 @@ import org.eclipse.jgit.api.errors.GitAPIException; -/** - * - Check if Git is enabled - * - Verify activeDatabase is not null - * - Call GitPullViewModel.pull() - */ public class GitPullAction extends SimpleCommand { private final DialogService dialogService; private final StateManager stateManager; private final GuiPreferences guiPreferences; - private final UndoManager undoManager; private final TaskExecutor taskExecutor; public GitPullAction(DialogService dialogService, StateManager stateManager, GuiPreferences guiPreferences, - UndoManager undoManager, TaskExecutor taskExecutor) { this.dialogService = dialogService; this.stateManager = stateManager; this.guiPreferences = guiPreferences; - this.undoManager = undoManager; this.taskExecutor = taskExecutor; } 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 a9106b157cb..85ee3c0f4dd 100644 --- a/jabgui/src/main/java/org/jabref/gui/git/GitStatusViewModel.java +++ b/jabgui/src/main/java/org/jabref/gui/git/GitStatusViewModel.java @@ -33,7 +33,7 @@ public class GitStatusViewModel extends AbstractViewModel { private final BooleanProperty isTracking = new SimpleBooleanProperty(false); private final BooleanProperty conflictDetected = new SimpleBooleanProperty(false); private final StringProperty lastPulledCommit = new SimpleStringProperty(""); - private GitHandler activeHandler = null; + private Optional activeHandler = Optional.empty(); public GitStatusViewModel(StateManager stateManager, Path bibFilePath) { this.stateManager = stateManager; @@ -66,7 +66,7 @@ protected void updateStatusFromContext(BibDatabaseContext context) { return; } - this.activeHandler = maybeHandler.get(); + this.activeHandler = maybeHandler; GitStatusSnapshot snapshot = GitStatusChecker.checkStatus(path); setTracking(snapshot.tracking()); @@ -90,10 +90,9 @@ public Optional getDatabaseContext() { return Optional.ofNullable(databaseContext.get()); } - public Path getCurrentBibFile() { + public Optional getCurrentBibFile() { return getDatabaseContext() - .flatMap(BibDatabaseContext::getDatabasePath) - .orElse(null); + .flatMap(BibDatabaseContext::getDatabasePath); } public ObjectProperty syncStatusProperty() { @@ -145,6 +144,6 @@ public void setLastPulledCommit(String commitHash) { } public Optional getActiveHandler() { - return Optional.ofNullable(activeHandler); + return activeHandler; } } diff --git a/jabgui/src/main/java/org/jabref/gui/git/GuiConflictResolverStrategy.java b/jabgui/src/main/java/org/jabref/gui/git/GuiConflictResolverStrategy.java index ce3178ab0d6..9db7c7fc7b8 100644 --- a/jabgui/src/main/java/org/jabref/gui/git/GuiConflictResolverStrategy.java +++ b/jabgui/src/main/java/org/jabref/gui/git/GuiConflictResolverStrategy.java @@ -6,8 +6,6 @@ import org.jabref.logic.git.conflicts.GitConflictResolverStrategy; import org.jabref.logic.git.conflicts.ThreeWayEntryConflict; -import org.jabref.logic.git.merge.GitMergeUtil; -import org.jabref.model.database.BibDatabaseContext; import org.jabref.model.entry.BibEntry; public class GuiConflictResolverStrategy implements GitConflictResolverStrategy { @@ -18,7 +16,7 @@ public GuiConflictResolverStrategy(GitConflictResolverDialog dialog) { } @Override - public Optional resolveConflicts(List conflicts, BibDatabaseContext remote) { + public Optional> resolveConflicts(List conflicts) { List resolved = new ArrayList<>(); for (ThreeWayEntryConflict conflict : conflicts) { Optional maybeConflict = dialog.resolveConflict(conflict); @@ -27,6 +25,6 @@ public Optional resolveConflicts(List } resolved.add(maybeConflict.get()); } - return Optional.of(GitMergeUtil.replaceEntries(remote, resolved)); + return Optional.of(resolved); } } 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 da0479e95c2..ce9b4c7a9b8 100644 --- a/jablib/src/main/java/org/jabref/logic/git/GitSyncService.java +++ b/jablib/src/main/java/org/jabref/logic/git/GitSyncService.java @@ -13,6 +13,7 @@ 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; @@ -20,6 +21,7 @@ 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; @@ -130,12 +132,12 @@ public MergeResult performSemanticMerge(Git git, effectiveRemote = remote; } else { // 3. If there are conflicts, ask strategy to resolve - Optional maybeRemote = gitConflictResolverStrategy.resolveConflicts(conflicts, remote); - if (maybeRemote.isEmpty()) { + Optional> maybeResolved = gitConflictResolverStrategy.resolveConflicts(conflicts); + if (maybeResolved.isEmpty()) { LOGGER.warn("Merge aborted: Conflict resolution was canceled or denied."); return MergeResult.failure(); } - effectiveRemote = maybeRemote.get(); + effectiveRemote = GitMergeUtil.replaceEntries(remote, maybeResolved.get()); } // 4. Apply resolved remote (either original or conflict-resolved) to local diff --git a/jablib/src/main/java/org/jabref/logic/git/conflicts/CliConflictResolverStrategy.java b/jablib/src/main/java/org/jabref/logic/git/conflicts/CliConflictResolverStrategy.java index 24b6afd30c4..529bf3fd4d4 100644 --- a/jablib/src/main/java/org/jabref/logic/git/conflicts/CliConflictResolverStrategy.java +++ b/jablib/src/main/java/org/jabref/logic/git/conflicts/CliConflictResolverStrategy.java @@ -3,12 +3,11 @@ import java.util.List; import java.util.Optional; -import org.jabref.model.database.BibDatabaseContext; +import org.jabref.model.entry.BibEntry; public class CliConflictResolverStrategy implements GitConflictResolverStrategy { - @Override - public Optional resolveConflicts(List conflicts, BibDatabaseContext remote) { + public Optional> resolveConflicts(List conflicts) { return Optional.empty(); } } diff --git a/jablib/src/main/java/org/jabref/logic/git/conflicts/GitConflictResolverStrategy.java b/jablib/src/main/java/org/jabref/logic/git/conflicts/GitConflictResolverStrategy.java index 392497ce819..21ebeee9a44 100644 --- a/jablib/src/main/java/org/jabref/logic/git/conflicts/GitConflictResolverStrategy.java +++ b/jablib/src/main/java/org/jabref/logic/git/conflicts/GitConflictResolverStrategy.java @@ -3,16 +3,15 @@ import java.util.List; import java.util.Optional; -import org.jabref.model.database.BibDatabaseContext; +import org.jabref.model.entry.BibEntry; public interface GitConflictResolverStrategy { /** * Resolves all given entry-level semantic conflicts, and produces a new, resolved remote state. * * @param conflicts the list of detected three-way entry conflicts - * @param remote the original remote state * @return the modified BibDatabaseContext containing resolved entries, * or empty if user canceled merge or CLI refuses to merge. */ - Optional resolveConflicts(List conflicts, BibDatabaseContext remote); + Optional> 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 index c446164a626..941a0f10c73 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 @@ -1,16 +1,14 @@ package org.jabref.logic.git.conflicts; import java.util.ArrayList; -import java.util.HashSet; import java.util.LinkedHashMap; -import java.util.LinkedHashSet; import java.util.List; import java.util.Map; import java.util.Objects; import java.util.Optional; -import java.util.Set; import java.util.function.Function; import java.util.stream.Collectors; +import java.util.stream.Stream; import org.jabref.logic.bibtex.comparator.BibDatabaseDiff; import org.jabref.logic.bibtex.comparator.BibEntryDiff; @@ -81,25 +79,19 @@ public static Map toEntryMap(BibDatabaseContext context) { } private static boolean hasConflictingFields(BibEntry base, BibEntry local, BibEntry remote) { - // Go through union of all fields - Set fields = new HashSet<>(); - fields.addAll(base.getFields()); - fields.addAll(local.getFields()); - fields.addAll(remote.getFields()); - - for (Field field : fields) { - String baseVal = base.getField(field).orElse(null); - String localVal = local.getField(field).orElse(null); - String remoteVal = remote.getField(field).orElse(null); - - boolean localChanged = !Objects.equals(baseVal, localVal); - boolean remoteChanged = !Objects.equals(baseVal, remoteVal); - - if (localChanged && remoteChanged && !Objects.equals(localVal, remoteVal)) { - return true; - } - } - return false; + 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); + }); } /** @@ -138,23 +130,24 @@ public static MergePlan extractMergePlan(BibDatabaseContext base, BibDatabaseCon /** * 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<>(); - - Set allFields = new LinkedHashSet<>(); - allFields.addAll(base.getFields()); - allFields.addAll(remote.getFields()); - - for (Field field : allFields) { - String baseValue = base.getField(field).orElse(null); - String remoteValue = remote.getField(field).orElse(null); - - if (!Objects.equals(baseValue, remoteValue)) { - patch.put(field, remoteValue); - } - } - - return patch; + return Stream.concat(base.getFields().stream(), remote.getFields().stream()) + .distinct() + .filter(field -> { + String baseValue = base.getField(field).orElse(null); + String remoteValue = remote.getField(field).orElse(null); + return !Objects.equals(baseValue, remoteValue); + }) + .collect(Collectors.toMap( + field -> field, + field -> remote.getField(field).orElse(null), + (existing, replacement) -> replacement, + LinkedHashMap::new + )); } } diff --git a/jablib/src/main/java/org/jabref/logic/git/io/GitBibParser.java b/jablib/src/main/java/org/jabref/logic/git/io/GitBibParser.java index 9cd4812b60b..823af9629c3 100644 --- a/jablib/src/main/java/org/jabref/logic/git/io/GitBibParser.java +++ b/jablib/src/main/java/org/jabref/logic/git/io/GitBibParser.java @@ -30,7 +30,7 @@ public static BibDatabaseContext parseBibFromGit(Optional maybeContent, if (maybeContent.isEmpty()) { BibDatabase emptyDatabase = new BibDatabase(); MetaData emptyMetaData = new MetaData(); - return new BibDatabaseContext(emptyDatabase, emptyMetaData, null); + return new BibDatabaseContext(emptyDatabase, emptyMetaData); } return parseBibFromGit(maybeContent.get(), prefs); } 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 index 73cfdb2f8b8..758da15479d 100644 --- a/jablib/src/main/java/org/jabref/logic/git/io/GitRevisionLocator.java +++ b/jablib/src/main/java/org/jabref/logic/git/io/GitRevisionLocator.java @@ -13,10 +13,10 @@ import org.eclipse.jgit.revwalk.filter.RevFilter; /** - * Find the base/local/remote three commits: - * base = merge-base of HEAD and origin/main - * local = HEAD - * remote = origin/main + * 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 { private static final String HEAD = "HEAD"; 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 index 5a39aa35832..d4c6234cf74 100644 --- a/jablib/src/main/java/org/jabref/logic/git/io/RevisionTriple.java +++ b/jablib/src/main/java/org/jabref/logic/git/io/RevisionTriple.java @@ -16,7 +16,7 @@ public record RevisionTriple(RevCommit base, @NonNull RevCommit remote) { public RevisionTriple { if (local == null || remote == null) { - throw new NullPointerException("local and remote commits must not be null"); + throw new IllegalArgumentException("local and remote commits must not be null"); } } } 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 adfee8273f9..954533f8530 100644 --- a/jablib/src/test/java/org/jabref/logic/git/GitSyncServiceTest.java +++ b/jablib/src/test/java/org/jabref/logic/git/GitSyncServiceTest.java @@ -10,7 +10,6 @@ import org.jabref.logic.git.conflicts.GitConflictResolverStrategy; import org.jabref.logic.git.conflicts.ThreeWayEntryConflict; import org.jabref.logic.git.io.GitFileReader; -import org.jabref.logic.git.merge.GitMergeUtil; import org.jabref.logic.git.merge.GitSemanticMergeExecutor; import org.jabref.logic.git.merge.GitSemanticMergeExecutorImpl; import org.jabref.logic.git.model.MergeResult; @@ -34,7 +33,6 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertTrue; -import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyList; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; @@ -266,18 +264,14 @@ void mergeConflictOnSameFieldTriggersDialogAndUsesUserResolution(@TempDir Path t // Setup mock conflict resolver GitConflictResolverStrategy resolver = mock(GitConflictResolverStrategy.class); - when(resolver.resolveConflicts(anyList(), any())).thenAnswer(invocation -> { + when(resolver.resolveConflicts(anyList())).thenAnswer(invocation -> { List conflicts = invocation.getArgument(0); - BibDatabaseContext remote = invocation.getArgument(1); - - ThreeWayEntryConflict conflict = ((List) invocation.getArgument(0)).getFirst(); + 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 = (BibEntry) conflict.remote().clone(); resolved.setField(StandardField.AUTHOR, "alice-c + bob-c"); - - BibDatabaseContext merged = GitMergeUtil.replaceEntries(remote, List.of(resolved)); - return Optional.of(merged); + return Optional.of(List.of(resolved)); }); GitHandler handler = new GitHandler(aliceDir); @@ -287,7 +281,7 @@ void mergeConflictOnSameFieldTriggersDialogAndUsesUserResolution(@TempDir Path t assertTrue(result.isSuccessful()); String content = Files.readString(library); assertTrue(content.contains("alice-c + bob-c")); - verify(resolver).resolveConflicts(anyList(), any()); + verify(resolver).resolveConflicts(anyList()); } @Test From fc67d0d16e287b421ffeecd32c9865dde34dbe42 Mon Sep 17 00:00:00 2001 From: Wanling Fu Date: Fri, 25 Jul 2025 17:48:15 +0100 Subject: [PATCH 24/37] chore: Apply valid trag-bot suggestions in logic module; partially update GUI for integration preview #12350 --- .../org/jabref/gui/git/GitStatusViewModel.java | 11 ++++++----- .../git/conflicts/SemanticConflictDetector.java | 14 ++++++++++---- .../org/jabref/logic/git/io/RevisionTriple.java | 3 ++- 3 files changed, 18 insertions(+), 10 deletions(-) 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 85ee3c0f4dd..fcf2d21c42a 100644 --- a/jabgui/src/main/java/org/jabref/gui/git/GitStatusViewModel.java +++ b/jabgui/src/main/java/org/jabref/gui/git/GitStatusViewModel.java @@ -33,7 +33,7 @@ public class GitStatusViewModel extends AbstractViewModel { private final BooleanProperty isTracking = new SimpleBooleanProperty(false); private final BooleanProperty conflictDetected = new SimpleBooleanProperty(false); private final StringProperty lastPulledCommit = new SimpleStringProperty(""); - private Optional activeHandler = Optional.empty(); + private GitHandler activeHandler = null; public GitStatusViewModel(StateManager stateManager, Path bibFilePath) { this.stateManager = stateManager; @@ -60,13 +60,13 @@ protected void updateStatusFromContext(BibDatabaseContext context) { return; } - Optional maybeHandler = GitHandler.fromAnyPath(path); - if (maybeHandler.isEmpty()) { + GitHandler handler = GitHandler.fromAnyPath(path).orElse(null); + if (handler == null) { reset(); return; } - this.activeHandler = maybeHandler; + this.activeHandler = handler; GitStatusSnapshot snapshot = GitStatusChecker.checkStatus(path); setTracking(snapshot.tracking()); @@ -80,6 +80,7 @@ protected void updateStatusFromContext(BibDatabaseContext context) { * Should be called when switching projects or Git context is lost */ public void reset() { + activeHandler = null; setSyncStatus(SyncStatus.UNTRACKED); setTracking(false); setConflictDetected(false); @@ -144,6 +145,6 @@ public void setLastPulledCommit(String commitHash) { } public Optional getActiveHandler() { - return activeHandler; + return Optional.ofNullable(activeHandler); } } 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 941a0f10c73..4d8866f8e77 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 @@ -17,11 +17,17 @@ 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: + * Entries without citation keys are ignored (cannot be matched). + */ public class SemanticConflictDetector { - /** - * result := local + remoteDiff - * and then create merge commit having result as file content and local and remote branch as parent - */ public static List detectConflicts(BibDatabaseContext base, BibDatabaseContext local, BibDatabaseContext remote) { // 1. get diffs between base and remote List remoteDiffs = BibDatabaseDiff.compare(base, remote).getEntryDifferences(); 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 index d4c6234cf74..b9a5ad14e52 100644 --- a/jablib/src/main/java/org/jabref/logic/git/io/RevisionTriple.java +++ b/jablib/src/main/java/org/jabref/logic/git/io/RevisionTriple.java @@ -2,6 +2,7 @@ import org.eclipse.jgit.revwalk.RevCommit; import org.jspecify.annotations.NonNull; +import org.jspecify.annotations.Nullable; /** * Holds the three relevant commits involved in a semantic three-way merge, @@ -11,7 +12,7 @@ * @param local the current local branch tip * @param remote the tip of the remote tracking branch (typically origin/main) */ -public record RevisionTriple(RevCommit base, +public record RevisionTriple(@Nullable RevCommit base, @NonNull RevCommit local, @NonNull RevCommit remote) { public RevisionTriple { From c721298768272643e3b9be0339a7f08c80ac60b2 Mon Sep 17 00:00:00 2001 From: Wanling Fu Date: Fri, 25 Jul 2025 18:56:17 +0100 Subject: [PATCH 25/37] chore: Apply valid trag-bot suggestions (change RevisionTriple.base to Optional) #12350 --- .../gui/git/GuiConflictResolverStrategy.java | 8 +++----- .../java/org/jabref/logic/git/GitSyncService.java | 14 ++++++++++---- .../jabref/logic/git/io/GitRevisionLocator.java | 3 ++- .../org/jabref/logic/git/io/RevisionTriple.java | 11 ++++++----- .../logic/git/util/GitRevisionLocatorTest.java | 4 +++- 5 files changed, 24 insertions(+), 16 deletions(-) diff --git a/jabgui/src/main/java/org/jabref/gui/git/GuiConflictResolverStrategy.java b/jabgui/src/main/java/org/jabref/gui/git/GuiConflictResolverStrategy.java index 9db7c7fc7b8..eb5b53f6e4d 100644 --- a/jabgui/src/main/java/org/jabref/gui/git/GuiConflictResolverStrategy.java +++ b/jabgui/src/main/java/org/jabref/gui/git/GuiConflictResolverStrategy.java @@ -19,11 +19,9 @@ public GuiConflictResolverStrategy(GitConflictResolverDialog dialog) { public Optional> resolveConflicts(List conflicts) { List resolved = new ArrayList<>(); for (ThreeWayEntryConflict conflict : conflicts) { - Optional maybeConflict = dialog.resolveConflict(conflict); - if (maybeConflict.isEmpty()) { - return Optional.empty(); - } - resolved.add(maybeConflict.get()); + BibEntry entry = dialog.resolveConflict(conflict) + .orElseThrow(() -> new IllegalStateException("Conflict resolution was cancelled")); + resolved.add(entry); } return Optional.of(resolved); } 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 ce9b4c7a9b8..6289a8e3855 100644 --- a/jablib/src/main/java/org/jabref/logic/git/GitSyncService.java +++ b/jablib/src/main/java/org/jabref/logic/git/GitSyncService.java @@ -102,7 +102,7 @@ public MergeResult fetchAndMerge(BibDatabaseContext localDatabaseContext, Path b } public MergeResult performSemanticMerge(Git git, - RevCommit baseCommit, + Optional maybeBaseCommit, RevCommit remoteCommit, BibDatabaseContext localDatabaseContext, Path bibFilePath) throws IOException, JabRefException { @@ -117,11 +117,17 @@ public MergeResult performSemanticMerge(Git git, relativePath = workTree.relativize(bibPath); // 1. Load three versions - Optional baseContent = GitFileReader.readFileFromCommit(git, baseCommit, relativePath); - Optional remoteContent = GitFileReader.readFileFromCommit(git, remoteCommit, relativePath); + BibDatabaseContext base; + if (maybeBaseCommit.isPresent()) { + Optional baseContent = GitFileReader.readFileFromCommit(git, maybeBaseCommit.get(), relativePath); + base = GitBibParser.parseBibFromGit(baseContent, importFormatPreferences); + } else { + base = new BibDatabaseContext(); + } - BibDatabaseContext base = GitBibParser.parseBibFromGit(baseContent, importFormatPreferences); + Optional remoteContent = GitFileReader.readFileFromCommit(git, remoteCommit, relativePath); BibDatabaseContext remote = GitBibParser.parseBibFromGit(remoteContent, importFormatPreferences); + BibDatabaseContext local = localDatabaseContext; // 2. Conflict detection 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 index 758da15479d..f6b0304260d 100644 --- a/jablib/src/main/java/org/jabref/logic/git/io/GitRevisionLocator.java +++ b/jablib/src/main/java/org/jabref/logic/git/io/GitRevisionLocator.java @@ -1,6 +1,7 @@ package org.jabref.logic.git.io; import java.io.IOException; +import java.util.Optional; import org.jabref.logic.JabRefException; @@ -43,7 +44,7 @@ public RevisionTriple locateMergeCommits(Git git) throws GitAPIException, IOExce throw new IllegalStateException("Could not determine merge base between local and remote."); } - return new RevisionTriple(base, local, remote); + return new RevisionTriple(Optional.ofNullable(base), local, remote); } } 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 index b9a5ad14e52..d8df3669fe5 100644 --- a/jablib/src/main/java/org/jabref/logic/git/io/RevisionTriple.java +++ b/jablib/src/main/java/org/jabref/logic/git/io/RevisionTriple.java @@ -1,8 +1,8 @@ package org.jabref.logic.git.io; +import java.util.Optional; + import org.eclipse.jgit.revwalk.RevCommit; -import org.jspecify.annotations.NonNull; -import org.jspecify.annotations.Nullable; /** * Holds the three relevant commits involved in a semantic three-way merge, @@ -12,9 +12,10 @@ * @param local the current local branch tip * @param remote the tip of the remote tracking branch (typically origin/main) */ -public record RevisionTriple(@Nullable RevCommit base, - @NonNull RevCommit local, - @NonNull RevCommit remote) { +public record RevisionTriple( + Optional base, + RevCommit local, + RevCommit remote) { public RevisionTriple { if (local == null || remote == null) { throw new IllegalArgumentException("local and remote commits must not be null"); 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 index a5e5d2d2b4c..adafa25dcb3 100644 --- a/jablib/src/test/java/org/jabref/logic/git/util/GitRevisionLocatorTest.java +++ b/jablib/src/test/java/org/jabref/logic/git/util/GitRevisionLocatorTest.java @@ -13,6 +13,7 @@ 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 { @Test @@ -46,7 +47,8 @@ void locateMergeCommits(@TempDir Path tempDir) throws Exception { GitRevisionLocator locator = new GitRevisionLocator(); RevisionTriple triple = locator.locateMergeCommits(git); - assertEquals(base.getId(), triple.base().getId()); + assertTrue(triple.base().isPresent()); + assertEquals(base.getId(), triple.base().get().getId()); assertEquals(local.getId(), triple.local().getId()); assertEquals(remote.getId(), triple.remote().getId()); } From d3f553605ca9d9f924152e951e659ee3357486a4 Mon Sep 17 00:00:00 2001 From: Oliver Kopp Date: Fri, 25 Jul 2025 21:17:17 +0200 Subject: [PATCH 26/37] Use JSpecify --- .../main/java/org/jabref/logic/git/io/RevisionTriple.java | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) 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 index d8df3669fe5..973601aa5e0 100644 --- a/jablib/src/main/java/org/jabref/logic/git/io/RevisionTriple.java +++ b/jablib/src/main/java/org/jabref/logic/git/io/RevisionTriple.java @@ -3,6 +3,7 @@ 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, @@ -12,13 +13,11 @@ * @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 { - if (local == null || remote == null) { - throw new IllegalArgumentException("local and remote commits must not be null"); - } } } From 363698c0203e18857cda777a321fbd6e827b5dee Mon Sep 17 00:00:00 2001 From: Wanling Fu Date: Tue, 29 Jul 2025 23:58:21 +0100 Subject: [PATCH 27/37] chore: Apply review suggestions #12350 --- docs/code-howtos/git.md | 158 ++++++++++-------- .../gui/git/GitConflictResolverDialog.java | 12 +- .../org/jabref/gui/git/GitPullAction.java | 47 +++++- .../org/jabref/gui/git/GitPullViewModel.java | 5 +- .../jabref/gui/git/GitStatusViewModel.java | 50 +++--- .../gui/git/GuiConflictResolverStrategy.java | 28 ---- .../git/GuiGitConflictResolverStrategy.java | 36 ++++ jablib/src/main/java/module-info.java | 4 +- .../java/org/jabref/logic/git/GitHandler.java | 2 +- .../org/jabref/logic/git/GitSyncService.java | 28 ++-- .../CliConflictResolverStrategy.java | 13 -- .../CliGitConflictResolverStrategy.java | 15 ++ .../GitConflictResolverStrategy.java | 10 +- .../conflicts/SemanticConflictDetector.java | 59 +++---- .../git/conflicts/ThreeWayEntryConflict.java | 18 +- .../org/jabref/logic/git/io/GitBibParser.java | 37 ---- .../jabref/logic/git/io/GitFileReader.java | 5 +- .../logic/git/io/GitRevisionLocator.java | 31 ++-- .../jabref/logic/git/io/RevisionTriple.java | 2 + .../git/merge/GitSemanticMergeExecutor.java | 12 +- .../logic/git/merge/SemanticMerger.java | 4 +- .../jabref/logic/git/model/MergeResult.java | 5 +- .../logic/git/status/GitStatusChecker.java | 10 +- .../model/database/BibDatabaseContext.java | 21 +++ .../org/jabref/logic/git/GitHandlerTest.java | 10 +- .../jabref/logic/git/GitSyncServiceTest.java | 47 ++++-- .../merge/GitSemanticMergeExecutorTest.java | 25 ++- .../git/status/GitStatusCheckerTest.java | 24 ++- .../logic/git/util/GitBibParserTest.java | 79 --------- .../logic/git/util/GitFileWriterTest.java | 31 ++-- .../git/util/GitRevisionLocatorTest.java | 28 +++- .../util/SemanticConflictDetectorTest.java | 96 ++++++----- .../logic/git/util/SemanticMergerTest.java | 76 ++++++--- .../database/BibDatabaseContextTest.java | 37 ++++ 34 files changed, 605 insertions(+), 460 deletions(-) delete mode 100644 jabgui/src/main/java/org/jabref/gui/git/GuiConflictResolverStrategy.java create mode 100644 jabgui/src/main/java/org/jabref/gui/git/GuiGitConflictResolverStrategy.java delete mode 100644 jablib/src/main/java/org/jabref/logic/git/conflicts/CliConflictResolverStrategy.java create mode 100644 jablib/src/main/java/org/jabref/logic/git/conflicts/CliGitConflictResolverStrategy.java delete mode 100644 jablib/src/main/java/org/jabref/logic/git/io/GitBibParser.java delete mode 100644 jablib/src/test/java/org/jabref/logic/git/util/GitBibParserTest.java diff --git a/docs/code-howtos/git.md b/docs/code-howtos/git.md index 630ebe030bf..313256f80b0 100644 --- a/docs/code-howtos/git.md +++ b/docs/code-howtos/git.md @@ -1,71 +1,93 @@ -# git +# 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` +* `org.jabref.logic.git.util.SemanticConflictDetectorTest`. ## Conflict Scenarios -- **T1.** Remote changed a field, local did not - → No conflict. - The local version remained unchanged, so the remote change can be safely applied. - -- **T2.** Local changed a field, remote did not - → No conflict. - The remote version did not touch the field, so the local change is preserved. - -- **T3.** Both local and remote changed the same field to the same value - → No conflict. - Although both sides changed the field, the result is identical—therefore, no conflict. - -- **T4.** Both local and remote changed the same field to different values - → Conflict. - This is a true semantic conflict that requires resolution. - -- **T5.** Local deleted a field, remote modified the same field - → Conflict. - One side deleted the field while the other updated it—this is contradictory. - -- **T6.** Local modified a field, remote deleted it - → Conflict. - Similar to T5, one side deletes, the other edits—this is a conflict. - -- **T7.** Local unchanged, remote deleted a field - → No conflict. - Local did not modify anything, so remote deletion is accepted. - -- **T8.** Local changed field A, remote changed field B (within the same entry) - → No conflict. - Changes are on separate fields, so they can be merged safely. - -- **T9.** Both changed the same entry, but only field order changed - → No conflict. - Field order is not semantically meaningful, so no conflict is detected. - -- **T10.** Local modified entry A, remote modified entry B - → No conflict. - Modifications are on different entries, which are always safe to merge. - -- **T11.** Remote added a new field, local did nothing - → No conflict. - Remote addition can be applied without issues. - -- **T12.** Remote added a field, local also added the same field, but with different value - → Conflict. - One side added while the other side modified—there is a semantic conflict. - -- **T13.** Local added a field, remote did nothing - → No conflict. - Safe to preserve the local addition. - -- **T14.** Both added the same field with the same value - → No conflict. - Even though both sides added it, the value is the same—no need for resolution. - -- **T15.** Both added the same field with different values - → Conflict. - The same field is introduced with different values, which creates a conflict. - -- **T16.** Both added the same entry key with different values - → Conflict. - Both sides created a new entry with the same citation key, but the fields differ. - -- **T17.** Both added the same entry key with identical values - → No conflict. - Both sides created a new entry with the same citation key and identical fields, so it can be merged safely. +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 index e89302588ff..701102a19c4 100644 --- a/jabgui/src/main/java/org/jabref/gui/git/GitConflictResolverDialog.java +++ b/jabgui/src/main/java/org/jabref/gui/git/GitConflictResolverDialog.java @@ -9,12 +9,12 @@ import org.jabref.gui.mergeentries.newmergedialog.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; -/** - * UI wrapper - * Receives a semantic conflict (ThreeWayEntryConflict), pops up an interactive GUI (belonging to mergeentries), and returns a user-confirmed BibEntry merge result. - */ +/// 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; @@ -30,8 +30,8 @@ public Optional resolveConflict(ThreeWayEntryConflict conflict) { BibEntry remote = conflict.remote(); MergeEntriesDialog dialog = new MergeEntriesDialog(local, remote, preferences); - dialog.setLeftHeaderText("Local"); - dialog.setRightHeaderText("Remote"); + dialog.setLeftHeaderText(Localization.lang("Local")); + dialog.setRightHeaderText(Localization.lang("Remote")); ShowDiffConfig diffConfig = new ShowDiffConfig( ThreeWayMergeToolbar.DiffView.SPLIT, DiffHighlighter.BasicDiffMethod.WORDS 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 ca9ff7d65ab..15e5ed7ce18 100644 --- a/jabgui/src/main/java/org/jabref/gui/git/GitPullAction.java +++ b/jabgui/src/main/java/org/jabref/gui/git/GitPullAction.java @@ -13,6 +13,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.l10n.Localization; import org.jabref.logic.util.BackgroundTask; import org.jabref.logic.util.TaskExecutor; import org.jabref.model.database.BibDatabaseContext; @@ -39,20 +40,26 @@ public GitPullAction(DialogService dialogService, @Override public void execute() { if (stateManager.getActiveDatabase().isEmpty()) { - dialogService.showErrorDialogAndWait("No database open", "Please open a database before pulling."); + dialogService.showErrorDialogAndWait( + Localization.lang("No library open"), + Localization.lang("Please open a library before pulling.") + ); return; } BibDatabaseContext database = stateManager.getActiveDatabase().get(); if (database.getDatabasePath().isEmpty()) { - dialogService.showErrorDialogAndWait("No .bib file path", "Cannot pull from Git: No file is associated with this database."); + dialogService.showErrorDialogAndWait( + Localization.lang("No library file path"), + Localization.lang("Cannot pull from Git: No file is associated with this library.") + ); return; } Path bibFilePath = database.getDatabasePath().get(); GitHandler handler = new GitHandler(bibFilePath.getParent()); GitConflictResolverDialog dialog = new GitConflictResolverDialog(dialogService, guiPreferences); - GitConflictResolverStrategy resolver = new GuiConflictResolverStrategy(dialog); + GitConflictResolverStrategy resolver = new GuiGitConflictResolverStrategy(dialog); GitSemanticMergeExecutor mergeExecutor = new GitSemanticMergeExecutorImpl(guiPreferences.getImportFormatPreferences()); GitSyncService syncService = new GitSyncService(guiPreferences.getImportFormatPreferences(), handler, resolver, mergeExecutor); @@ -63,20 +70,42 @@ public void execute() { .wrap(() -> viewModel.pull()) .onSuccess(result -> { if (result.isSuccessful()) { - dialogService.showInformationDialogAndWait("Git Pull", "Successfully merged and updated."); + dialogService.showInformationDialogAndWait( + Localization.lang("Git Pull"), + Localization.lang("Successfully merged and updated.") + ); } else { - dialogService.showWarningDialogAndWait("Git Pull", "Merge completed with conflicts."); + dialogService.showWarningDialogAndWait( + Localization.lang("Git Pull"), + Localization.lang("Merge completed with conflicts.") + ); } }) .onFailure(ex -> { if (ex instanceof JabRefException e) { - dialogService.showErrorDialogAndWait("Git Pull Failed", e.getLocalizedMessage()); + dialogService.showErrorDialogAndWait( + Localization.lang("Git Pull Failed"), + e.getLocalizedMessage(), + e + ); } else if (ex instanceof GitAPIException e) { - dialogService.showErrorDialogAndWait("Git Pull Failed", "An unexpected Git error occurred: " + e.getLocalizedMessage()); + 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("Git Pull Failed", "I/O error: " + e.getLocalizedMessage()); + dialogService.showErrorDialogAndWait( + Localization.lang("Git Pull Failed"), + Localization.lang("I/O error: %0", e.getLocalizedMessage()), + e + ); } else { - dialogService.showErrorDialogAndWait("Git Pull Failed", "Unexpected error: " + ex.getLocalizedMessage()); + 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 index 1e982fe2b45..0aa9fe405fa 100644 --- a/jabgui/src/main/java/org/jabref/gui/git/GitPullViewModel.java +++ b/jabgui/src/main/java/org/jabref/gui/git/GitPullViewModel.java @@ -8,6 +8,7 @@ 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; @@ -24,12 +25,12 @@ public GitPullViewModel(GitSyncService syncService, GitStatusViewModel gitStatus public MergeResult pull() throws IOException, GitAPIException, JabRefException { Optional maybeContext = gitStatusViewModel.getDatabaseContext(); if (maybeContext.isEmpty()) { - throw new JabRefException("Cannot pull: No active BibDatabaseContext."); + throw new JabRefException(Localization.lang("Cannot pull: No active BibDatabaseContext.")); } BibDatabaseContext localBibDatabaseContext = maybeContext.get(); Path bibFilePath = localBibDatabaseContext.getDatabasePath().orElseThrow(() -> - new JabRefException("Cannot pull: .bib file path missing in BibDatabaseContext.") + new JabRefException(Localization.lang("Cannot pull: .bib file path missing in BibDatabaseContext.")) ); MergeResult result = syncService.fetchAndMerge(localBibDatabaseContext, bibFilePath); 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 fcf2d21c42a..f5907ea94e9 100644 --- a/jabgui/src/main/java/org/jabref/gui/git/GitStatusViewModel.java +++ b/jabgui/src/main/java/org/jabref/gui/git/GitStatusViewModel.java @@ -18,31 +18,40 @@ import org.jabref.logic.git.status.SyncStatus; import org.jabref.model.database.BibDatabaseContext; -/** - * ViewModel that holds current Git sync status for the open .bib database. - * It maintains the state of the GitHandler bound to the current file path, including: - * - Whether the current file is inside a Git repository - * - Whether the file is tracked by Git - * - Whether there are unresolved merge conflicts - * - The current sync status (e.g., UP_TO_DATE, DIVERGED, etc.) - */ +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 GitHandler activeHandler = null; + private @Nullable GitHandler activeHandler = null; public GitStatusViewModel(StateManager stateManager, Path bibFilePath) { this.stateManager = stateManager; - stateManager.activeDatabaseProperty().addListener((obs, oldDb, newDb) -> { + EasyBind.subscribe(stateManager.activeDatabaseProperty(), newDb -> { if (newDb != null && newDb.isPresent() && newDb.get().getDatabasePath().isPresent()) { - BibDatabaseContext ctx = newDb.get(); - databaseContext.set(ctx); - updateStatusFromContext(ctx); + BibDatabaseContext databaseContext1 = newDb.get(); + databaseContext.set(databaseContext1); + updateStatusFromContext(databaseContext1); } else { + LOGGER.debug("No active database with path; resetting Git status."); reset(); } }); @@ -54,19 +63,22 @@ public GitStatusViewModel(StateManager stateManager, Path bibFilePath) { } protected void updateStatusFromContext(BibDatabaseContext context) { - Path path = context.getDatabasePath().orElse(null); - if (path == null) { + Optional maybePath = context.getDatabasePath(); + if (maybePath.isEmpty()) { + LOGGER.debug("No .bib file path available in database context; resetting Git status."); reset(); return; } - GitHandler handler = GitHandler.fromAnyPath(path).orElse(null); - if (handler == null) { + Path path = maybePath.get(); + + Optional maybeHandler = GitHandler.fromAnyPath(path); + if (maybeHandler.isEmpty()) { + LOGGER.debug("No Git repository found for path {}; resetting Git status.", path); reset(); return; } - - this.activeHandler = handler; + this.activeHandler = maybeHandler.get(); GitStatusSnapshot snapshot = GitStatusChecker.checkStatus(path); setTracking(snapshot.tracking()); diff --git a/jabgui/src/main/java/org/jabref/gui/git/GuiConflictResolverStrategy.java b/jabgui/src/main/java/org/jabref/gui/git/GuiConflictResolverStrategy.java deleted file mode 100644 index eb5b53f6e4d..00000000000 --- a/jabgui/src/main/java/org/jabref/gui/git/GuiConflictResolverStrategy.java +++ /dev/null @@ -1,28 +0,0 @@ -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; - -public class GuiConflictResolverStrategy implements GitConflictResolverStrategy { - private final GitConflictResolverDialog dialog; - - public GuiConflictResolverStrategy(GitConflictResolverDialog dialog) { - this.dialog = dialog; - } - - @Override - public Optional> resolveConflicts(List conflicts) { - List resolved = new ArrayList<>(); - for (ThreeWayEntryConflict conflict : conflicts) { - BibEntry entry = dialog.resolveConflict(conflict) - .orElseThrow(() -> new IllegalStateException("Conflict resolution was cancelled")); - resolved.add(entry); - } - return Optional.of(resolved); - } -} 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..9003c32579d --- /dev/null +++ b/jabgui/src/main/java/org/jabref/gui/git/GuiGitConflictResolverStrategy.java @@ -0,0 +1,36 @@ +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) { + // TODO: We discussed somewhere else that Optional> should be List - and that this list is empty if it was cancelled. + Optional maybeEntry = dialog.resolveConflict(conflict); + if (maybeEntry.isEmpty()) { + LOGGER.debug("User cancelled conflict resolution for entry {}", conflict.local().getCitationKey().orElse("")); + return List.of(); + } + resolved.add(maybeEntry.get()); + } + return resolved; + } +} diff --git a/jablib/src/main/java/module-info.java b/jablib/src/main/java/module-info.java index 485bb87e388..ef9f3d96406 100644 --- a/jablib/src/main/java/module-info.java +++ b/jablib/src/main/java/module-info.java @@ -103,12 +103,12 @@ 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.merge; 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; 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 916b2dbbdd2..7830822b935 100644 --- a/jablib/src/main/java/org/jabref/logic/git/GitHandler.java +++ b/jablib/src/main/java/org/jabref/logic/git/GitHandler.java @@ -209,7 +209,7 @@ public void fetchOnCurrentBranch() throws IOException { .setCredentialsProvider(credentialsProvider) .call(); } catch (GitAPIException e) { - LOGGER.info("Failed to fetch from remote", e); + LOGGER.error("Failed to fetch from remote", e); } } 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 6289a8e3855..6b4b4dafeee 100644 --- a/jablib/src/main/java/org/jabref/logic/git/GitSyncService.java +++ b/jablib/src/main/java/org/jabref/logic/git/GitSyncService.java @@ -9,7 +9,6 @@ import org.jabref.logic.git.conflicts.GitConflictResolverStrategy; import org.jabref.logic.git.conflicts.SemanticConflictDetector; import org.jabref.logic.git.conflicts.ThreeWayEntryConflict; -import org.jabref.logic.git.io.GitBibParser; import org.jabref.logic.git.io.GitFileReader; import org.jabref.logic.git.io.GitRevisionLocator; import org.jabref.logic.git.io.RevisionTriple; @@ -29,13 +28,12 @@ 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 - */ +/// 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); @@ -93,6 +91,7 @@ public MergeResult fetchAndMerge(BibDatabaseContext localDatabaseContext, Path b 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); } @@ -120,14 +119,13 @@ public MergeResult performSemanticMerge(Git git, BibDatabaseContext base; if (maybeBaseCommit.isPresent()) { Optional baseContent = GitFileReader.readFileFromCommit(git, maybeBaseCommit.get(), relativePath); - base = GitBibParser.parseBibFromGit(baseContent, importFormatPreferences); + base = baseContent.isEmpty() ? BibDatabaseContext.empty() : BibDatabaseContext.of(baseContent.get(), importFormatPreferences); } else { base = new BibDatabaseContext(); } Optional remoteContent = GitFileReader.readFileFromCommit(git, remoteCommit, relativePath); - BibDatabaseContext remote = GitBibParser.parseBibFromGit(remoteContent, importFormatPreferences); - + BibDatabaseContext remote = remoteContent.isEmpty() ? BibDatabaseContext.empty() : BibDatabaseContext.of(remoteContent.get(), importFormatPreferences); BibDatabaseContext local = localDatabaseContext; // 2. Conflict detection @@ -138,15 +136,15 @@ public MergeResult performSemanticMerge(Git git, effectiveRemote = remote; } else { // 3. If there are conflicts, ask strategy to resolve - Optional> maybeResolved = gitConflictResolverStrategy.resolveConflicts(conflicts); - if (maybeResolved.isEmpty()) { + 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, maybeResolved.get()); + effectiveRemote = GitMergeUtil.replaceEntries(remote, resolved); } - // 4. Apply resolved remote (either original or conflict-resolved) to local + // 4. Apply resolved remote (either original or conflict-resolved) to local MergeResult result = mergeExecutor.merge(base, local, effectiveRemote, bibFilePath); return result; diff --git a/jablib/src/main/java/org/jabref/logic/git/conflicts/CliConflictResolverStrategy.java b/jablib/src/main/java/org/jabref/logic/git/conflicts/CliConflictResolverStrategy.java deleted file mode 100644 index 529bf3fd4d4..00000000000 --- a/jablib/src/main/java/org/jabref/logic/git/conflicts/CliConflictResolverStrategy.java +++ /dev/null @@ -1,13 +0,0 @@ -package org.jabref.logic.git.conflicts; - -import java.util.List; -import java.util.Optional; - -import org.jabref.model.entry.BibEntry; - -public class CliConflictResolverStrategy implements GitConflictResolverStrategy { - @Override - public Optional> resolveConflicts(List conflicts) { - return Optional.empty(); - } -} 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 index 21ebeee9a44..01d5135155d 100644 --- a/jablib/src/main/java/org/jabref/logic/git/conflicts/GitConflictResolverStrategy.java +++ b/jablib/src/main/java/org/jabref/logic/git/conflicts/GitConflictResolverStrategy.java @@ -1,17 +1,23 @@ package org.jabref.logic.git.conflicts; import java.util.List; -import java.util.Optional; 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. */ - Optional> resolveConflicts(List conflicts); + 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 index 4d8866f8e77..1ffd468e11e 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 @@ -17,24 +17,26 @@ 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: - * Entries without citation keys are ignored (cannot be matched). - */ +/// 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 = toEntryMap(base); - Map localEntries = toEntryMap(local); + Map baseEntries = getCitationKeyToEntryMap(base); + Map localEntries = getCitationKeyToEntryMap(local); List conflicts = new ArrayList<>(); @@ -73,7 +75,7 @@ public static List detectConflicts(BibDatabaseContext bas return conflicts; } - public static Map toEntryMap(BibDatabaseContext context) { + private static Map getCitationKeyToEntryMap(BibDatabaseContext context) { return context.getDatabase().getEntries().stream() .filter(entry -> entry.getCitationKey().isPresent()) .collect(Collectors.toMap( @@ -110,8 +112,8 @@ private static boolean hasConflictingFields(BibEntry base, BibEntry local, BibEn * @return A {@link MergePlan} describing how to update the local copy with remote changes. */ public static MergePlan extractMergePlan(BibDatabaseContext base, BibDatabaseContext remote) { - Map baseMap = toEntryMap(base); - Map remoteMap = toEntryMap(remote); + Map baseMap = getCitationKeyToEntryMap(base); + Map remoteMap = getCitationKeyToEntryMap(remote); Map> fieldPatches = new LinkedHashMap<>(); List newEntries = new ArrayList<>(); @@ -142,18 +144,19 @@ public static MergePlan extractMergePlan(BibDatabaseContext base, BibDatabaseCon * @return A map from field to new value */ private static Map computeFieldPatch(BibEntry base, BibEntry remote) { - return Stream.concat(base.getFields().stream(), remote.getFields().stream()) - .distinct() - .filter(field -> { - String baseValue = base.getField(field).orElse(null); - String remoteValue = remote.getField(field).orElse(null); - return !Objects.equals(baseValue, remoteValue); - }) - .collect(Collectors.toMap( - field -> field, - field -> remote.getField(field).orElse(null), - (existing, replacement) -> replacement, - LinkedHashMap::new - )); + 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 index 9f0238e1546..191495a506e 100644 --- a/jablib/src/main/java/org/jabref/logic/git/conflicts/ThreeWayEntryConflict.java +++ b/jablib/src/main/java/org/jabref/logic/git/conflicts/ThreeWayEntryConflict.java @@ -2,14 +2,20 @@ 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( - BibEntry base, - BibEntry local, - BibEntry remote + @Nullable BibEntry base, + @Nullable BibEntry local, + @Nullable BibEntry remote ) { public ThreeWayEntryConflict { - if (local == null && remote == null) { - throw new IllegalArgumentException("Both local and remote are null: conflict must involve at least one side."); - } + 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/GitBibParser.java b/jablib/src/main/java/org/jabref/logic/git/io/GitBibParser.java deleted file mode 100644 index 823af9629c3..00000000000 --- a/jablib/src/main/java/org/jabref/logic/git/io/GitBibParser.java +++ /dev/null @@ -1,37 +0,0 @@ -package org.jabref.logic.git.io; - -import java.io.IOException; -import java.io.Reader; -import java.util.Optional; - -import org.jabref.logic.JabRefException; -import org.jabref.logic.importer.ImportFormatPreferences; -import org.jabref.logic.importer.ParserResult; -import org.jabref.logic.importer.fileformat.BibtexParser; -import org.jabref.model.database.BibDatabase; -import org.jabref.model.database.BibDatabaseContext; -import org.jabref.model.metadata.MetaData; -import org.jabref.model.util.DummyFileUpdateMonitor; - -public class GitBibParser { - public static BibDatabaseContext parseBibFromGit(String bibContent, ImportFormatPreferences importFormatPreferences) throws JabRefException { - BibtexParser parser = new BibtexParser(importFormatPreferences, new DummyFileUpdateMonitor()); - ParserResult result; - try { - Reader reader = Reader.of(bibContent); - result = parser.parse(reader); - return result.getDatabaseContext(); - } catch (IOException e) { - throw new JabRefException("Failed to parse BibTeX content from Git", e); - } - } - - public static BibDatabaseContext parseBibFromGit(Optional maybeContent, ImportFormatPreferences prefs) throws JabRefException { - if (maybeContent.isEmpty()) { - BibDatabase emptyDatabase = new BibDatabase(); - MetaData emptyMetaData = new MetaData(); - return new BibDatabaseContext(emptyDatabase, emptyMetaData); - } - return parseBibFromGit(maybeContent.get(), prefs); - } -} 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 index 6a33a67707b..3606cd0ad9d 100644 --- a/jablib/src/main/java/org/jabref/logic/git/io/GitFileReader.java +++ b/jablib/src/main/java/org/jabref/logic/git/io/GitFileReader.java @@ -16,10 +16,11 @@ import org.eclipse.jgit.revwalk.RevCommit; import org.eclipse.jgit.revwalk.RevTree; import org.eclipse.jgit.treewalk.TreeWalk; -import org.jspecify.annotations.NonNull; +import org.jspecify.annotations.NullMarked; +@NullMarked public class GitFileReader { - public static Optional readFileFromCommit(Git git, RevCommit commit, @NonNull Path relativePath) throws JabRefException { + 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(); 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 index f6b0304260d..f749d43cc1f 100644 --- a/jablib/src/main/java/org/jabref/logic/git/io/GitRevisionLocator.java +++ b/jablib/src/main/java/org/jabref/logic/git/io/GitRevisionLocator.java @@ -7,42 +7,35 @@ 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 - */ +/// 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 { - private static final String HEAD = "HEAD"; - private static final String REMOTE = "refs/remotes/origin/main"; public RevisionTriple locateMergeCommits(Git git) throws GitAPIException, IOException, JabRefException { Repository repo = git.getRepository(); - ObjectId headId = repo.resolve(HEAD); - ObjectId remoteId = repo.resolve(REMOTE); - if (headId == null) { - throw new IllegalStateException("Local HEAD commit is missing."); - } - if (remoteId == null) { - throw new IllegalStateException("Remote branch missing origin/main."); - } + 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); - if (base == null) { - throw new IllegalStateException("Could not determine merge base between local and remote."); - } + assert base != null : "Could not determine merge base between local and remote."; return new RevisionTriple(Optional.ofNullable(base), local, remote); } 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 index d8df3669fe5..a2f28cec756 100644 --- a/jablib/src/main/java/org/jabref/logic/git/io/RevisionTriple.java +++ b/jablib/src/main/java/org/jabref/logic/git/io/RevisionTriple.java @@ -3,6 +3,7 @@ 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, @@ -12,6 +13,7 @@ * @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, 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 index 615dc9360e8..4eef9adef5e 100644 --- a/jablib/src/main/java/org/jabref/logic/git/merge/GitSemanticMergeExecutor.java +++ b/jablib/src/main/java/org/jabref/logic/git/merge/GitSemanticMergeExecutor.java @@ -6,8 +6,10 @@ import org.jabref.logic.git.model.MergeResult; import org.jabref.model.database.BibDatabaseContext; -import org.jspecify.annotations.NonNull; +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; +@NullMarked public interface GitSemanticMergeExecutor { /** @@ -20,8 +22,8 @@ public interface GitSemanticMergeExecutor { * @param bibFilePath The path to the target bib file (used for write-back) * @return MergeResult object containing merge status */ - MergeResult merge(BibDatabaseContext base, - @NonNull BibDatabaseContext local, - @NonNull BibDatabaseContext remote, - @NonNull Path bibFilePath) throws IOException; + 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/SemanticMerger.java b/jablib/src/main/java/org/jabref/logic/git/merge/SemanticMerger.java index 55301cacf89..638500dd54c 100644 --- a/jablib/src/main/java/org/jabref/logic/git/merge/SemanticMerger.java +++ b/jablib/src/main/java/org/jabref/logic/git/merge/SemanticMerger.java @@ -23,8 +23,7 @@ public static void applyMergePlan(BibDatabaseContext local, MergePlan plan) { applyPatchToDatabase(local, plan.fieldPatches()); for (BibEntry newEntry : plan.newEntries()) { - BibEntry clone = new BibEntry(newEntry.getType()).withFields(newEntry.getFieldMap()); - newEntry.getCitationKey().ifPresent(clone::withCitationKey); + BibEntry clone = (BibEntry) newEntry.clone(); clone.getCitationKey().ifPresent(citationKey -> local.getDatabase().getEntryByCitationKey(citationKey).ifPresent(existing -> { @@ -34,7 +33,6 @@ public static void applyMergePlan(BibDatabaseContext local, MergePlan plan) { ); local.getDatabase().insertEntry(clone); - clone.setChanged(true); LOGGER.debug("Inserted (or replaced) entry '{}', fields={}, marked as changed", clone.getCitationKey().orElse("?"), clone.getFieldMap()); 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 index 8a581abf4c8..543db8e9507 100644 --- a/jablib/src/main/java/org/jabref/logic/git/model/MergeResult.java +++ b/jablib/src/main/java/org/jabref/logic/git/model/MergeResult.java @@ -5,13 +5,12 @@ import org.jabref.logic.bibtex.comparator.BibEntryDiff; public record MergeResult(boolean isSuccessful, List conflicts) { - private final static boolean SUCCESS = true; public static MergeResult withConflicts(List conflicts) { - return new MergeResult(!SUCCESS, conflicts); + return new MergeResult(false, conflicts); } public static MergeResult success() { - return new MergeResult(SUCCESS, List.of()); + return new MergeResult(true, List.of()); } public static MergeResult failure() { 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 0bae7a20ce3..42eaf0c6deb 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 @@ -10,6 +10,7 @@ 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; @@ -46,7 +47,10 @@ public static GitStatusSnapshot checkStatus(Path anyPathInsideRepo) { boolean hasUncommittedChanges = !status.isClean(); ObjectId localHead = repo.resolve("HEAD"); - ObjectId remoteHead = repo.resolve("refs/remotes/origin/main"); + // TODO: Handle remote branches properly + Test + 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( @@ -57,7 +61,7 @@ public static GitStatusSnapshot checkStatus(Path anyPathInsideRepo) { Optional.ofNullable(localHead).map(ObjectId::getName) ); } catch (IOException | GitAPIException e) { - LOGGER.warn("Failed to check Git status: {}", e.getMessage(), e); + LOGGER.warn("Failed to check Git status", e); return new GitStatusSnapshot( true, SyncStatus.UNKNOWN, @@ -70,6 +74,7 @@ public static GitStatusSnapshot checkStatus(Path anyPathInsideRepo) { 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; } @@ -92,6 +97,7 @@ private static SyncStatus determineSyncStatus(Repository repo, ObjectId localHea } 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/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/test/java/org/jabref/logic/git/GitHandlerTest.java b/jablib/src/test/java/org/jabref/logic/git/GitHandlerTest.java index cf3ec0fbdb0..87942746092 100644 --- a/jablib/src/test/java/org/jabref/logic/git/GitHandlerTest.java +++ b/jablib/src/test/java/org/jabref/logic/git/GitHandlerTest.java @@ -7,6 +7,7 @@ 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; @@ -53,9 +54,14 @@ void setUpGitHandler() throws IOException, GitAPIException, URISyntaxException { .setRemote("origin") .setRefSpecs(new RefSpec("refs/heads/main:refs/heads/main")) .call(); - } - Files.writeString(remoteRepoPath.resolve("HEAD"), "ref: refs/heads/main"); + localGit.branchCreate() + .setName("main") + .setUpstreamMode(CreateBranchCommand.SetupUpstreamMode.SET_UPSTREAM) + .setStartPoint("origin/main") + .setForce(true) + .call(); + } } @Test 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 954533f8530..e1e553682e5 100644 --- a/jablib/src/test/java/org/jabref/logic/git/GitSyncServiceTest.java +++ b/jablib/src/test/java/org/jabref/logic/git/GitSyncServiceTest.java @@ -1,11 +1,11 @@ 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 java.util.Optional; import org.jabref.logic.git.conflicts.GitConflictResolverStrategy; import org.jabref.logic.git.conflicts.ThreeWayEntryConflict; @@ -21,7 +21,10 @@ 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; @@ -45,6 +48,7 @@ class GitSyncServiceTest { private Path bobDir; private Git aliceGit; private Git bobGit; + private Git remoteGit; private ImportFormatPreferences importFormatPreferences; private GitConflictResolverStrategy gitConflictResolverStrategy; private GitSemanticMergeExecutor mergeExecutor; @@ -57,6 +61,10 @@ class GitSyncServiceTest { 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}, @@ -111,12 +119,11 @@ void aliceBobSimple(@TempDir Path tempDir) throws Exception { // create fake remote repo remoteDir = tempDir.resolve("remote.git"); - Git remoteGit = Git.init() + remoteGit = Git.init() .setBare(true) .setInitialBranch("main") .setDirectory(remoteDir.toFile()) .call(); - remoteGit.close(); // Alice init local repository aliceDir = tempDir.resolve("alice"); @@ -140,6 +147,8 @@ void aliceBobSimple(@TempDir Path tempDir) throws Exception { .setRefSpecs(new RefSpec("refs/heads/main:refs/heads/main")) .call(); + configureTracking(aliceGit, "main", "origin"); + // Bob clone remote bobDir = tempDir.resolve("bob"); bobGit = Git.cloneRepository() @@ -169,6 +178,19 @@ void aliceBobSimple(@TempDir Path tempDir) throws Exception { // 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()); @@ -271,7 +293,7 @@ void mergeConflictOnSameFieldTriggersDialogAndUsesUserResolution(@TempDir Path t // We simulate conflict resolution by choosing the remote version and modifying the author field. BibEntry resolved = (BibEntry) conflict.remote().clone(); resolved.setField(StandardField.AUTHOR, "alice-c + bob-c"); - return Optional.of(List.of(resolved)); + return List.of(resolved); }); GitHandler handler = new GitHandler(aliceDir); @@ -303,16 +325,6 @@ void readFromCommits() throws Exception { assertEquals(bobUpdatedContent, remote); } - @AfterEach - void cleanup() { - if (aliceGit != null) { - aliceGit.close(); - } - if (bobGit != null) { - bobGit.close(); - } - } - private RevCommit writeAndCommit(String content, String message, PersonIdent author, Path library, Git git) throws Exception { Files.writeString(library, content, StandardCharsets.UTF_8); String relativePath = git.getRepository().getWorkTree().toPath().relativize(library).toString(); @@ -329,4 +341,11 @@ private String normalize(String s) { .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/GitSemanticMergeExecutorTest.java b/jablib/src/test/java/org/jabref/logic/git/merge/GitSemanticMergeExecutorTest.java index 13ed348013d..7b81c42b109 100644 --- a/jablib/src/test/java/org/jabref/logic/git/merge/GitSemanticMergeExecutorTest.java +++ b/jablib/src/test/java/org/jabref/logic/git/merge/GitSemanticMergeExecutorTest.java @@ -3,9 +3,11 @@ 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; @@ -14,8 +16,10 @@ 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; @@ -28,6 +32,8 @@ public class GitSemanticMergeExecutorTest { private ImportFormatPreferences preferences; private GitSemanticMergeExecutor executor; private Path tempFile; + @TempDir + private Path tempDir; @BeforeEach public void setup() throws IOException { @@ -35,7 +41,8 @@ public void setup() throws IOException { local = new BibDatabaseContext(); remote = new BibDatabaseContext(); - BibEntry baseEntry = new BibEntry().withCitationKey("Smith2020").withField(StandardField.TITLE, "Old Title"); + BibEntry baseEntry = new BibEntry().withCitationKey("Smith2020") + .withField(StandardField.TITLE, "Old Title"); BibEntry localEntry = (BibEntry) baseEntry.clone(); BibEntry remoteEntry = (BibEntry) baseEntry.clone(); remoteEntry.setField(StandardField.TITLE, "New Title"); @@ -50,16 +57,22 @@ public void setup() throws IOException { executor = new GitSemanticMergeExecutorImpl(preferences); - tempFile = Files.createTempFile("merged", ".bib"); - tempFile.toFile().deleteOnExit(); + tempFile = tempDir.resolve("merged.bib"); } @Test - public void successfulMergeAndWrite() throws IOException { + public void successfulMergeAndWrite() throws IOException, JabRefException { MergeResult result = executor.merge(base, local, remote, tempFile); assertTrue(result.isSuccessful()); - String content = Files.readString(tempFile); - assertTrue(content.contains("New Title")); + + 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 index 2c1e75a6e47..fdd01538ebc 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,11 +5,13 @@ 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; @@ -22,6 +24,7 @@ class GitStatusCheckerTest { private Path localLibrary; private Git localGit; private Git remoteGit; + private Git seedGit; private final PersonIdent author = new PersonIdent("Tester", "tester@example.org"); @@ -67,7 +70,7 @@ void setup(@TempDir Path tempDir) throws Exception { remoteGit = Git.init().setBare(true).setDirectory(remoteDir.toFile()).call(); Path seedDir = tempDir.resolve("seed"); - Git seedGit = Git.init() + seedGit = Git.init() .setInitialBranch("main") .setDirectory(seedDir.toFile()) .call(); @@ -93,10 +96,29 @@ void setup(@TempDir Path tempDir) throws Exception { .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"); diff --git a/jablib/src/test/java/org/jabref/logic/git/util/GitBibParserTest.java b/jablib/src/test/java/org/jabref/logic/git/util/GitBibParserTest.java deleted file mode 100644 index 36987211825..00000000000 --- a/jablib/src/test/java/org/jabref/logic/git/util/GitBibParserTest.java +++ /dev/null @@ -1,79 +0,0 @@ -package org.jabref.logic.git.util; - -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.List; -import java.util.Optional; - -import org.jabref.logic.git.io.GitBibParser; -import org.jabref.logic.git.io.GitFileReader; -import org.jabref.logic.importer.ImportFormatPreferences; -import org.jabref.model.database.BibDatabaseContext; -import org.jabref.model.entry.BibEntry; -import org.jabref.model.entry.field.FieldFactory; - -import org.eclipse.jgit.api.Git; -import org.eclipse.jgit.lib.PersonIdent; -import org.eclipse.jgit.revwalk.RevCommit; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.io.TempDir; -import org.mockito.Answers; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.when; - -class GitBibParserTest { - private Git git; - private Path library; - private RevCommit commit; - - private final PersonIdent alice = new PersonIdent("Alice", "alice@example.org"); - private final String bibContent = """ - @article{test2025, - author = {Alice}, - title = {Test Title}, - year = {2025} - } - """; - - private ImportFormatPreferences importFormatPreferences; - - @BeforeEach - void setup(@TempDir Path tempDir) throws Exception { - importFormatPreferences = mock(ImportFormatPreferences.class, Answers.RETURNS_DEEP_STUBS); - when(importFormatPreferences.bibEntryPreferences().getKeywordSeparator()).thenReturn(','); - - git = Git.init() - .setDirectory(tempDir.toFile()) - .setInitialBranch("main") - .call(); - - library = tempDir.resolve("library.bib"); - commit = writeAndCommit(bibContent, "Initial commit", alice, library, git); - } - - @Test - void parsesBibContentFromCommit() throws Exception { - String rawBib = GitFileReader.readFileFromCommit(git, commit, Path.of("library.bib")).orElse(""); - - BibDatabaseContext context = GitBibParser.parseBibFromGit(rawBib, importFormatPreferences); - - List entries = context.getEntries(); - assertEquals(1, entries.size()); - - BibEntry entry = entries.getFirst(); - assertEquals(Optional.of("Alice"), entry.getField(FieldFactory.parseField("author"))); - assertEquals(Optional.of("Test Title"), entry.getField(FieldFactory.parseField("title"))); - assertEquals(Optional.of("2025"), entry.getField(FieldFactory.parseField("year"))); - assertEquals(Optional.of("test2025"), entry.getCitationKey()); - } - - private RevCommit writeAndCommit(String content, String message, PersonIdent author, Path library, Git git) throws Exception { - Files.writeString(library, content, StandardCharsets.UTF_8); - git.add().addFilepattern(library.getFileName().toString()).call(); - return git.commit().setAuthor(author).setMessage(message).call(); - } -} diff --git a/jablib/src/test/java/org/jabref/logic/git/util/GitFileWriterTest.java b/jablib/src/test/java/org/jabref/logic/git/util/GitFileWriterTest.java index 2cd9bd0924c..02d162988ce 100644 --- a/jablib/src/test/java/org/jabref/logic/git/util/GitFileWriterTest.java +++ b/jablib/src/test/java/org/jabref/logic/git/util/GitFileWriterTest.java @@ -4,12 +4,12 @@ import java.nio.file.Path; import java.util.List; -import org.jabref.logic.git.io.GitBibParser; import org.jabref.logic.git.io.GitFileWriter; import org.jabref.logic.importer.ImportFormatPreferences; import org.jabref.model.database.BibDatabaseContext; import org.jabref.model.entry.BibEntry; import org.jabref.model.entry.field.StandardField; +import org.jabref.model.entry.types.StandardEntryType; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -29,25 +29,22 @@ void setUp() { @Test void writeThenReadBack() throws Exception { - BibDatabaseContext inputDatabaseContext = GitBibParser.parseBibFromGit( - """ - @article{a, - author = {Alice}, - title = {Test} - } - """, importFormatPreferences); + BibDatabaseContext inputDatabaseContext = BibDatabaseContext.of(""" + @article{a, + author = {Alice}, + title = {Test}, + } + """, importFormatPreferences); Path tempFile = Files.createTempFile("tempgitwriter", ".bib"); - GitFileWriter.write(tempFile, inputDatabaseContext, importFormatPreferences); - BibDatabaseContext outputCtx = GitBibParser.parseBibFromGit(Files.readString(tempFile), importFormatPreferences); - - List inputEntries = inputDatabaseContext.getDatabase().getEntries(); - List outputEntries = outputCtx.getDatabase().getEntries(); - - assertEquals(inputEntries.size(), outputEntries.size()); - assertEquals(inputEntries.getFirst().getCitationKey(), outputEntries.getFirst().getCitationKey()); - assertEquals(inputEntries.getFirst().getField(StandardField.AUTHOR), outputEntries.getFirst().getField(StandardField.AUTHOR)); + 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 index adafa25dcb3..b2765428493 100644 --- a/jablib/src/test/java/org/jabref/logic/git/util/GitRevisionLocatorTest.java +++ b/jablib/src/test/java/org/jabref/logic/git/util/GitRevisionLocatorTest.java @@ -8,7 +8,12 @@ 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; @@ -16,10 +21,19 @@ 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 = Git.init().setDirectory(tempDir.toFile()).setInitialBranch("main").call(); + git = Git.init().setDirectory(tempDir.toFile()).setInitialBranch("main").call(); // create base commit Files.writeString(bibFile, "@article{a, author = {x}}", StandardCharsets.UTF_8); @@ -40,9 +54,19 @@ void locateMergeCommits(@TempDir Path tempDir) throws Exception { // restore HEAD to local git.checkout().setName("main").call(); - // simulate fake remote ref + 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); 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 a8878012549..31da078d777 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 @@ -9,7 +9,6 @@ import org.jabref.logic.git.conflicts.SemanticConflictDetector; import org.jabref.logic.git.conflicts.ThreeWayEntryConflict; -import org.jabref.logic.git.io.GitBibParser; import org.jabref.logic.git.io.GitFileReader; import org.jabref.logic.git.merge.MergePlan; import org.jabref.logic.importer.ImportFormatPreferences; @@ -20,6 +19,7 @@ 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; @@ -54,8 +54,15 @@ void setup(@TempDir Path tempDir) throws Exception { library = tempDir.resolve("library.bib"); } - @ParameterizedTest(name = "{0}") - @MethodSource("provideConflictCases") + @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); @@ -76,7 +83,7 @@ void semanticConflicts(String description, String base, String local, String rem private BibDatabaseContext parse(RevCommit commit) throws Exception { String content = GitFileReader.readFileFromCommit(git, commit, Path.of("library.bib")).orElse(""); - return GitBibParser.parseBibFromGit(content, importFormatPreferences); + return BibDatabaseContext.of(content, importFormatPreferences); } private RevCommit writeAndCommit(String content, String message, PersonIdent author, Path file, Git git) throws Exception { @@ -89,18 +96,19 @@ private RevCommit writeAndCommit(String content, String message, PersonIdent aut return writeAndCommit(content, message, author, library, git); } - static Stream provideConflictCases() { + // 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 = {lala}, + author = {Test Author}, doi = {xya}, } """, """ @article{a, - author = {lala}, + author = {Test Author}, doi = {xya}, } """, @@ -115,7 +123,7 @@ static Stream provideConflictCases() { Arguments.of("T2 - local changed a field, remote unchanged", """ @article{a, - author = {lala}, + author = {Test Author}, doi = {xya}, } """, @@ -127,7 +135,7 @@ static Stream provideConflictCases() { """, """ @article{a, - author = {lala}, + author = {Test Author}, doi = {xya}, } """, @@ -136,7 +144,7 @@ static Stream provideConflictCases() { Arguments.of("T3 - both changed to same value", """ @article{a, - author = {lala}, + author = {Test Author}, doi = {xya}, } """, @@ -157,7 +165,7 @@ static Stream provideConflictCases() { Arguments.of("T4 - both changed to different values", """ @article{a, - author = {lala}, + author = {Test Author}, doi = {xya}, } """, @@ -178,7 +186,7 @@ static Stream provideConflictCases() { Arguments.of("T5 - local deleted field, remote changed it", """ @article{a, - author = {lala}, + author = {Test Author}, doi = {xya}, } """, @@ -197,7 +205,7 @@ static Stream provideConflictCases() { Arguments.of("T6 - local changed, remote deleted", """ @article{a, - author = {lala}, + author = {Test Author}, doi = {xya}, } """, @@ -216,13 +224,13 @@ static Stream provideConflictCases() { Arguments.of("T7 - remote deleted, local unchanged", """ @article{a, - author = {lala}, + author = {Test Author}, doi = {xya}, } """, """ @article{a, - author = {lala}, + author = {Test Author}, doi = {xya}, } """, @@ -235,7 +243,7 @@ static Stream provideConflictCases() { Arguments.of("T8 - local changed field A, remote changed field B", """ @article{a, - author = {lala}, + author = {Test Author}, doi = {xya}, } """, @@ -247,7 +255,7 @@ static Stream provideConflictCases() { """, """ @article{a, - author = {lala}, + author = {Test Author}, doi = {xyz}, } """, @@ -256,19 +264,19 @@ static Stream provideConflictCases() { Arguments.of("T9 - field order changed only", """ @article{a, - author = {lala}, + author = {Test Author}, doi = {xya}, } """, """ @article{a, doi = {xya}, - author = {lala}, + author = {Test Author}, } """, """ @article{a, - author = {lala}, + author = {Test Author}, doi = {xya}, } """, @@ -277,12 +285,12 @@ static Stream provideConflictCases() { Arguments.of("T10 - local changed entry a, remote changed entry b", """ @article{a, - author = {lala}, + author = {Test Author}, doi = {xya}, } @article{b, - author = {lala}, + author = {Test Author}, doi = {xyz}, } """, @@ -292,7 +300,7 @@ static Stream provideConflictCases() { doi = {xya}, } @article{b, - author = {lala}, + author = {Test Author}, doi = {xyz}, } """, @@ -303,7 +311,7 @@ static Stream provideConflictCases() { } @article{a, - author = {lala}, + author = {Test Author}, doi = {xya}, } """, @@ -312,19 +320,19 @@ static Stream provideConflictCases() { Arguments.of("T11 - remote added field, local unchanged", """ @article{a, - author = {lala}, + author = {Test Author}, doi = {xya}, } """, """ @article{a, - author = {lala}, + author = {Test Author}, doi = {xya}, } """, """ @article{a, - author = {lala}, + author = {Test Author}, doi = {xya}, year = {2025}, } @@ -334,20 +342,20 @@ static Stream provideConflictCases() { Arguments.of("T12 - both added same field with different values", """ @article{a, - author = {lala}, + author = {Test Author}, doi = {xya}, } """, """ @article{a, - author = {lala}, + author = {Test Author}, doi = {xya}, year = {2023}, } """, """ @article{a, - author = {lala}, + author = {Test Author}, doi = {xya}, year = {2025}, } @@ -357,19 +365,19 @@ static Stream provideConflictCases() { Arguments.of("T13 - local added field, remote unchanged", """ @article{a, - author = {lala}, + author = {Test Author}, doi = {xya}, } """, """ @article{a, - author = {lala}, + author = {Test Author}, doi = {newfield}, } """, """ @article{a, - author = {lala}, + author = {Test Author}, doi = {xya}, } """, @@ -378,19 +386,19 @@ static Stream provideConflictCases() { Arguments.of("T14 - both added same field with same value", """ @article{a, - author = {lala}, + author = {Test Author}, doi = {xya}, } """, """ @article{a, - author = {lala}, + author = {Test Author}, doi = {value}, } """, """ @article{a, - author = {lala}, + author = {Test Author}, doi = {value}, } """, @@ -399,19 +407,19 @@ static Stream provideConflictCases() { Arguments.of("T15 - both added same field with different values", """ @article{a, - author = {lala}, + author = {Test Author}, doi = {xya}, } """, """ @article{a, - author = {lala}, + author = {Test Author}, doi = {value1}, } """, """ @article{a, - author = {lala}, + author = {Test Author}, doi = {value2}, } """, @@ -456,11 +464,11 @@ static Stream provideConflictCases() { void extractMergePlanT10OnlyRemoteChangedEntryB() throws Exception { String base = """ @article{a, - author = {lala}, + author = {Test Author}, doi = {xya}, } @article{b, - author = {lala}, + author = {Test Author}, doi = {xyz}, } """; @@ -470,7 +478,7 @@ void extractMergePlanT10OnlyRemoteChangedEntryB() throws Exception { doi = {xyz}, } @article{a, - author = {lala}, + author = {Test Author}, doi = {xya}, } """; @@ -493,13 +501,13 @@ void extractMergePlanT10OnlyRemoteChangedEntryB() throws Exception { void extractMergePlanT11RemoteAddsField() throws Exception { String base = """ @article{a, - author = {lala}, + author = {Test Author}, doi = {xya}, } """; String remote = """ @article{a, - author = {lala}, + author = {Test Author}, doi = {xya}, year = {2025}, } 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 index dad6a29f16e..e81bb2e6251 100644 --- a/jablib/src/test/java/org/jabref/logic/git/util/SemanticMergerTest.java +++ b/jablib/src/test/java/org/jabref/logic/git/util/SemanticMergerTest.java @@ -1,9 +1,9 @@ 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.io.GitBibParser; import org.jabref.logic.git.merge.MergePlan; import org.jabref.logic.git.merge.SemanticMerger; import org.jabref.logic.importer.ImportFormatPreferences; @@ -18,6 +18,7 @@ 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; @@ -30,39 +31,44 @@ void setup() { when(importFormatPreferences.bibEntryPreferences().getKeywordSeparator()).thenReturn(','); } - @ParameterizedTest(name = "Database patch: {0}") - @MethodSource("provideDatabasePatchCases") + // These test cases are based on documented scenarios from docs/code-howtos/git.md. + @ParameterizedTest + @MethodSource void patchDatabase(String description, String base, String local, String remote, String expectedAuthor) throws Exception { - BibDatabaseContext baseDatabaseContext = GitBibParser.parseBibFromGit(base, importFormatPreferences); - BibDatabaseContext localDatabaseContext = GitBibParser.parseBibFromGit(local, importFormatPreferences); - BibDatabaseContext remoteDatabaseContext = GitBibParser.parseBibFromGit(remote, importFormatPreferences); + 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(); - assertEquals(expectedAuthor, patched.getField(StandardField.AUTHOR).orElse(null)); + if (expectedAuthor == null) { + assertTrue(patched.getField(StandardField.AUTHOR).isEmpty()); + } else { + assertEquals(Optional.of(expectedAuthor), patched.getField(StandardField.AUTHOR)); + } } - static Stream provideDatabasePatchCases() { + static Stream patchDatabase() { return Stream.of( Arguments.of("T1 - remote changed a field, local unchanged", """ - @article{a, - author = {lala}, - doi = {xya}, - } - """, + @article{a, + author = {TestAuthor}, + doi = {ExampleDoi} + } + """, """ @article{a, - author = {lala}, - doi = {xya}, + author = {TestAuthor}, + doi = {ExampleDoi} } """, """ @article{a, author = {bob}, - doi = {xya}, + doi = {ExampleDoi} } """, "bob" @@ -70,20 +76,20 @@ static Stream provideDatabasePatchCases() { Arguments.of("T2 - local changed a field, remote unchanged", """ @article{a, - author = {lala}, - doi = {xya}, + author = {TestAuthor}, + doi = {ExampleDoi} } """, """ @article{a, author = {alice}, - doi = {xya}, + doi = {ExampleDoi} } """, """ @article{a, - author = {lala}, - doi = {xya}, + author = {TestAuthor}, + doi = {ExampleDoi} } """, "alice" @@ -91,23 +97,43 @@ static Stream provideDatabasePatchCases() { Arguments.of("T3 - both changed to same value", """ @article{a, - author = {lala}, - doi = {xya}, + author = {TestAuthor}, + doi = {ExampleDoi} } """, """ @article{a, author = {bob}, - doi = {xya}, + doi = {ExampleDoi} } """, """ @article{a, author = {bob}, - doi = {xya}, + 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()); + } } From 44f147adc0bf89cb98b5fe5c959bfc9923c00e5a Mon Sep 17 00:00:00 2001 From: Wanling Fu Date: Wed, 30 Jul 2025 00:12:57 +0100 Subject: [PATCH 28/37] chore: Fix markdown formatting issues #12350 --- docs/code-howtos/git.md | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/docs/code-howtos/git.md b/docs/code-howtos/git.md index 313256f80b0..0ba07755205 100644 --- a/docs/code-howtos/git.md +++ b/docs/code-howtos/git.md @@ -1,9 +1,11 @@ # 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. @@ -27,7 +29,9 @@ The following example illustrates a case where Git detects a conflict, but JabRe ``` ### Bob's Side + Bob reorders the entries and updates the author field of entry b: + ```bibtex @article{b, author = {author-b}, @@ -39,8 +43,11 @@ Bob reorders the entries and updates the author field of entry b: doi = {xya}, } ``` + ### Alice's Side + Alice modifies the author field of entry a: + ```bibtex @article{a, author = {author-a}, @@ -52,7 +59,9 @@ Alice modifies the author field of entry a: 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: @@ -64,9 +73,11 @@ However, JabRef is able to analyze the entries and determine that: 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` -* `org.jabref.logic.git.util.SemanticConflictDetectorTest`. + +* `org.jabref.logic.git.util.SemanticMergerTest#patchDatabase` +* `org.jabref.logic.git.util.SemanticConflictDetectorTest#patchDatabase`. ## Conflict Scenarios From 14358c50cba22a63c5517a283cb6fb80e02b2d32 Mon Sep 17 00:00:00 2001 From: Wanling Fu Date: Wed, 30 Jul 2025 00:20:15 +0100 Subject: [PATCH 29/37] chore: Apply OpenRewrite autoformat fixes #12350 --- docs/code-howtos/git.md | 1 + .../main/java/org/jabref/logic/git/io/GitRevisionLocator.java | 2 +- .../main/java/org/jabref/logic/git/status/GitStatusChecker.java | 2 +- 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/code-howtos/git.md b/docs/code-howtos/git.md index 0ba07755205..8a8b9e0a47d 100644 --- a/docs/code-howtos/git.md +++ b/docs/code-howtos/git.md @@ -65,6 +65,7 @@ Alice modifies the author field of entry a: 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. 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 index f749d43cc1f..b9fb715dc46 100644 --- a/jablib/src/main/java/org/jabref/logic/git/io/GitRevisionLocator.java +++ b/jablib/src/main/java/org/jabref/logic/git/io/GitRevisionLocator.java @@ -27,7 +27,7 @@ public RevisionTriple locateMergeCommits(Git git) throws GitAPIException, IOExce 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; + ObjectId remoteId = trackingBranch != null ? repo.resolve(trackingBranch) : null; assert remoteId != null : "Remote tracking branch is missing."; try (RevWalk walk = new RevWalk(git.getRepository())) { 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 42eaf0c6deb..5b39bd761d1 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 @@ -49,7 +49,7 @@ public static GitStatusSnapshot checkStatus(Path anyPathInsideRepo) { ObjectId localHead = repo.resolve("HEAD"); // TODO: Handle remote branches properly + Test String trackingBranch = new BranchConfig(repo.getConfig(), repo.getBranch()).getTrackingBranch(); - ObjectId remoteHead = (trackingBranch != null) ? repo.resolve(trackingBranch) : null; + ObjectId remoteHead = trackingBranch != null ? repo.resolve(trackingBranch) : null; SyncStatus syncStatus = determineSyncStatus(repo, localHead, remoteHead); From 7dfa8b57740e4024e3bbba62749e970542200a97 Mon Sep 17 00:00:00 2001 From: Wanling Fu Date: Wed, 30 Jul 2025 01:12:57 +0100 Subject: [PATCH 30/37] fix: Fix failing jablib tests #12350 --- .../logic/git/status/GitStatusChecker.java | 1 - .../src/main/resources/l10n/JabRef_en.properties | 16 ++++++++++++++++ .../org/jabref/logic/git/GitHandlerTest.java | 1 + 3 files changed, 17 insertions(+), 1 deletion(-) 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 5b39bd761d1..a23e8a69c5d 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 @@ -47,7 +47,6 @@ public static GitStatusSnapshot checkStatus(Path anyPathInsideRepo) { boolean hasUncommittedChanges = !status.isClean(); ObjectId localHead = repo.resolve("HEAD"); - // TODO: Handle remote branches properly + Test String trackingBranch = new BranchConfig(repo.getConfig(), repo.getBranch()).getTrackingBranch(); ObjectId remoteHead = trackingBranch != null ? repo.resolve(trackingBranch) : null; diff --git a/jablib/src/main/resources/l10n/JabRef_en.properties b/jablib/src/main/resources/l10n/JabRef_en.properties index 2fd7772295b..4129b88af95 100644 --- a/jablib/src/main/resources/l10n/JabRef_en.properties +++ b/jablib/src/main/resources/l10n/JabRef_en.properties @@ -3017,3 +3017,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 87942746092..98dfccd7b38 100644 --- a/jablib/src/test/java/org/jabref/logic/git/GitHandlerTest.java +++ b/jablib/src/test/java/org/jabref/logic/git/GitHandlerTest.java @@ -38,6 +38,7 @@ void setUpGitHandler() throws IOException, GitAPIException, URISyntaxException { Git remoteGit = Git.init() .setBare(true) .setDirectory(remoteRepoPath.toFile()) + .setInitialBranch("main") .call(); Path testFile = repositoryPath.resolve("initial.txt"); Files.writeString(testFile, "init"); From cdb5590289de771cdc6a84271c5e16ae182b9f88 Mon Sep 17 00:00:00 2001 From: Wanling <164749591+wanling0000@users.noreply.github.com> Date: Wed, 30 Jul 2025 11:48:18 +0100 Subject: [PATCH 31/37] Apply suggestions from code review Co-authored-by: Subhramit Basu --- .../main/java/org/jabref/gui/git/GitPullAction.java | 10 ++++++---- .../java/org/jabref/gui/git/GitStatusViewModel.java | 6 +++--- 2 files changed, 9 insertions(+), 7 deletions(-) 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 15e5ed7ce18..920c5353af4 100644 --- a/jabgui/src/main/java/org/jabref/gui/git/GitPullAction.java +++ b/jabgui/src/main/java/org/jabref/gui/git/GitPullAction.java @@ -39,7 +39,8 @@ public GitPullAction(DialogService dialogService, @Override public void execute() { - if (stateManager.getActiveDatabase().isEmpty()) { + Optional activeDatabaseOpt = stateManager.getActiveDatabase(); + if (activeDatabaseOpt.isEmpty()) { dialogService.showErrorDialogAndWait( Localization.lang("No library open"), Localization.lang("Please open a library before pulling.") @@ -47,8 +48,9 @@ public void execute() { return; } - BibDatabaseContext database = stateManager.getActiveDatabase().get(); - if (database.getDatabasePath().isEmpty()) { + 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.") @@ -56,7 +58,7 @@ public void execute() { return; } - Path bibFilePath = database.getDatabasePath().get(); + Path bibFilePath = bibFilePathOpt.get(); GitHandler handler = new GitHandler(bibFilePath.getParent()); GitConflictResolverDialog dialog = new GitConflictResolverDialog(dialogService, guiPreferences); GitConflictResolverStrategy resolver = new GuiGitConflictResolverStrategy(dialog); 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 f5907ea94e9..6bc54038568 100644 --- a/jabgui/src/main/java/org/jabref/gui/git/GitStatusViewModel.java +++ b/jabgui/src/main/java/org/jabref/gui/git/GitStatusViewModel.java @@ -56,9 +56,9 @@ public GitStatusViewModel(StateManager stateManager, Path bibFilePath) { } }); - stateManager.getActiveDatabase().ifPresent(ctx -> { - databaseContext.set(ctx); - updateStatusFromContext(ctx); + stateManager.getActiveDatabase().ifPresent(presentContext -> { + databaseContext.set(presentContext); + updateStatusFromContext(presentContext); }); } From c21d4a9435c7b1b6de581bd78103bb8b2bef3636 Mon Sep 17 00:00:00 2001 From: Wanling Fu Date: Wed, 30 Jul 2025 12:04:59 +0100 Subject: [PATCH 32/37] chore: Rename Optional variable names from maybe to Opt for clarity #12350 --- .../org/jabref/gui/git/GitPullViewModel.java | 6 +++--- .../jabref/gui/git/GitStatusViewModel.java | 12 +++++------ .../git/GuiGitConflictResolverStrategy.java | 7 +++---- .../org/jabref/logic/git/GitSyncService.java | 10 +++++----- .../logic/git/merge/SemanticMerger.java | 6 +++--- .../logic/git/status/GitStatusChecker.java | 20 +++++++++---------- .../logic/git/status/GitStatusSnapshot.java | 9 ++++++++- 7 files changed, 38 insertions(+), 32 deletions(-) diff --git a/jabgui/src/main/java/org/jabref/gui/git/GitPullViewModel.java b/jabgui/src/main/java/org/jabref/gui/git/GitPullViewModel.java index 0aa9fe405fa..5d76cf266a2 100644 --- a/jabgui/src/main/java/org/jabref/gui/git/GitPullViewModel.java +++ b/jabgui/src/main/java/org/jabref/gui/git/GitPullViewModel.java @@ -23,12 +23,12 @@ public GitPullViewModel(GitSyncService syncService, GitStatusViewModel gitStatus } public MergeResult pull() throws IOException, GitAPIException, JabRefException { - Optional maybeContext = gitStatusViewModel.getDatabaseContext(); - if (maybeContext.isEmpty()) { + Optional databaseContextOpt = gitStatusViewModel.getDatabaseContext(); + if (databaseContextOpt.isEmpty()) { throw new JabRefException(Localization.lang("Cannot pull: No active BibDatabaseContext.")); } - BibDatabaseContext localBibDatabaseContext = maybeContext.get(); + BibDatabaseContext localBibDatabaseContext = databaseContextOpt.get(); Path bibFilePath = localBibDatabaseContext.getDatabasePath().orElseThrow(() -> new JabRefException(Localization.lang("Cannot pull: .bib file path missing in BibDatabaseContext.")) ); 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 f5907ea94e9..f909853d35f 100644 --- a/jabgui/src/main/java/org/jabref/gui/git/GitStatusViewModel.java +++ b/jabgui/src/main/java/org/jabref/gui/git/GitStatusViewModel.java @@ -63,22 +63,22 @@ public GitStatusViewModel(StateManager stateManager, Path bibFilePath) { } protected void updateStatusFromContext(BibDatabaseContext context) { - Optional maybePath = context.getDatabasePath(); - if (maybePath.isEmpty()) { + Optional databasePathOpt = context.getDatabasePath(); + if (databasePathOpt.isEmpty()) { LOGGER.debug("No .bib file path available in database context; resetting Git status."); reset(); return; } - Path path = maybePath.get(); + Path path = databasePathOpt.get(); - Optional maybeHandler = GitHandler.fromAnyPath(path); - if (maybeHandler.isEmpty()) { + Optional gitHandlerOpt = GitHandler.fromAnyPath(path); + if (gitHandlerOpt.isEmpty()) { LOGGER.debug("No Git repository found for path {}; resetting Git status.", path); reset(); return; } - this.activeHandler = maybeHandler.get(); + this.activeHandler = gitHandlerOpt.get(); GitStatusSnapshot snapshot = GitStatusChecker.checkStatus(path); setTracking(snapshot.tracking()); diff --git a/jabgui/src/main/java/org/jabref/gui/git/GuiGitConflictResolverStrategy.java b/jabgui/src/main/java/org/jabref/gui/git/GuiGitConflictResolverStrategy.java index 9003c32579d..46ed97339e3 100644 --- a/jabgui/src/main/java/org/jabref/gui/git/GuiGitConflictResolverStrategy.java +++ b/jabgui/src/main/java/org/jabref/gui/git/GuiGitConflictResolverStrategy.java @@ -23,13 +23,12 @@ public GuiGitConflictResolverStrategy(GitConflictResolverDialog dialog) { public List resolveConflicts(List conflicts) { List resolved = new ArrayList<>(); for (ThreeWayEntryConflict conflict : conflicts) { - // TODO: We discussed somewhere else that Optional> should be List - and that this list is empty if it was cancelled. - Optional maybeEntry = dialog.resolveConflict(conflict); - if (maybeEntry.isEmpty()) { + 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(maybeEntry.get()); + resolved.add(entryOpt.get()); } return resolved; } 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 6b4b4dafeee..fbef280fc3b 100644 --- a/jablib/src/main/java/org/jabref/logic/git/GitSyncService.java +++ b/jablib/src/main/java/org/jabref/logic/git/GitSyncService.java @@ -51,8 +51,8 @@ public GitSyncService(ImportFormatPreferences importFormatPreferences, GitHandle } public MergeResult fetchAndMerge(BibDatabaseContext localDatabaseContext, Path bibFilePath) throws GitAPIException, IOException, JabRefException { - Optional maybeHandler = GitHandler.fromAnyPath(bibFilePath); - if (maybeHandler.isEmpty()) { + Optional gitHandlerOpt = GitHandler.fromAnyPath(bibFilePath); + if (gitHandlerOpt.isEmpty()) { LOGGER.warn("Pull aborted: The file is not inside a Git repository."); return MergeResult.failure(); } @@ -101,7 +101,7 @@ public MergeResult fetchAndMerge(BibDatabaseContext localDatabaseContext, Path b } public MergeResult performSemanticMerge(Git git, - Optional maybeBaseCommit, + Optional baseCommitOpt, RevCommit remoteCommit, BibDatabaseContext localDatabaseContext, Path bibFilePath) throws IOException, JabRefException { @@ -117,8 +117,8 @@ public MergeResult performSemanticMerge(Git git, // 1. Load three versions BibDatabaseContext base; - if (maybeBaseCommit.isPresent()) { - Optional baseContent = GitFileReader.readFileFromCommit(git, maybeBaseCommit.get(), relativePath); + 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(); 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 index 638500dd54c..be30b185032 100644 --- a/jablib/src/main/java/org/jabref/logic/git/merge/SemanticMerger.java +++ b/jablib/src/main/java/org/jabref/logic/git/merge/SemanticMerger.java @@ -43,14 +43,14 @@ public static void applyPatchToDatabase(BibDatabaseContext local, Map> entry : patchMap.entrySet()) { String key = entry.getKey(); Map fieldPatch = entry.getValue(); - Optional maybeLocalEntry = local.getDatabase().getEntryByCitationKey(key); + Optional localEntryOpt = local.getDatabase().getEntryByCitationKey(key); - if (maybeLocalEntry.isEmpty()) { + if (localEntryOpt.isEmpty()) { LOGGER.warn("Skip patch: local does not contain entry '{}'", key); continue; } - BibEntry localEntry = maybeLocalEntry.get(); + BibEntry localEntry = localEntryOpt.get(); applyFieldPatchToEntry(localEntry, fieldPatch); } } 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 a23e8a69c5d..19efa049d80 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 @@ -27,18 +27,18 @@ public class GitStatusChecker { private static final Logger LOGGER = LoggerFactory.getLogger(GitStatusChecker.class); public static GitStatusSnapshot checkStatus(Path anyPathInsideRepo) { - Optional maybeHandler = GitHandler.fromAnyPath(anyPathInsideRepo); + Optional gitHandlerOpt = GitHandler.fromAnyPath(anyPathInsideRepo); - if (maybeHandler.isEmpty()) { + if (gitHandlerOpt.isEmpty()) { return new GitStatusSnapshot( - false, + GitStatusSnapshot.NOT_TRACKING, SyncStatus.UNTRACKED, - false, - false, + GitStatusSnapshot.NO_CONFLICT, + GitStatusSnapshot.NO_UNCOMMITTED, Optional.empty() ); } - GitHandler handler = maybeHandler.get(); + GitHandler handler = gitHandlerOpt.get(); try (Git git = Git.open(handler.getRepositoryPathAsFile())) { Repository repo = git.getRepository(); @@ -53,7 +53,7 @@ public static GitStatusSnapshot checkStatus(Path anyPathInsideRepo) { SyncStatus syncStatus = determineSyncStatus(repo, localHead, remoteHead); return new GitStatusSnapshot( - true, + GitStatusSnapshot.TRACKING, syncStatus, hasConflict, hasUncommittedChanges, @@ -62,10 +62,10 @@ public static GitStatusSnapshot checkStatus(Path anyPathInsideRepo) { } catch (IOException | GitAPIException e) { LOGGER.warn("Failed to check Git status", e); return new GitStatusSnapshot( - true, + GitStatusSnapshot.TRACKING, SyncStatus.UNKNOWN, - false, - false, + GitStatusSnapshot.NO_CONFLICT, + GitStatusSnapshot.NO_UNCOMMITTED, Optional.empty() ); } 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 index d2a27749595..c14ae351c2b 100644 --- a/jablib/src/main/java/org/jabref/logic/git/status/GitStatusSnapshot.java +++ b/jablib/src/main/java/org/jabref/logic/git/status/GitStatusSnapshot.java @@ -7,4 +7,11 @@ public record GitStatusSnapshot( SyncStatus syncStatus, boolean conflict, boolean uncommittedChanges, - Optional lastPulledCommit) { } + Optional lastPulledCommit) { + public static final boolean TRACKING = true; + public static final boolean NOT_TRACKING = false; + public static final boolean CONFLICT = true; + public static final boolean NO_CONFLICT = false; + public static final boolean UNCOMMITTED = true; + public static final boolean NO_UNCOMMITTED = false; +} From 1052d4c274c29cda8de1ccbe761efb3e568f8057 Mon Sep 17 00:00:00 2001 From: Wanling Fu Date: Wed, 30 Jul 2025 12:35:06 +0100 Subject: [PATCH 33/37] chore: Import Optional dependencies #12350 --- .../main/java/org/jabref/gui/git/GitPullAction.java | 1 + .../org/jabref/logic/git/status/GitStatusChecker.java | 10 +++++----- .../org/jabref/logic/git/status/GitStatusSnapshot.java | 3 --- 3 files changed, 6 insertions(+), 8 deletions(-) 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 920c5353af4..5087d4d26c6 100644 --- a/jabgui/src/main/java/org/jabref/gui/git/GitPullAction.java +++ b/jabgui/src/main/java/org/jabref/gui/git/GitPullAction.java @@ -2,6 +2,7 @@ import java.io.IOException; import java.nio.file.Path; +import java.util.Optional; import org.jabref.gui.DialogService; import org.jabref.gui.StateManager; 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 19efa049d80..97e1c118a19 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 @@ -31,10 +31,10 @@ public static GitStatusSnapshot checkStatus(Path anyPathInsideRepo) { if (gitHandlerOpt.isEmpty()) { return new GitStatusSnapshot( - GitStatusSnapshot.NOT_TRACKING, + !GitStatusSnapshot.TRACKING, SyncStatus.UNTRACKED, - GitStatusSnapshot.NO_CONFLICT, - GitStatusSnapshot.NO_UNCOMMITTED, + !GitStatusSnapshot.CONFLICT, + !GitStatusSnapshot.UNCOMMITTED, Optional.empty() ); } @@ -64,8 +64,8 @@ public static GitStatusSnapshot checkStatus(Path anyPathInsideRepo) { return new GitStatusSnapshot( GitStatusSnapshot.TRACKING, SyncStatus.UNKNOWN, - GitStatusSnapshot.NO_CONFLICT, - GitStatusSnapshot.NO_UNCOMMITTED, + !GitStatusSnapshot.CONFLICT, + !GitStatusSnapshot.UNCOMMITTED, Optional.empty() ); } 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 index c14ae351c2b..61aa29a5529 100644 --- a/jablib/src/main/java/org/jabref/logic/git/status/GitStatusSnapshot.java +++ b/jablib/src/main/java/org/jabref/logic/git/status/GitStatusSnapshot.java @@ -9,9 +9,6 @@ public record GitStatusSnapshot( boolean uncommittedChanges, Optional lastPulledCommit) { public static final boolean TRACKING = true; - public static final boolean NOT_TRACKING = false; public static final boolean CONFLICT = true; - public static final boolean NO_CONFLICT = false; public static final boolean UNCOMMITTED = true; - public static final boolean NO_UNCOMMITTED = false; } From c371370ee9a4f2966ad67cefa7ee44cc42a600ec Mon Sep 17 00:00:00 2001 From: Wanling Fu Date: Wed, 30 Jul 2025 20:34:00 +0100 Subject: [PATCH 34/37] chore: Apply review suggestions #12350 --- docs/code-howtos/git.md | 2 +- .../test/java/org/jabref/logic/git/util/SemanticMergerTest.java | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/code-howtos/git.md b/docs/code-howtos/git.md index 8a8b9e0a47d..aa624c8cf85 100644 --- a/docs/code-howtos/git.md +++ b/docs/code-howtos/git.md @@ -78,7 +78,7 @@ Therefore, JabRef performs an automatic merge without requiring manual conflict The semantic conflict detection and merge resolution logic is covered by: * `org.jabref.logic.git.util.SemanticMergerTest#patchDatabase` -* `org.jabref.logic.git.util.SemanticConflictDetectorTest#patchDatabase`. +* `org.jabref.logic.git.util.SemanticConflictDetectorTest#semanticConflicts`. ## Conflict Scenarios 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 index e81bb2e6251..4d611d2760c 100644 --- a/jablib/src/test/java/org/jabref/logic/git/util/SemanticMergerTest.java +++ b/jablib/src/test/java/org/jabref/logic/git/util/SemanticMergerTest.java @@ -31,7 +31,6 @@ void setup() { when(importFormatPreferences.bibEntryPreferences().getKeywordSeparator()).thenReturn(','); } - // These test cases are based on documented scenarios from docs/code-howtos/git.md. @ParameterizedTest @MethodSource void patchDatabase(String description, String base, String local, String remote, String expectedAuthor) throws Exception { @@ -50,6 +49,7 @@ void patchDatabase(String description, String base, String local, String remote, } } + // 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", From 44c3fac58b95ccf2e85dfc532ee42c04570eb3df Mon Sep 17 00:00:00 2001 From: Wanling Fu Date: Fri, 1 Aug 2025 14:45:14 +0100 Subject: [PATCH 35/37] fix: Replace BibEntry.clone() with copy constructor #12350 --- .../main/java/org/jabref/logic/git/merge/GitMergeUtil.java | 4 ++-- .../main/java/org/jabref/logic/git/merge/SemanticMerger.java | 2 +- .../test/java/org/jabref/logic/git/GitSyncServiceTest.java | 2 +- .../jabref/logic/git/merge/GitSemanticMergeExecutorTest.java | 4 ++-- 4 files changed, 6 insertions(+), 6 deletions(-) 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 index 462dfc06a64..e10c4d0c0a1 100644 --- a/jablib/src/main/java/org/jabref/logic/git/merge/GitMergeUtil.java +++ b/jablib/src/main/java/org/jabref/logic/git/merge/GitMergeUtil.java @@ -37,12 +37,12 @@ public static BibDatabaseContext replaceEntries(BibDatabaseContext remote, List< } // Clone the entry and add it to new DB - newDatabase.insertEntry((BibEntry) entry.clone()); + newDatabase.insertEntry(new BibEntry(entry)); } // 4. Insert all resolved entries (cloned for safety) for (BibEntry resolved : resolvedEntries) { - newDatabase.insertEntry((BibEntry) resolved.clone()); + newDatabase.insertEntry(new BibEntry(resolved)); } // 5. Construct a new BibDatabaseContext with this new database and same metadata 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 index be30b185032..940e8ed632a 100644 --- a/jablib/src/main/java/org/jabref/logic/git/merge/SemanticMerger.java +++ b/jablib/src/main/java/org/jabref/logic/git/merge/SemanticMerger.java @@ -23,7 +23,7 @@ public static void applyMergePlan(BibDatabaseContext local, MergePlan plan) { applyPatchToDatabase(local, plan.fieldPatches()); for (BibEntry newEntry : plan.newEntries()) { - BibEntry clone = (BibEntry) newEntry.clone(); + BibEntry clone = new BibEntry(newEntry); clone.getCitationKey().ifPresent(citationKey -> local.getDatabase().getEntryByCitationKey(citationKey).ifPresent(existing -> { 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 e1e553682e5..9db9c5dd8c2 100644 --- a/jablib/src/test/java/org/jabref/logic/git/GitSyncServiceTest.java +++ b/jablib/src/test/java/org/jabref/logic/git/GitSyncServiceTest.java @@ -291,7 +291,7 @@ void mergeConflictOnSameFieldTriggersDialogAndUsesUserResolution(@TempDir Path t 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 = (BibEntry) conflict.remote().clone(); + BibEntry resolved = new BibEntry(conflict.remote()); resolved.setField(StandardField.AUTHOR, "alice-c + bob-c"); return List.of(resolved); }); 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 index 7b81c42b109..9b03e5f5154 100644 --- a/jablib/src/test/java/org/jabref/logic/git/merge/GitSemanticMergeExecutorTest.java +++ b/jablib/src/test/java/org/jabref/logic/git/merge/GitSemanticMergeExecutorTest.java @@ -43,8 +43,8 @@ public void setup() throws IOException { BibEntry baseEntry = new BibEntry().withCitationKey("Smith2020") .withField(StandardField.TITLE, "Old Title"); - BibEntry localEntry = (BibEntry) baseEntry.clone(); - BibEntry remoteEntry = (BibEntry) baseEntry.clone(); + BibEntry localEntry = new BibEntry(baseEntry); + BibEntry remoteEntry = new BibEntry(baseEntry); remoteEntry.setField(StandardField.TITLE, "New Title"); base.getDatabase().insertEntry(baseEntry); From 3aa5d237097567699c3c0bb6fdd35bee4da0ae3f Mon Sep 17 00:00:00 2001 From: Wanling Fu Date: Fri, 1 Aug 2025 15:19:28 +0100 Subject: [PATCH 36/37] fix: Update import paths #12350 --- .../org/jabref/gui/git/GitConflictResolverDialog.java | 8 ++++---- .../logic/git/merge/GitSemanticMergeExecutorImpl.java | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/jabgui/src/main/java/org/jabref/gui/git/GitConflictResolverDialog.java b/jabgui/src/main/java/org/jabref/gui/git/GitConflictResolverDialog.java index 701102a19c4..3a47ca04ea1 100644 --- a/jabgui/src/main/java/org/jabref/gui/git/GitConflictResolverDialog.java +++ b/jabgui/src/main/java/org/jabref/gui/git/GitConflictResolverDialog.java @@ -3,10 +3,10 @@ import java.util.Optional; import org.jabref.gui.DialogService; -import org.jabref.gui.mergeentries.MergeEntriesDialog; -import org.jabref.gui.mergeentries.newmergedialog.ShowDiffConfig; -import org.jabref.gui.mergeentries.newmergedialog.diffhighlighter.DiffHighlighter; -import org.jabref.gui.mergeentries.newmergedialog.toolbar.ThreeWayMergeToolbar; +import org.jabref.gui.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; 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 index 6a6539bf6e0..4959bd6cfde 100644 --- a/jablib/src/main/java/org/jabref/logic/git/merge/GitSemanticMergeExecutorImpl.java +++ b/jablib/src/main/java/org/jabref/logic/git/merge/GitSemanticMergeExecutorImpl.java @@ -18,7 +18,7 @@ public GitSemanticMergeExecutorImpl(ImportFormatPreferences importFormatPreferen } @Override - public MergeResult merge(BibDatabaseContext base, BibDatabaseContext local, BibDatabaseContext remote, Path bibFilePath) throws IOException, IOException { + 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); From 0319c09177af81c3752b9660f60e8e386d41fc85 Mon Sep 17 00:00:00 2001 From: Wanling Fu Date: Fri, 1 Aug 2025 15:39:33 +0100 Subject: [PATCH 37/37] retrigger CI