-
-
Notifications
You must be signed in to change notification settings - Fork 2.9k
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
base: main
Are you sure you want to change the base?
Changes from all commits
43ca5f7
7fa3b86
6233df9
8d3fa2b
269473d
ac66eed
69575b5
3fc49c3
32c30a0
f8250d1
477cfae
61a19b8
7e36b2e
cf60c02
bf05ce9
5799483
36e7d95
9107ddf
0ea08f1
bd2a738
a78c8c4
fe3d84e
d055947
22f9704
a0a39cc
0419771
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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()); | ||
} | ||
} |
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) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Wrapping exceptions in RuntimeException loses the specific error context and makes error handling less effective. Should use more specific exception handling. |
||
throw new RuntimeException(e); | ||
} catch (IOException e) { | ||
throw new RuntimeException(e); | ||
} | ||
} | ||
} |
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); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The operation might be long-running and should use BackgroundTask instead of direct execution to prevent UI freezing during Git operations. |
||
|
||
if (result.isSuccessful()) { | ||
gitStatusViewModel.updateStatusFromPath(bibFilePath); | ||
} | ||
|
||
return result; | ||
} | ||
} |
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; | ||
} | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Comment is trivial and only describes what the code does without adding any additional information or reasoning. The operations are self-evident from the code that follows.