Skip to content

Commit 269473d

Browse files
committed
chore(git): Fix CI-related issues #12350
1 parent 8d3fa2b commit 269473d

29 files changed

+591
-72
lines changed

docs/code-howtos/git.md

Lines changed: 9 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -5,42 +5,34 @@
55
→ No conflict.
66
The local version remained unchanged, so the remote change can be safely applied.
77

8-
98
- **T2.** Local changed a field, remote did not
109
→ No conflict.
1110
The remote version did not touch the field, so the local change is preserved.
1211

13-
1412
- **T3.** Both local and remote changed the same field to the same value
1513
→ No conflict.
1614
Although both sides changed the field, the result is identical—therefore, no conflict.
1715

18-
1916
- **T4.** Both local and remote changed the same field to different values
2017
→ Conflict.
2118
This is a true semantic conflict that requires resolution.
2219

23-
2420
- **T5.** Local deleted a field, remote modified the same field
2521
→ Conflict.
2622
One side deleted the field while the other updated it—this is contradictory.
2723

28-
2924
- **T6.** Local modified a field, remote deleted it
3025
→ Conflict.
3126
Similar to T5, one side deletes, the other edits—this is a conflict.
3227

33-
3428
- **T7.** Local unchanged, remote deleted a field
3529
→ No conflict.
3630
Local did not modify anything, so remote deletion is accepted.
3731

38-
3932
- **T8.** Local changed field A, remote changed field B (within the same entry)
4033
→ No conflict.
4134
Changes are on separate fields, so they can be merged safely.
4235

43-
4436
- **T9.** Both changed the same entry, but only field order changed
4537
→ No conflict.
4638
Field order is not semantically meaningful, so no conflict is detected.
@@ -49,27 +41,31 @@
4941
→ No conflict.
5042
Modifications are on different entries, which are always safe to merge.
5143

52-
5344
- **T11.** Remote added a new field, local did nothing
5445
→ No conflict.
5546
Remote addition can be applied without issues.
5647

57-
5848
- **T12.** Remote added a field, local also added the same field, but with different value
5949
→ Conflict.
6050
One side added while the other side modified—there is a semantic conflict.
6151

62-
6352
- **T13.** Local added a field, remote did nothing
6453
→ No conflict.
6554
Safe to preserve the local addition.
6655

67-
6856
- **T14.** Both added the same field with the same value
6957
→ No conflict.
7058
Even though both sides added it, the value is the same—no need for resolution.
7159

72-
7360
- **T15.** Both added the same field with different values
7461
→ Conflict.
7562
The same field is introduced with different values, which creates a conflict.
63+
64+
- **T16.** Both added the same entry key with different values
65+
→ Conflict.
66+
Both sides created a new entry with the same citation key, but the fields differ.
67+
68+
- **T17.** Both added the same entry key with identical values
69+
→ No conflict.
70+
Both sides created a new entry with the same citation key and identical fields, so it can be merged safely.
71+
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
package org.jabref.gui.git;
2+
3+
import java.util.Optional;
4+
5+
import org.jabref.logic.git.conflicts.ThreeWayEntryConflict;
6+
import org.jabref.model.entry.BibEntry;
7+
8+
public interface GitConflictResolver {
9+
Optional<BibEntry> resolveConflict(ThreeWayEntryConflict conflict);
10+
}
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
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.MergeEntriesDialog;
7+
import org.jabref.gui.mergeentries.newmergedialog.ShowDiffConfig;
8+
import org.jabref.gui.mergeentries.newmergedialog.diffhighlighter.DiffHighlighter;
9+
import org.jabref.gui.mergeentries.newmergedialog.toolbar.ThreeWayMergeToolbar;
10+
import org.jabref.gui.preferences.GuiPreferences;
11+
import org.jabref.logic.git.conflicts.ThreeWayEntryConflict;
12+
import org.jabref.model.entry.BibEntry;
13+
14+
/**
15+
* UI wrapper
16+
* Receives a semantic conflict (ThreeWayEntryConflict), pops up an interactive GUI (belonging to mergeentries), and returns a user-confirmed BibEntry merge result.
17+
*/
18+
public class GitConflictResolverViaDialog implements GitConflictResolver {
19+
private final DialogService dialogService;
20+
private final GuiPreferences preferences;
21+
22+
public GitConflictResolverViaDialog(DialogService dialogService, GuiPreferences preferences) {
23+
this.dialogService = dialogService;
24+
this.preferences = preferences;
25+
}
26+
27+
@Override
28+
public Optional<BibEntry> resolveConflict(ThreeWayEntryConflict conflict) {
29+
BibEntry base = conflict.base();
30+
BibEntry local = conflict.local();
31+
BibEntry remote = conflict.remote();
32+
33+
// Create Dialog + Set Title + Configure Diff Highlighting
34+
MergeEntriesDialog dialog = new MergeEntriesDialog(local, remote, preferences);
35+
dialog.setLeftHeaderText("Local");
36+
dialog.setRightHeaderText("Remote");
37+
ShowDiffConfig diffConfig = new ShowDiffConfig(
38+
ThreeWayMergeToolbar.DiffView.SPLIT,
39+
DiffHighlighter.BasicDiffMethod.WORDS
40+
);
41+
dialog.configureDiff(diffConfig);
42+
43+
return dialogService.showCustomDialogAndWait(dialog)
44+
.map(result -> result.mergedEntry());
45+
}
46+
}
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
package org.jabref.gui.git;
2+
3+
import java.io.IOException;
4+
import java.nio.file.Path;
5+
6+
import javax.swing.undo.UndoManager;
7+
8+
import org.jabref.gui.DialogService;
9+
import org.jabref.gui.StateManager;
10+
import org.jabref.gui.actions.SimpleCommand;
11+
import org.jabref.gui.preferences.GuiPreferences;
12+
import org.jabref.logic.JabRefException;
13+
import org.jabref.logic.git.model.MergeResult;
14+
import org.jabref.model.database.BibDatabaseContext;
15+
16+
import org.eclipse.jgit.api.errors.GitAPIException;
17+
18+
/**
19+
* - Check if Git is enabled
20+
* - Verify activeDatabase is not null
21+
* - Call GitPullViewModel.pull()
22+
*/
23+
public class GitPullAction extends SimpleCommand {
24+
25+
private final DialogService dialogService;
26+
private final StateManager stateManager;
27+
private final GuiPreferences guiPreferences;
28+
private final UndoManager undoManager;
29+
30+
public GitPullAction(DialogService dialogService,
31+
StateManager stateManager,
32+
GuiPreferences guiPreferences,
33+
UndoManager undoManager) {
34+
this.dialogService = dialogService;
35+
this.stateManager = stateManager;
36+
this.guiPreferences = guiPreferences;
37+
this.undoManager = undoManager;
38+
}
39+
40+
@Override
41+
public void execute() {
42+
// TODO: reconsider error handling
43+
if (stateManager.getActiveDatabase().isEmpty()) {
44+
dialogService.showErrorDialogAndWait("No database open", "Please open a database before pulling.");
45+
return;
46+
}
47+
48+
BibDatabaseContext database = stateManager.getActiveDatabase().get();
49+
if (database.getDatabasePath().isEmpty()) {
50+
dialogService.showErrorDialogAndWait("No .bib file path", "Cannot pull from Git: No file is associated with this database.");
51+
return;
52+
}
53+
54+
Path bibFilePath = database.getDatabasePath().get();
55+
try {
56+
GitPullViewModel viewModel = new GitPullViewModel(
57+
guiPreferences.getImportFormatPreferences(),
58+
new GitConflictResolverViaDialog(dialogService, guiPreferences),
59+
dialogService
60+
);
61+
MergeResult result = viewModel.pull(bibFilePath);
62+
63+
if (result.isSuccessful()) {
64+
dialogService.showInformationDialogAndWait("Git Pull", "Successfully merged and updated.");
65+
} else {
66+
dialogService.showWarningDialogAndWait("Git Pull", "Merge completed with conflicts.");
67+
}
68+
} catch (JabRefException e) {
69+
dialogService.showErrorDialogAndWait("Git Pull Failed", e);
70+
// TODO: error handling
71+
} catch (GitAPIException e) {
72+
throw new RuntimeException(e);
73+
} catch (IOException e) {
74+
throw new RuntimeException(e);
75+
}
76+
}
77+
}
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
package org.jabref.gui.git;
2+
3+
import java.io.IOException;
4+
import java.nio.file.Path;
5+
import java.util.ArrayList;
6+
import java.util.List;
7+
import java.util.Optional;
8+
9+
import org.jabref.gui.AbstractViewModel;
10+
import org.jabref.gui.DialogService;
11+
import org.jabref.logic.JabRefException;
12+
import org.jabref.logic.git.GitHandler;
13+
import org.jabref.logic.git.conflicts.SemanticConflictDetector;
14+
import org.jabref.logic.git.conflicts.ThreeWayEntryConflict;
15+
import org.jabref.logic.git.io.GitBibParser;
16+
import org.jabref.logic.git.io.GitFileReader;
17+
import org.jabref.logic.git.io.GitFileWriter;
18+
import org.jabref.logic.git.io.GitRevisionLocator;
19+
import org.jabref.logic.git.io.RevisionTriple;
20+
import org.jabref.logic.git.merge.GitMergeUtil;
21+
import org.jabref.logic.git.merge.MergePlan;
22+
import org.jabref.logic.git.merge.SemanticMerger;
23+
import org.jabref.logic.git.model.MergeResult;
24+
import org.jabref.logic.importer.ImportFormatPreferences;
25+
import org.jabref.model.database.BibDatabaseContext;
26+
import org.jabref.model.entry.BibEntry;
27+
28+
import org.eclipse.jgit.api.Git;
29+
import org.eclipse.jgit.api.errors.GitAPIException;
30+
import org.eclipse.jgit.revwalk.RevCommit;
31+
32+
public class GitPullViewModel extends AbstractViewModel {
33+
private final ImportFormatPreferences importFormatPreferences;
34+
private final GitConflictResolver conflictResolver;
35+
private final DialogService dialogService;
36+
37+
public GitPullViewModel(ImportFormatPreferences importFormatPreferences,
38+
GitConflictResolver conflictResolver,
39+
DialogService dialogService) {
40+
this.importFormatPreferences = importFormatPreferences;
41+
this.conflictResolver = conflictResolver;
42+
this.dialogService = dialogService;
43+
}
44+
45+
public MergeResult pull(Path bibFilePath) throws IOException, GitAPIException, JabRefException {
46+
// Open the Git repository from the parent folder of the .bib file
47+
Git git = Git.open(bibFilePath.getParent().toFile());
48+
49+
// Fetch latest changes from remote
50+
// TODO: Temporary — GitHandler should be injected from GitStatusViewModel once centralized git status is implemented.
51+
GitHandler gitHandler = GitHandler.fromAnyPath(bibFilePath)
52+
.orElseThrow(() -> new IllegalStateException("Not inside a Git repository"));
53+
54+
gitHandler.fetchOnCurrentBranch();
55+
56+
// Determine the three-way merge base, local, and remote commits
57+
GitRevisionLocator locator = new GitRevisionLocator();
58+
RevisionTriple triple = locator.locateMergeCommits(git);
59+
60+
RevCommit baseCommit = triple.base();
61+
RevCommit localCommit = triple.local();
62+
RevCommit remoteCommit = triple.remote();
63+
64+
// Ensure file is inside the Git working tree
65+
Path bibPath = bibFilePath.toRealPath();
66+
Path workTree = git.getRepository().getWorkTree().toPath().toRealPath();
67+
if (!bibPath.startsWith(workTree)) {
68+
throw new IllegalStateException("Given .bib file is not inside repository");
69+
}
70+
Path relativePath = workTree.relativize(bibPath);
71+
72+
// 1. Load three versions
73+
String baseContent = GitFileReader.readFileFromCommit(git, baseCommit, relativePath);
74+
String localContent = GitFileReader.readFileFromCommit(git, localCommit, relativePath);
75+
String remoteContent = GitFileReader.readFileFromCommit(git, remoteCommit, relativePath);
76+
77+
BibDatabaseContext base = GitBibParser.parseBibFromGit(baseContent, importFormatPreferences);
78+
BibDatabaseContext local = GitBibParser.parseBibFromGit(localContent, importFormatPreferences);
79+
BibDatabaseContext remote = GitBibParser.parseBibFromGit(remoteContent, importFormatPreferences);
80+
81+
// 2. Conflict detection
82+
List<ThreeWayEntryConflict> conflicts = SemanticConflictDetector.detectConflicts(base, local, remote);
83+
84+
// 3. If there are conflicts, prompt user to resolve them via GUI
85+
BibDatabaseContext effectiveRemote = remote;
86+
if (!conflicts.isEmpty()) {
87+
List<BibEntry> resolvedRemoteEntries = new ArrayList<>();
88+
for (ThreeWayEntryConflict conflict : conflicts) {
89+
// Ask user to resolve this conflict via GUI dialog
90+
Optional<BibEntry> maybeResolved = conflictResolver.resolveConflict(conflict);
91+
if (maybeResolved.isPresent()) {
92+
resolvedRemoteEntries.add(maybeResolved.get());
93+
} else {
94+
// User canceled the merge dialog → abort the whole merge
95+
throw new JabRefException("Merge aborted: Not all conflicts were resolved by user.");
96+
}
97+
}
98+
// Replace original conflicting entries in remote with resolved versions
99+
effectiveRemote = GitMergeUtil.replaceEntries(remote, resolvedRemoteEntries);
100+
}
101+
102+
// Extract merge plan and apply it to the local database
103+
MergePlan plan = SemanticConflictDetector.extractMergePlan(base, effectiveRemote);
104+
SemanticMerger.applyMergePlan(local, plan);
105+
106+
// Save merged result to .bib file
107+
GitFileWriter.write(bibFilePath, local, importFormatPreferences);
108+
109+
// Create Git commit for the merged result
110+
gitHandler.createCommitOnCurrentBranch("Auto-merged by JabRef", true);
111+
return MergeResult.success();
112+
}
113+
}

jablib/src/main/java/module-info.java

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,10 @@
106106
exports org.jabref.logic.git;
107107
exports org.jabref.logic.pseudonymization;
108108
exports org.jabref.logic.citation.repository;
109-
exports org.jabref.logic.git.util;
109+
exports org.jabref.logic.git.conflicts;
110+
exports org.jabref.logic.git.merge;
111+
exports org.jabref.logic.git.io;
112+
exports org.jabref.logic.git.model;
110113

111114
requires java.base;
112115

jablib/src/main/java/org/jabref/logic/git/GitHandler.java

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,4 +212,22 @@ public void fetchOnCurrentBranch() throws IOException {
212212
LOGGER.info("Failed to fetch from remote", e);
213213
}
214214
}
215+
216+
/**
217+
* Try to locate the Git repository root by walking up the directory tree starting from the given path.
218+
* If a directory containing a `.git` folder is found, a new GitHandler is created and returned.
219+
*
220+
* @param anyPathInsideRepo Any file or directory path that is assumed to be inside a Git repository
221+
* @return Optional containing a GitHandler initialized with the repository root, or empty if not found
222+
*/
223+
public static Optional<GitHandler> fromAnyPath(Path anyPathInsideRepo) {
224+
Path current = anyPathInsideRepo.toAbsolutePath();
225+
while (current != null) {
226+
if (Files.exists(current.resolve(".git"))) {
227+
return Optional.of(new GitHandler(current));
228+
}
229+
current = current.getParent();
230+
}
231+
return Optional.empty();
232+
}
215233
}

0 commit comments

Comments
 (0)