From 43ca5f70b94d1dee7ffaaa194c79bc967dcd1bed Mon Sep 17 00:00:00 2001 From: Wanling Fu Date: Wed, 28 May 2025 21:25:57 +0100 Subject: [PATCH 01/20] 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/20] 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/20] 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/20] 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/20] 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/20] 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/20] 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/20] 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/20] 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/20] 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/20] 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/20] 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/20] 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/20] 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/20] 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/20] 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/20] 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/20] 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/20] 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/20] 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) {