Skip to content
Merged
Show file tree
Hide file tree
Changes from 45 commits
Commits
Show all changes
48 commits
Select commit Hold shift + click to select a range
43ca5f7
init: add basic test case for git sync service
wanling0000 May 28, 2025
7fa3b86
feat(git): Add SemanticMerger MVP #12350
wanling0000 Jun 12, 2025
6233df9
feat(git): Implement normal sync loop without conflicts #12350
wanling0000 Jun 18, 2025
8d3fa2b
chore(git): Apply review suggestions #12350
wanling0000 Jun 22, 2025
269473d
chore(git): Fix CI-related issues #12350
wanling0000 Jul 7, 2025
ac66eed
chore(git): Add push test + Fix failing unit test #12350
wanling0000 Jul 9, 2025
69575b5
chore(git): Fix variable names + refactoring (moving the GitConflictR…
wanling0000 Jul 13, 2025
3fc49c3
refactor(git): Apply strategy pattern for conflict resolution + add G…
wanling0000 Jul 13, 2025
32c30a0
fix: Repair failing unit tests and CI integration tests #12350
wanling0000 Jul 13, 2025
f8250d1
Fix submodules
wanling0000 Jul 14, 2025
477cfae
Merge remote-tracking branch 'upstream/main' into clean-gsoc-git-supp…
wanling0000 Jul 15, 2025
61a19b8
test: Try to fix GitHandlerTest by ensuring remote main branch exists…
wanling0000 Jul 15, 2025
7e36b2e
test: Try to fix GitSyncServiceTest/GitStatusCheckerTest by ensuring …
wanling0000 Jul 15, 2025
cf60c02
test: Try to fix GitSyncServiceTest by explicitly pushing refspec #12350
wanling0000 Jul 15, 2025
bf05ce9
Merge branch 'main' into clean-gsoc-git-support-init
wanling0000 Jul 15, 2025
5799483
test: Try to fix GitSyncServiceTest by explicitly checking out to the…
wanling0000 Jul 15, 2025
36e7d95
Merge branch 'clean-gsoc-git-support-init' of github.com:wanling0000/…
wanling0000 Jul 15, 2025
9107ddf
test: Try to fix GitSyncServiceTest #12350
wanling0000 Jul 15, 2025
0ea08f1
Change exception logging
koppor Jul 16, 2025
bd2a738
Merge branch 'main' into clean-gsoc-git-support-init
koppor Jul 16, 2025
a78c8c4
test: Add debug output to GitSyncServiceTest #12350
wanling0000 Jul 16, 2025
fe3d84e
Merge branch 'clean-gsoc-git-support-init' of github.com:wanling0000/…
wanling0000 Jul 16, 2025
d055947
test: Fix GitSyncServiceTest by closing Git resources and improving c…
wanling0000 Jul 16, 2025
22f9704
test: Fix GitSyncServiceTest by switching to init() + remoteAdd() + p…
wanling0000 Jul 16, 2025
a0a39cc
test: Remove redundant git field in GitSyncServiceTest #12350
wanling0000 Jul 17, 2025
0419771
Merge remote-tracking branch 'upstream/main' into clean-gsoc-git-supp…
wanling0000 Jul 17, 2025
15aca84
fix: Apply trag bot suggestion and fix bug where newly merged entries…
wanling0000 Jul 25, 2025
b9a072a
Merge remote-tracking branch 'upstream/main' into clean-gsoc-git-supp…
wanling0000 Jul 25, 2025
1e13ffd
fix: Resolve Modernizer violations #12350
wanling0000 Jul 25, 2025
aaeac7c
chore: Apply valid trag-bot suggestions #12350
wanling0000 Jul 25, 2025
fc67d0d
chore: Apply valid trag-bot suggestions in logic module; partially up…
wanling0000 Jul 25, 2025
c721298
chore: Apply valid trag-bot suggestions (change RevisionTriple.base t…
wanling0000 Jul 25, 2025
d3f5536
Use JSpecify
koppor Jul 25, 2025
363698c
chore: Apply review suggestions #12350
wanling0000 Jul 29, 2025
2f4fd46
Merge branch 'clean-gsoc-git-support-init' of github.com:wanling0000/…
wanling0000 Jul 29, 2025
44f147a
chore: Fix markdown formatting issues #12350
wanling0000 Jul 29, 2025
14358c5
chore: Apply OpenRewrite autoformat fixes #12350
wanling0000 Jul 29, 2025
7dfa8b5
fix: Fix failing jablib tests #12350
wanling0000 Jul 30, 2025
cdb5590
Apply suggestions from code review
wanling0000 Jul 30, 2025
c21d4a9
chore: Rename Optional variable names from maybe to Opt for clarity #…
wanling0000 Jul 30, 2025
69a52c5
Merge branch 'clean-gsoc-git-support-init' of github.com:wanling0000/…
wanling0000 Jul 30, 2025
1052d4c
chore: Import Optional dependencies #12350
wanling0000 Jul 30, 2025
c371370
chore: Apply review suggestions #12350
wanling0000 Jul 30, 2025
6f36b5c
Merge remote-tracking branch 'upstream/main' into clean-gsoc-git-supp…
wanling0000 Jul 30, 2025
44c3fac
fix: Replace BibEntry.clone() with copy constructor #12350
wanling0000 Aug 1, 2025
2f763f4
Merge remote-tracking branch 'upstream/main' into clean-gsoc-git-supp…
wanling0000 Aug 1, 2025
3aa5d23
fix: Update import paths #12350
wanling0000 Aug 1, 2025
0319c09
retrigger CI
wanling0000 Aug 1, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,8 @@ testlogger {
showPassed = false
showSkipped = false

showCauses = false
showStackTraces = false
showCauses = true
showStackTraces = true
}

configurations.testCompileOnly {
Expand Down
105 changes: 105 additions & 0 deletions docs/code-howtos/git.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
# Git

## Why Semantic Merge?

In JabRef, we aim to minimize user interruptions when collaborating on the same `.bib` library file using Git. To achieve this, we go beyond Git’s default line-based syntactic merging and implement our own semantic merge logic that understands the structure of BibTeX entries.

This means:

* Even if Git detects conflicting lines,
* JabRef is able to recognize that both sides are editing the same BibTeX entry,
* And determine—at the field level—whether there is an actual semantic conflict.

## Merge Example

The following example illustrates a case where Git detects a conflict, but JabRef is able to resolve it automatically.

### Base Version

```bibtex
@article{a,
author = {don't know the author},
doi = {xya},
}

@article{b,
author = {don't know the author},
doi = {xyz},
}
```

### Bob's Side

Bob reorders the entries and updates the author field of entry b:

```bibtex
@article{b,
author = {author-b},
doi = {xyz},
}

@article{a,
author = {don't know the author},
doi = {xya},
}
```

### Alice's Side

Alice modifies the author field of entry a:

```bibtex
@article{a,
author = {author-a},
doi = {xya},
}

@article{b,
author = {don't know the author},
doi = {xyz},
}
```

### Merge Outcome

When Alice runs git pull, Git sees that both branches have modified overlapping lines (due to reordering and content changes) and reports a syntactic conflict.

However, JabRef is able to analyze the entries and determine that:

* Entry a was modified only by Alice.
* Entry b was modified only by Bob.
* There is no conflict at the field level.
* The order of entries in the file does not affect BibTeX semantics.

Therefore, JabRef performs an automatic merge without requiring manual conflict resolution.

## Related Test Cases

The semantic conflict detection and merge resolution logic is covered by:

* `org.jabref.logic.git.util.SemanticMergerTest#patchDatabase`
* `org.jabref.logic.git.util.SemanticConflictDetectorTest#semanticConflicts`.

## Conflict Scenarios

The following table describes when semantic merge in JabRef should consider a situation as conflict or not during a three-way merge.

| ID | Base | Local Change | Remote Change | Result |
|------|----------------------------|------------------------------------|------------------------------------|--------|
| T1 | Field present | (unchanged) | Field modified | No conflict. The local version remained unchanged, so the remote change can be safely applied. |
| T2 | Field present | Field modified | (unchanged) | No conflict. The remote version did not touch the field, so the local change is preserved. |
| T3 | Field present | Field changed to same value | Field changed to same value | No conflict. Although both sides changed the field, the result is identical—therefore, no conflict. |
| T4 | Field present | Field changed to A | Field changed to B | Conflict. This is a true semantic conflict that requires resolution. |
| T5 | Field present | Field deleted | Field modified | Conflict. One side deleted the field while the other updated it—this is contradictory. |
| T6 | Field present | Field modified | Field deleted | Conflict. Similar to T5, one side deletes, the other edits—this is a conflict. |
| T7 | Field present | (unchanged) | Field deleted | No conflict. Local did not modify anything, so remote deletion is accepted. |
| T8 | Entry with fields A and B | Field A modified | Field B modified | No conflict. Changes are on separate fields, so they can be merged safely. |
| T9 | Entry with fields A and B | Field order changed | Field order changed differently | No conflict. Field order is not semantically meaningful, so no conflict is detected. |
| T10 | Entries A and B | Entry A modified | Entry B modified | No conflict. Modifications are on different entries, which are always safe to merge. |
| T11 | Entry with existing fields | (unchanged) | New field added | No conflict. Remote addition can be applied without issues. |
| T12 | Entry with existing fields | New field added with value A | New field added with value B | Conflict. One side added while the other side modified—there is a semantic conflict. |
| T13 | Entry with existing fields | New field added | (unchanged) | No conflict. Safe to preserve the local addition. |
| T14 | Entry with existing fields | New field added with value A | New field added with value A | No conflict. Even though both sides added it, the value is the same—no need for resolution. |
| T15 | Entry with existing fields | New field added with value A | New field added with value B | Conflict. The same field is introduced with different values, which creates a conflict. |
| T16 | (entry not present) | New entry with author A | New entry with author B | Conflict. Both sides created a new entry with the same citation key, but the fields differ. |
| T17 | (entry not present) | New entry with identical fields | New entry with identical fields | No conflict. Both sides created a new entry with the same citation key and identical fields, so it can be merged safely. |
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
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.logic.l10n.Localization;
import org.jabref.model.entry.BibEntry;

/// A wrapper around {@link MergeEntriesDialog} for Git feature
///
/// Receives a semantic conflict (ThreeWayEntryConflict), pops up an interactive GUI (belonging to mergeentries), and returns a user-confirmed BibEntry merge result.
public class GitConflictResolverDialog {
private final DialogService dialogService;
private final GuiPreferences preferences;

public GitConflictResolverDialog(DialogService dialogService, GuiPreferences preferences) {
this.dialogService = dialogService;
this.preferences = preferences;
}

public Optional<BibEntry> resolveConflict(ThreeWayEntryConflict conflict) {
BibEntry base = conflict.base();
BibEntry local = conflict.local();
BibEntry remote = conflict.remote();

MergeEntriesDialog dialog = new MergeEntriesDialog(local, remote, preferences);
dialog.setLeftHeaderText(Localization.lang("Local"));
dialog.setRightHeaderText(Localization.lang("Remote"));
ShowDiffConfig diffConfig = new ShowDiffConfig(
ThreeWayMergeToolbar.DiffView.SPLIT,
DiffHighlighter.BasicDiffMethod.WORDS
);
dialog.configureDiff(diffConfig);

return dialogService.showCustomDialogAndWait(dialog)
.map(result -> result.mergedEntry());
}
}
116 changes: 116 additions & 0 deletions jabgui/src/main/java/org/jabref/gui/git/GitPullAction.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
package org.jabref.gui.git;

import java.io.IOException;
import java.nio.file.Path;
import java.util.Optional;

import org.jabref.gui.DialogService;
import org.jabref.gui.StateManager;
import org.jabref.gui.actions.SimpleCommand;
import org.jabref.gui.preferences.GuiPreferences;
import org.jabref.logic.JabRefException;
import org.jabref.logic.git.GitHandler;
import org.jabref.logic.git.GitSyncService;
import org.jabref.logic.git.conflicts.GitConflictResolverStrategy;
import org.jabref.logic.git.merge.GitSemanticMergeExecutor;
import org.jabref.logic.git.merge.GitSemanticMergeExecutorImpl;
import org.jabref.logic.l10n.Localization;
import org.jabref.logic.util.BackgroundTask;
import org.jabref.logic.util.TaskExecutor;
import org.jabref.model.database.BibDatabaseContext;

import org.eclipse.jgit.api.errors.GitAPIException;

public class GitPullAction extends SimpleCommand {

private final DialogService dialogService;
private final StateManager stateManager;
private final GuiPreferences guiPreferences;
private final TaskExecutor taskExecutor;

public GitPullAction(DialogService dialogService,
StateManager stateManager,
GuiPreferences guiPreferences,
TaskExecutor taskExecutor) {
this.dialogService = dialogService;
this.stateManager = stateManager;
this.guiPreferences = guiPreferences;
this.taskExecutor = taskExecutor;
}

@Override
public void execute() {
Optional<BibDatabaseContext> activeDatabaseOpt = stateManager.getActiveDatabase();
if (activeDatabaseOpt.isEmpty()) {
dialogService.showErrorDialogAndWait(
Localization.lang("No library open"),
Localization.lang("Please open a library before pulling.")
);
return;
}

BibDatabaseContext activeDatabase = activeDatabaseOpt.get();
Optional<Path> bibFilePathOpt = activeDatabase.getDatabasePath();
if (bibFilePathOpt.isEmpty()) {
dialogService.showErrorDialogAndWait(
Localization.lang("No library file path"),
Localization.lang("Cannot pull from Git: No file is associated with this library.")
);
return;
}

Path bibFilePath = bibFilePathOpt.get();
GitHandler handler = new GitHandler(bibFilePath.getParent());
GitConflictResolverDialog dialog = new GitConflictResolverDialog(dialogService, guiPreferences);
GitConflictResolverStrategy resolver = new GuiGitConflictResolverStrategy(dialog);
GitSemanticMergeExecutor mergeExecutor = new GitSemanticMergeExecutorImpl(guiPreferences.getImportFormatPreferences());

GitSyncService syncService = new GitSyncService(guiPreferences.getImportFormatPreferences(), handler, resolver, mergeExecutor);
GitStatusViewModel statusViewModel = new GitStatusViewModel(stateManager, bibFilePath);
GitPullViewModel viewModel = new GitPullViewModel(syncService, statusViewModel);

BackgroundTask
.wrap(() -> viewModel.pull())
.onSuccess(result -> {
if (result.isSuccessful()) {
dialogService.showInformationDialogAndWait(
Localization.lang("Git Pull"),
Localization.lang("Successfully merged and updated.")
);
} else {
dialogService.showWarningDialogAndWait(
Localization.lang("Git Pull"),
Localization.lang("Merge completed with conflicts.")
);
}
})
.onFailure(ex -> {
if (ex instanceof JabRefException e) {
dialogService.showErrorDialogAndWait(
Localization.lang("Git Pull Failed"),
e.getLocalizedMessage(),
e
);
} else if (ex instanceof GitAPIException e) {
dialogService.showErrorDialogAndWait(
Localization.lang("Git Pull Failed"),
Localization.lang("An unexpected Git error occurred: %0", e.getLocalizedMessage()),
e
);
} else if (ex instanceof IOException e) {
dialogService.showErrorDialogAndWait(
Localization.lang("Git Pull Failed"),
Localization.lang("I/O error: %0", e.getLocalizedMessage()),
e
);
} else {
dialogService.showErrorDialogAndWait(
Localization.lang("Git Pull Failed"),
Localization.lang("Unexpected error: %0", ex.getLocalizedMessage()),
ex
);
}
})
.executeWith(taskExecutor);
}
}
44 changes: 44 additions & 0 deletions jabgui/src/main/java/org/jabref/gui/git/GitPullViewModel.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
package org.jabref.gui.git;

import java.io.IOException;
import java.nio.file.Path;
import java.util.Optional;

import org.jabref.gui.AbstractViewModel;
import org.jabref.logic.JabRefException;
import org.jabref.logic.git.GitSyncService;
import org.jabref.logic.git.model.MergeResult;
import org.jabref.logic.l10n.Localization;
import org.jabref.model.database.BibDatabaseContext;

import org.eclipse.jgit.api.errors.GitAPIException;

public class GitPullViewModel extends AbstractViewModel {
private final GitSyncService syncService;
private final GitStatusViewModel gitStatusViewModel;

public GitPullViewModel(GitSyncService syncService, GitStatusViewModel gitStatusViewModel) {
this.syncService = syncService;
this.gitStatusViewModel = gitStatusViewModel;
}

public MergeResult pull() throws IOException, GitAPIException, JabRefException {
Optional<BibDatabaseContext> databaseContextOpt = gitStatusViewModel.getDatabaseContext();
if (databaseContextOpt.isEmpty()) {
throw new JabRefException(Localization.lang("Cannot pull: No active BibDatabaseContext."));
}

BibDatabaseContext localBibDatabaseContext = databaseContextOpt.get();
Path bibFilePath = localBibDatabaseContext.getDatabasePath().orElseThrow(() ->
new JabRefException(Localization.lang("Cannot pull: .bib file path missing in BibDatabaseContext."))
);

MergeResult result = syncService.fetchAndMerge(localBibDatabaseContext, bibFilePath);

if (result.isSuccessful()) {
gitStatusViewModel.updateStatusFromContext(localBibDatabaseContext);
}

return result;
}
}
Loading