Skip to content

Commit bfa37f0

Browse files
wanling0000kopporsubhramit
authored
Implement logic orchestration for Git Pull/Push operations (#13518)
* init: add basic test case for git sync service * feat(git): Add SemanticMerger MVP #12350 * feat(git): Implement normal sync loop without conflicts #12350 * chore(git): Apply review suggestions #12350 * chore(git): Fix CI-related issues #12350 * chore(git): Add push test + Fix failing unit test #12350 * chore(git): Fix variable names + refactoring (moving the GitConflictResolver interface to the logic module) #12350 * refactor(git): Apply strategy pattern for conflict resolution + add GitMergeUtil tests #12350 * fix: Repair failing unit tests and CI integration tests #12350 * Fix submodules * test: Try to fix GitHandlerTest by ensuring remote main branch exists #12350 * test: Try to fix GitSyncServiceTest/GitStatusCheckerTest by ensuring remote main branch exists #12350 * test: Try to fix GitSyncServiceTest by explicitly pushing refspec #12350 * test: Try to fix GitSyncServiceTest by explicitly checking out to the main branch #12350 * test: Try to fix GitSyncServiceTest #12350 * Change exception logging * test: Add debug output to GitSyncServiceTest #12350 * test: Fix GitSyncServiceTest by closing Git resources and improving conflict setup #12350 * test: Fix GitSyncServiceTest by switching to init() + remoteAdd() + push() for setup #12350 * test: Remove redundant git field in GitSyncServiceTest #12350 * fix: Apply trag bot suggestion and fix bug where newly merged entries were not written to file #12350 * fix: Resolve Modernizer violations #12350 * chore: Apply valid trag-bot suggestions #12350 * chore: Apply valid trag-bot suggestions in logic module; partially update GUI for integration preview #12350 * chore: Apply valid trag-bot suggestions (change RevisionTriple.base to Optional) #12350 * Use JSpecify * chore: Apply review suggestions #12350 * chore: Fix markdown formatting issues #12350 * chore: Apply OpenRewrite autoformat fixes #12350 * fix: Fix failing jablib tests #12350 * Apply suggestions from code review Co-authored-by: Subhramit Basu <[email protected]> * chore: Rename Optional variable names from maybe to Opt for clarity #12350 * chore: Import Optional dependencies #12350 * chore: Apply review suggestions #12350 * fix: Replace BibEntry.clone() with copy constructor #12350 * fix: Update import paths #12350 * retrigger CI --------- Co-authored-by: Oliver Kopp <[email protected]> Co-authored-by: Subhramit Basu <[email protected]>
1 parent cbed90f commit bfa37f0

39 files changed

+3134
-4
lines changed

build-logic/src/main/kotlin/org.jabref.gradle.feature.test.gradle.kts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,8 @@ testlogger {
2828
showPassed = false
2929
showSkipped = false
3030

31-
showCauses = false
32-
showStackTraces = false
31+
showCauses = true
32+
showStackTraces = true
3333
}
3434

3535
configurations.testCompileOnly {

docs/code-howtos/git.md

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
# Git
2+
3+
## Why Semantic Merge?
4+
5+
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.
6+
7+
This means:
8+
9+
* Even if Git detects conflicting lines,
10+
* JabRef is able to recognize that both sides are editing the same BibTeX entry,
11+
* And determine—at the field level—whether there is an actual semantic conflict.
12+
13+
## Merge Example
14+
15+
The following example illustrates a case where Git detects a conflict, but JabRef is able to resolve it automatically.
16+
17+
### Base Version
18+
19+
```bibtex
20+
@article{a,
21+
author = {don't know the author},
22+
doi = {xya},
23+
}
24+
25+
@article{b,
26+
author = {don't know the author},
27+
doi = {xyz},
28+
}
29+
```
30+
31+
### Bob's Side
32+
33+
Bob reorders the entries and updates the author field of entry b:
34+
35+
```bibtex
36+
@article{b,
37+
author = {author-b},
38+
doi = {xyz},
39+
}
40+
41+
@article{a,
42+
author = {don't know the author},
43+
doi = {xya},
44+
}
45+
```
46+
47+
### Alice's Side
48+
49+
Alice modifies the author field of entry a:
50+
51+
```bibtex
52+
@article{a,
53+
author = {author-a},
54+
doi = {xya},
55+
}
56+
57+
@article{b,
58+
author = {don't know the author},
59+
doi = {xyz},
60+
}
61+
```
62+
63+
### Merge Outcome
64+
65+
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.
66+
67+
However, JabRef is able to analyze the entries and determine that:
68+
69+
* Entry a was modified only by Alice.
70+
* Entry b was modified only by Bob.
71+
* There is no conflict at the field level.
72+
* The order of entries in the file does not affect BibTeX semantics.
73+
74+
Therefore, JabRef performs an automatic merge without requiring manual conflict resolution.
75+
76+
## Related Test Cases
77+
78+
The semantic conflict detection and merge resolution logic is covered by:
79+
80+
* `org.jabref.logic.git.util.SemanticMergerTest#patchDatabase`
81+
* `org.jabref.logic.git.util.SemanticConflictDetectorTest#semanticConflicts`.
82+
83+
## Conflict Scenarios
84+
85+
The following table describes when semantic merge in JabRef should consider a situation as conflict or not during a three-way merge.
86+
87+
| ID | Base | Local Change | Remote Change | Result |
88+
|------|----------------------------|------------------------------------|------------------------------------|--------|
89+
| T1 | Field present | (unchanged) | Field modified | No conflict. The local version remained unchanged, so the remote change can be safely applied. |
90+
| T2 | Field present | Field modified | (unchanged) | No conflict. The remote version did not touch the field, so the local change is preserved. |
91+
| 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. |
92+
| T4 | Field present | Field changed to A | Field changed to B | Conflict. This is a true semantic conflict that requires resolution. |
93+
| T5 | Field present | Field deleted | Field modified | Conflict. One side deleted the field while the other updated it—this is contradictory. |
94+
| T6 | Field present | Field modified | Field deleted | Conflict. Similar to T5, one side deletes, the other edits—this is a conflict. |
95+
| T7 | Field present | (unchanged) | Field deleted | No conflict. Local did not modify anything, so remote deletion is accepted. |
96+
| 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. |
97+
| 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. |
98+
| T10 | Entries A and B | Entry A modified | Entry B modified | No conflict. Modifications are on different entries, which are always safe to merge. |
99+
| T11 | Entry with existing fields | (unchanged) | New field added | No conflict. Remote addition can be applied without issues. |
100+
| 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. |
101+
| T13 | Entry with existing fields | New field added | (unchanged) | No conflict. Safe to preserve the local addition. |
102+
| 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. |
103+
| 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. |
104+
| 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. |
105+
| 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. |
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
package org.jabref.gui.git;
2+
3+
import java.util.Optional;
4+
5+
import org.jabref.gui.DialogService;
6+
import org.jabref.gui.mergeentries.threewaymerge.MergeEntriesDialog;
7+
import org.jabref.gui.mergeentries.threewaymerge.ShowDiffConfig;
8+
import org.jabref.gui.mergeentries.threewaymerge.diffhighlighter.DiffHighlighter;
9+
import org.jabref.gui.mergeentries.threewaymerge.toolbar.ThreeWayMergeToolbar;
10+
import org.jabref.gui.preferences.GuiPreferences;
11+
import org.jabref.logic.git.conflicts.ThreeWayEntryConflict;
12+
import org.jabref.logic.l10n.Localization;
13+
import org.jabref.model.entry.BibEntry;
14+
15+
/// A wrapper around {@link MergeEntriesDialog} for Git feature
16+
///
17+
/// Receives a semantic conflict (ThreeWayEntryConflict), pops up an interactive GUI (belonging to mergeentries), and returns a user-confirmed BibEntry merge result.
18+
public class GitConflictResolverDialog {
19+
private final DialogService dialogService;
20+
private final GuiPreferences preferences;
21+
22+
public GitConflictResolverDialog(DialogService dialogService, GuiPreferences preferences) {
23+
this.dialogService = dialogService;
24+
this.preferences = preferences;
25+
}
26+
27+
public Optional<BibEntry> resolveConflict(ThreeWayEntryConflict conflict) {
28+
BibEntry base = conflict.base();
29+
BibEntry local = conflict.local();
30+
BibEntry remote = conflict.remote();
31+
32+
MergeEntriesDialog dialog = new MergeEntriesDialog(local, remote, preferences);
33+
dialog.setLeftHeaderText(Localization.lang("Local"));
34+
dialog.setRightHeaderText(Localization.lang("Remote"));
35+
ShowDiffConfig diffConfig = new ShowDiffConfig(
36+
ThreeWayMergeToolbar.DiffView.SPLIT,
37+
DiffHighlighter.BasicDiffMethod.WORDS
38+
);
39+
dialog.configureDiff(diffConfig);
40+
41+
return dialogService.showCustomDialogAndWait(dialog)
42+
.map(result -> result.mergedEntry());
43+
}
44+
}
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
package org.jabref.gui.git;
2+
3+
import java.io.IOException;
4+
import java.nio.file.Path;
5+
import java.util.Optional;
6+
7+
import org.jabref.gui.DialogService;
8+
import org.jabref.gui.StateManager;
9+
import org.jabref.gui.actions.SimpleCommand;
10+
import org.jabref.gui.preferences.GuiPreferences;
11+
import org.jabref.logic.JabRefException;
12+
import org.jabref.logic.git.GitHandler;
13+
import org.jabref.logic.git.GitSyncService;
14+
import org.jabref.logic.git.conflicts.GitConflictResolverStrategy;
15+
import org.jabref.logic.git.merge.GitSemanticMergeExecutor;
16+
import org.jabref.logic.git.merge.GitSemanticMergeExecutorImpl;
17+
import org.jabref.logic.l10n.Localization;
18+
import org.jabref.logic.util.BackgroundTask;
19+
import org.jabref.logic.util.TaskExecutor;
20+
import org.jabref.model.database.BibDatabaseContext;
21+
22+
import org.eclipse.jgit.api.errors.GitAPIException;
23+
24+
public class GitPullAction extends SimpleCommand {
25+
26+
private final DialogService dialogService;
27+
private final StateManager stateManager;
28+
private final GuiPreferences guiPreferences;
29+
private final TaskExecutor taskExecutor;
30+
31+
public GitPullAction(DialogService dialogService,
32+
StateManager stateManager,
33+
GuiPreferences guiPreferences,
34+
TaskExecutor taskExecutor) {
35+
this.dialogService = dialogService;
36+
this.stateManager = stateManager;
37+
this.guiPreferences = guiPreferences;
38+
this.taskExecutor = taskExecutor;
39+
}
40+
41+
@Override
42+
public void execute() {
43+
Optional<BibDatabaseContext> activeDatabaseOpt = stateManager.getActiveDatabase();
44+
if (activeDatabaseOpt.isEmpty()) {
45+
dialogService.showErrorDialogAndWait(
46+
Localization.lang("No library open"),
47+
Localization.lang("Please open a library before pulling.")
48+
);
49+
return;
50+
}
51+
52+
BibDatabaseContext activeDatabase = activeDatabaseOpt.get();
53+
Optional<Path> bibFilePathOpt = activeDatabase.getDatabasePath();
54+
if (bibFilePathOpt.isEmpty()) {
55+
dialogService.showErrorDialogAndWait(
56+
Localization.lang("No library file path"),
57+
Localization.lang("Cannot pull from Git: No file is associated with this library.")
58+
);
59+
return;
60+
}
61+
62+
Path bibFilePath = bibFilePathOpt.get();
63+
GitHandler handler = new GitHandler(bibFilePath.getParent());
64+
GitConflictResolverDialog dialog = new GitConflictResolverDialog(dialogService, guiPreferences);
65+
GitConflictResolverStrategy resolver = new GuiGitConflictResolverStrategy(dialog);
66+
GitSemanticMergeExecutor mergeExecutor = new GitSemanticMergeExecutorImpl(guiPreferences.getImportFormatPreferences());
67+
68+
GitSyncService syncService = new GitSyncService(guiPreferences.getImportFormatPreferences(), handler, resolver, mergeExecutor);
69+
GitStatusViewModel statusViewModel = new GitStatusViewModel(stateManager, bibFilePath);
70+
GitPullViewModel viewModel = new GitPullViewModel(syncService, statusViewModel);
71+
72+
BackgroundTask
73+
.wrap(() -> viewModel.pull())
74+
.onSuccess(result -> {
75+
if (result.isSuccessful()) {
76+
dialogService.showInformationDialogAndWait(
77+
Localization.lang("Git Pull"),
78+
Localization.lang("Successfully merged and updated.")
79+
);
80+
} else {
81+
dialogService.showWarningDialogAndWait(
82+
Localization.lang("Git Pull"),
83+
Localization.lang("Merge completed with conflicts.")
84+
);
85+
}
86+
})
87+
.onFailure(ex -> {
88+
if (ex instanceof JabRefException e) {
89+
dialogService.showErrorDialogAndWait(
90+
Localization.lang("Git Pull Failed"),
91+
e.getLocalizedMessage(),
92+
e
93+
);
94+
} else if (ex instanceof GitAPIException e) {
95+
dialogService.showErrorDialogAndWait(
96+
Localization.lang("Git Pull Failed"),
97+
Localization.lang("An unexpected Git error occurred: %0", e.getLocalizedMessage()),
98+
e
99+
);
100+
} else if (ex instanceof IOException e) {
101+
dialogService.showErrorDialogAndWait(
102+
Localization.lang("Git Pull Failed"),
103+
Localization.lang("I/O error: %0", e.getLocalizedMessage()),
104+
e
105+
);
106+
} else {
107+
dialogService.showErrorDialogAndWait(
108+
Localization.lang("Git Pull Failed"),
109+
Localization.lang("Unexpected error: %0", ex.getLocalizedMessage()),
110+
ex
111+
);
112+
}
113+
})
114+
.executeWith(taskExecutor);
115+
}
116+
}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
package org.jabref.gui.git;
2+
3+
import java.io.IOException;
4+
import java.nio.file.Path;
5+
import java.util.Optional;
6+
7+
import org.jabref.gui.AbstractViewModel;
8+
import org.jabref.logic.JabRefException;
9+
import org.jabref.logic.git.GitSyncService;
10+
import org.jabref.logic.git.model.MergeResult;
11+
import org.jabref.logic.l10n.Localization;
12+
import org.jabref.model.database.BibDatabaseContext;
13+
14+
import org.eclipse.jgit.api.errors.GitAPIException;
15+
16+
public class GitPullViewModel extends AbstractViewModel {
17+
private final GitSyncService syncService;
18+
private final GitStatusViewModel gitStatusViewModel;
19+
20+
public GitPullViewModel(GitSyncService syncService, GitStatusViewModel gitStatusViewModel) {
21+
this.syncService = syncService;
22+
this.gitStatusViewModel = gitStatusViewModel;
23+
}
24+
25+
public MergeResult pull() throws IOException, GitAPIException, JabRefException {
26+
Optional<BibDatabaseContext> databaseContextOpt = gitStatusViewModel.getDatabaseContext();
27+
if (databaseContextOpt.isEmpty()) {
28+
throw new JabRefException(Localization.lang("Cannot pull: No active BibDatabaseContext."));
29+
}
30+
31+
BibDatabaseContext localBibDatabaseContext = databaseContextOpt.get();
32+
Path bibFilePath = localBibDatabaseContext.getDatabasePath().orElseThrow(() ->
33+
new JabRefException(Localization.lang("Cannot pull: .bib file path missing in BibDatabaseContext."))
34+
);
35+
36+
MergeResult result = syncService.fetchAndMerge(localBibDatabaseContext, bibFilePath);
37+
38+
if (result.isSuccessful()) {
39+
gitStatusViewModel.updateStatusFromContext(localBibDatabaseContext);
40+
}
41+
42+
return result;
43+
}
44+
}

0 commit comments

Comments
 (0)