Skip to content

Commit ac66eed

Browse files
committed
chore(git): Add push test + Fix failing unit test #12350
1 parent 269473d commit ac66eed

File tree

13 files changed

+423
-43
lines changed

13 files changed

+423
-43
lines changed

docs/code-howtos/git.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
# git
22

33
## Conflict Scenarios
4+
45
- **T1.** Remote changed a field, local did not
56
→ No conflict.
67
The local version remained unchanged, so the remote change can be safely applied.
@@ -68,4 +69,3 @@
6869
- **T17.** Both added the same entry key with identical values
6970
→ No conflict.
7071
Both sides created a new entry with the same citation key and identical fields, so it can be merged safely.
71-

jabgui/src/main/java/org/jabref/gui/git/GitPullViewModel.java

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,10 @@
2929
import org.eclipse.jgit.api.errors.GitAPIException;
3030
import org.eclipse.jgit.revwalk.RevCommit;
3131

32+
/**
33+
* ViewModel responsible for coordinating UI-bound Git Pull workflow,
34+
* including conflict resolution.
35+
*/
3236
public class GitPullViewModel extends AbstractViewModel {
3337
private final ImportFormatPreferences importFormatPreferences;
3438
private final GitConflictResolver conflictResolver;

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

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -215,7 +215,7 @@ public void fetchOnCurrentBranch() throws IOException {
215215

216216
/**
217217
* 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.
218+
* If a directory containing a .git folder is found, a new GitHandler is created and returned.
219219
*
220220
* @param anyPathInsideRepo Any file or directory path that is assumed to be inside a Git repository
221221
* @return Optional containing a GitHandler initialized with the repository root, or empty if not found
@@ -230,4 +230,12 @@ public static Optional<GitHandler> fromAnyPath(Path anyPathInsideRepo) {
230230
}
231231
return Optional.empty();
232232
}
233+
234+
public File getRepositoryPathAsFile() {
235+
return repositoryPathAsFile;
236+
}
237+
238+
public Git open() throws IOException {
239+
return Git.open(this.repositoryPathAsFile);
240+
}
233241
}

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

Lines changed: 102 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,9 @@
1616
import org.jabref.logic.git.merge.MergePlan;
1717
import org.jabref.logic.git.merge.SemanticMerger;
1818
import org.jabref.logic.git.model.MergeResult;
19+
import org.jabref.logic.git.status.GitStatusChecker;
20+
import org.jabref.logic.git.status.GitStatusSnapshot;
21+
import org.jabref.logic.git.status.SyncStatus;
1922
import org.jabref.logic.importer.ImportFormatPreferences;
2023
import org.jabref.model.database.BibDatabaseContext;
2124
import org.jabref.model.entry.BibEntry;
@@ -27,11 +30,23 @@
2730
import org.slf4j.LoggerFactory;
2831

2932
/**
30-
* Orchestrator for git sync service
33+
* GitSyncService currently serves as an orchestrator for Git pull/push logic.
3134
* if (hasConflict)
3235
* → UI merge;
3336
* else
3437
* → autoMerge := local + remoteDiff
38+
*
39+
* NOTICE:
40+
* - TODO:This class will be **deprecated** in the near future to avoid architecture violation (logic → gui)!
41+
* - The underlying business logic will not change significantly.
42+
* - Only the coordination responsibilities will shift to GUI/ViewModel layer.
43+
*
44+
* PLAN:
45+
* - All orchestration logic (pull/push, merge, resolve, commit)
46+
* will be **moved into corresponding ViewModels**, such as:
47+
* - GitPullViewModel
48+
* - GitPushViewModel
49+
* - GitStatusViewModel
3550
*/
3651
public class GitSyncService {
3752
private static final Logger LOGGER = LoggerFactory.getLogger(GitSyncService.class);
@@ -49,24 +64,42 @@ public GitSyncService(ImportFormatPreferences importFormatPreferences, GitHandle
4964
* Called when user clicks Pull
5065
*/
5166
public MergeResult fetchAndMerge(Path bibFilePath) throws GitAPIException, IOException, JabRefException {
52-
Git git = Git.open(bibFilePath.getParent().toFile());
67+
GitStatusSnapshot status = GitStatusChecker.checkStatus(bibFilePath);
5368

54-
// 1. fetch latest remote branch
55-
gitHandler.fetchOnCurrentBranch();
56-
57-
// 2. Locating the base / local / remote versions
58-
GitRevisionLocator locator = new GitRevisionLocator();
59-
RevisionTriple triple = locator.locateMergeCommits(git);
69+
if (!status.tracking()) {
70+
LOGGER.warn("Pull aborted: The file is not under Git version control.");
71+
return MergeResult.failure();
72+
}
6073

61-
// 3. Calling semantic merge logic
62-
MergeResult result = performSemanticMerge(git, triple.base(), triple.local(), triple.remote(), bibFilePath);
74+
if (status.conflict()) {
75+
LOGGER.warn("Pull aborted: Local repository has unresolved merge conflicts.");
76+
return MergeResult.failure();
77+
}
6378

64-
// 4. Automatic merge
65-
if (result.isSuccessful()) {
66-
gitHandler.createCommitOnCurrentBranch("Auto-merged by JabRef", !AMEND);
79+
if (status.syncStatus() == SyncStatus.UP_TO_DATE || status.syncStatus() == SyncStatus.AHEAD) {
80+
LOGGER.info("Pull skipped: Local branch is already up to date with remote.");
81+
return MergeResult.success();
6782
}
6883

69-
return result;
84+
// Status is BEHIND or DIVERGED
85+
try (Git git = gitHandler.open()) {
86+
// 1. Fetch latest remote branch
87+
gitHandler.fetchOnCurrentBranch();
88+
89+
// 2. Locate base / local / remote commits
90+
GitRevisionLocator locator = new GitRevisionLocator();
91+
RevisionTriple triple = locator.locateMergeCommits(git);
92+
93+
// 3. Perform semantic merge
94+
MergeResult result = performSemanticMerge(git, triple.base(), triple.local(), triple.remote(), bibFilePath);
95+
96+
// 4. Auto-commit merge result if successful
97+
if (result.isSuccessful()) {
98+
gitHandler.createCommitOnCurrentBranch("Auto-merged by JabRef", !AMEND);
99+
}
100+
101+
return result;
102+
}
70103
}
71104

72105
public MergeResult performSemanticMerge(Git git,
@@ -80,7 +113,6 @@ public MergeResult performSemanticMerge(Git git,
80113
Path relativePath;
81114

82115
// TODO: Validate that the .bib file is inside the Git repository earlier in the workflow.
83-
// This check might be better placed before calling performSemanticMerge.
84116
if (!bibPath.startsWith(workTree)) {
85117
throw new IllegalStateException("Given .bib file is not inside repository");
86118
}
@@ -122,17 +154,61 @@ public MergeResult performSemanticMerge(Git git,
122154
return MergeResult.success();
123155
}
124156

125-
// WIP
126-
// TODO: add test
127-
public void push(Path bibFilePath) throws GitAPIException, IOException {
128-
// 1. Auto-commit: commit if there are changes
129-
boolean committed = gitHandler.createCommitOnCurrentBranch("Changes committed by JabRef", !AMEND);
130-
131-
// 2. push to remote
132-
if (committed) {
133-
gitHandler.pushCommitsToRemoteRepository();
134-
} else {
135-
LOGGER.info("No changes to commit — skipping push");
157+
public void push(Path bibFilePath) throws GitAPIException, IOException, JabRefException {
158+
GitStatusSnapshot status = GitStatusChecker.checkStatus(bibFilePath);
159+
160+
if (!status.tracking()) {
161+
LOGGER.warn("Push aborted: file is not tracked by Git");
162+
return;
163+
}
164+
165+
switch (status.syncStatus()) {
166+
case UP_TO_DATE -> {
167+
boolean committed = gitHandler.createCommitOnCurrentBranch("Changes committed by JabRef", !AMEND);
168+
if (committed) {
169+
gitHandler.pushCommitsToRemoteRepository();
170+
} else {
171+
LOGGER.info("No changes to commit — skipping push");
172+
}
173+
}
174+
175+
case AHEAD -> {
176+
gitHandler.pushCommitsToRemoteRepository();
177+
}
178+
179+
case BEHIND -> {
180+
LOGGER.warn("Push aborted: Local branch is behind remote. Please pull first.");
181+
}
182+
183+
case DIVERGED -> {
184+
try (Git git = gitHandler.open()) {
185+
GitRevisionLocator locator = new GitRevisionLocator();
186+
RevisionTriple triple = locator.locateMergeCommits(git);
187+
188+
MergeResult mergeResult = performSemanticMerge(git, triple.base(), triple.local(), triple.remote(), bibFilePath);
189+
190+
if (!mergeResult.isSuccessful()) {
191+
LOGGER.warn("Semantic merge failed — aborting push");
192+
return;
193+
}
194+
195+
boolean committed = gitHandler.createCommitOnCurrentBranch("Merged changes", !AMEND);
196+
197+
if (committed) {
198+
gitHandler.pushCommitsToRemoteRepository();
199+
} else {
200+
LOGGER.info("Nothing to commit after semantic merge — skipping push");
201+
}
202+
}
203+
}
204+
205+
case CONFLICT -> {
206+
LOGGER.warn("Push aborted: Local repository has unresolved merge conflicts.");
207+
}
208+
209+
case UNTRACKED, UNKNOWN -> {
210+
LOGGER.warn("Push aborted: Untracked or unknown Git status.");
211+
}
136212
}
137213
}
138214
}

jablib/src/main/java/org/jabref/logic/git/io/GitFileWriter.java

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,8 @@
66
import java.nio.file.Path;
77

88
import org.jabref.logic.exporter.AtomicFileWriter;
9+
import org.jabref.logic.exporter.BibDatabaseWriter;
910
import org.jabref.logic.exporter.BibWriter;
10-
import org.jabref.logic.exporter.BibtexDatabaseWriter;
1111
import org.jabref.logic.exporter.SelfContainedSaveConfiguration;
1212
import org.jabref.logic.importer.ImportFormatPreferences;
1313
import org.jabref.model.database.BibDatabaseContext;
@@ -21,7 +21,7 @@ public static void write(Path file, BibDatabaseContext bibDatabaseContext, Impor
2121
synchronized (bibDatabaseContext) {
2222
try (AtomicFileWriter fileWriter = new AtomicFileWriter(file, encoding, saveConfiguration.shouldMakeBackup())) {
2323
BibWriter bibWriter = new BibWriter(fileWriter, bibDatabaseContext.getDatabase().getNewLineSeparator());
24-
BibtexDatabaseWriter writer = new BibtexDatabaseWriter(
24+
BibDatabaseWriter writer = new BibDatabaseWriter(
2525
bibWriter,
2626
saveConfiguration,
2727
importPrefs.fieldPreferences(),

jablib/src/main/java/org/jabref/logic/git/io/GitRevisionLocator.java

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,10 @@
77
import org.eclipse.jgit.api.Git;
88
import org.eclipse.jgit.api.errors.GitAPIException;
99
import org.eclipse.jgit.lib.ObjectId;
10+
import org.eclipse.jgit.lib.Repository;
1011
import org.eclipse.jgit.revwalk.RevCommit;
1112
import org.eclipse.jgit.revwalk.RevWalk;
13+
import org.eclipse.jgit.revwalk.filter.RevFilter;
1214

1315
/**
1416
* Find the base/local/remote three commits:
@@ -21,26 +23,31 @@ public class GitRevisionLocator {
2123
private static final String REMOTE = "refs/remotes/origin/main";
2224

2325
public RevisionTriple locateMergeCommits(Git git) throws GitAPIException, IOException, JabRefException {
26+
Repository repo = git.getRepository();
2427
// assumes the remote branch is 'origin/main'
25-
ObjectId headId = git.getRepository().resolve(HEAD);
28+
ObjectId headId = repo.resolve(HEAD);
2629
// and uses the default remote tracking reference
2730
// does not support multiple remotes or custom remote branch names so far
28-
ObjectId remoteId = git.getRepository().resolve(REMOTE);
31+
ObjectId remoteId = repo.resolve(REMOTE);
2932
if (remoteId == null) {
3033
throw new IllegalStateException("Remote branch missing origin/main.");
3134
}
3235

3336
try (RevWalk walk = new RevWalk(git.getRepository())) {
3437
RevCommit local = walk.parseCommit(headId);
3538
RevCommit remote = walk.parseCommit(remoteId);
36-
37-
walk.setRevFilter(org.eclipse.jgit.revwalk.filter.RevFilter.MERGE_BASE);
38-
walk.markStart(local);
39-
walk.markStart(remote);
40-
41-
RevCommit base = walk.next();
39+
RevCommit base = findMergeBase(repo, local, remote);
4240

4341
return new RevisionTriple(base, local, remote);
4442
}
4543
}
44+
45+
public static RevCommit findMergeBase(Repository repo, RevCommit a, RevCommit b) throws IOException {
46+
try (RevWalk walk = new RevWalk(repo)) {
47+
walk.setRevFilter(RevFilter.MERGE_BASE);
48+
walk.markStart(a);
49+
walk.markStart(b);
50+
return walk.next();
51+
}
52+
}
4653
}

jablib/src/main/java/org/jabref/logic/git/model/MergeResult.java

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,10 @@ public static MergeResult success() {
1414
return new MergeResult(SUCCESS, List.of());
1515
}
1616

17+
public static MergeResult failure() {
18+
return new MergeResult(false, List.of());
19+
}
20+
1721
public boolean hasConflicts() {
1822
return !conflicts.isEmpty();
1923
}
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
package org.jabref.logic.git.status;
2+
3+
import java.io.IOException;
4+
import java.nio.file.Path;
5+
import java.util.Optional;
6+
7+
import org.jabref.logic.git.GitHandler;
8+
import org.jabref.logic.git.io.GitRevisionLocator;
9+
10+
import org.eclipse.jgit.api.Git;
11+
import org.eclipse.jgit.api.Status;
12+
import org.eclipse.jgit.api.errors.GitAPIException;
13+
import org.eclipse.jgit.lib.ObjectId;
14+
import org.eclipse.jgit.lib.Repository;
15+
import org.eclipse.jgit.revwalk.RevCommit;
16+
import org.eclipse.jgit.revwalk.RevWalk;
17+
import org.slf4j.Logger;
18+
import org.slf4j.LoggerFactory;
19+
20+
/**
21+
* This class is used to determine the status of a Git repository from any given path inside it.
22+
* If no repository is found, it returns a {@link GitStatusSnapshot} with tracking = false.
23+
* Otherwise, it returns a full snapshot including tracking status, sync status, and conflict state.
24+
*/
25+
public class GitStatusChecker {
26+
private static final Logger LOGGER = LoggerFactory.getLogger(GitStatusChecker.class);
27+
28+
public static GitStatusSnapshot checkStatus(Path anyPathInsideRepo) {
29+
Optional<GitHandler> maybeHandler = GitHandler.fromAnyPath(anyPathInsideRepo);
30+
31+
if (maybeHandler.isEmpty()) {
32+
return new GitStatusSnapshot(false, SyncStatus.UNTRACKED, false, Optional.empty());
33+
}
34+
GitHandler handler = maybeHandler.get();
35+
36+
try (Git git = Git.open(handler.getRepositoryPathAsFile())) {
37+
Repository repo = git.getRepository();
38+
Status status = git.status().call();
39+
boolean hasConflict = !status.getConflicting().isEmpty();
40+
41+
ObjectId localHead = repo.resolve("HEAD");
42+
ObjectId remoteHead = repo.resolve("refs/remotes/origin/main");
43+
SyncStatus syncStatus = determineSyncStatus(repo, localHead, remoteHead);
44+
45+
return new GitStatusSnapshot(
46+
true,
47+
syncStatus,
48+
hasConflict,
49+
Optional.ofNullable(localHead).map(ObjectId::getName)
50+
);
51+
} catch (IOException | GitAPIException e) {
52+
LOGGER.warn("Failed to check Git status: " + e.getMessage());
53+
return new GitStatusSnapshot(
54+
true,
55+
SyncStatus.UNKNOWN,
56+
false,
57+
Optional.empty()
58+
);
59+
}
60+
}
61+
62+
private static SyncStatus determineSyncStatus(Repository repo, ObjectId localHead, ObjectId remoteHead) throws IOException {
63+
if (localHead == null || remoteHead == null) {
64+
return SyncStatus.UNKNOWN;
65+
}
66+
67+
if (localHead.equals(remoteHead)) {
68+
return SyncStatus.UP_TO_DATE;
69+
}
70+
71+
try (RevWalk walk = new RevWalk(repo)) {
72+
RevCommit localCommit = walk.parseCommit(localHead);
73+
RevCommit remoteCommit = walk.parseCommit(remoteHead);
74+
RevCommit mergeBase = GitRevisionLocator.findMergeBase(repo, localCommit, remoteCommit);
75+
76+
boolean ahead = !localCommit.equals(mergeBase);
77+
boolean behind = !remoteCommit.equals(mergeBase);
78+
79+
if (ahead && behind) {
80+
return SyncStatus.DIVERGED;
81+
} else if (ahead) {
82+
return SyncStatus.AHEAD;
83+
} else if (behind) {
84+
return SyncStatus.BEHIND;
85+
} else {
86+
return SyncStatus.UNKNOWN;
87+
}
88+
}
89+
}
90+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
package org.jabref.logic.git.status;
2+
3+
import java.util.Optional;
4+
5+
public record GitStatusSnapshot(
6+
boolean tracking,
7+
SyncStatus syncStatus,
8+
boolean conflict,
9+
Optional<String> lastPulledCommit) { }

0 commit comments

Comments
 (0)