Skip to content

Implement logic orchestration for Git Pull/Push operations #13518

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 24 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from 18 commits
Commits
Show all changes
24 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
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
71 changes: 71 additions & 0 deletions docs/code-howtos/git.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
# 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.

- **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.
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
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 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();

// 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());
}
}
83 changes: 83 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,83 @@
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.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;

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 {
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.");
} 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);
}
}
}
33 changes: 33 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,33 @@
package org.jabref.gui.git;

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

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.eclipse.jgit.api.errors.GitAPIException;

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

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

public MergeResult pull() throws IOException, GitAPIException, JabRefException {
MergeResult result = syncService.fetchAndMerge(bibFilePath);

if (result.isSuccessful()) {
gitStatusViewModel.updateStatusFromPath(bibFilePath);
}

return result;
}
}
130 changes: 130 additions & 0 deletions jabgui/src/main/java/org/jabref/gui/git/GitStatusViewModel.java
Original file line number Diff line number Diff line change
@@ -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.
* 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;
private final ObjectProperty<SyncStatus> 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<GitHandler> maybeHandler = GitHandler.fromAnyPath(fileOrFolderInRepo);

if (maybeHandler.isEmpty()) {
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<SyncStatus> 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<GitHandler> getActiveHandler() {
return Optional.ofNullable(activeHandler);
}

public Path getCurrentBibFile() {
return currentBibFile;
}
}
Original file line number Diff line number Diff line change
@@ -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<BibDatabaseContext> resolveConflicts(List<ThreeWayEntryConflict> conflicts, BibDatabaseContext remote) {
List<BibEntry> resolved = new ArrayList<>();
for (ThreeWayEntryConflict conflict : conflicts) {
Optional<BibEntry> maybeConflict = dialog.resolveConflict(conflict);
if (maybeConflict.isEmpty()) {
return Optional.empty();
}
resolved.add(maybeConflict.get());
}
return Optional.of(GitMergeUtil.replaceEntries(remote, resolved));
}
}
Loading
Loading