diff --git a/.vscode/settings.json b/.vscode/settings.json index 2094775de07..0daf1453fa0 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,5 +1,5 @@ { - "java.configuration.updateBuildConfiguration": "interactive", + "java.configuration.updateBuildConfiguration": "automatic", "java.format.settings.url": "/config/VSCode Code Style.xml", "java.checkstyle.configuration": "${workspaceFolder}/config/checkstyle/checkstyle_reviewdog.xml", "java.checkstyle.version": "10.3.4" diff --git a/CHANGELOG.md b/CHANGELOG.md index bcbd2265a9c..1e53c0e76b1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ Note that this project **does not** adhere to [Semantic Versioning](https://semv ### Added - When importing entries form the "Citation relations" tab, the field [cites](https://docs.jabref.org/advanced/entryeditor/entrylinks) is now filled according to the relationship between the entries. [#10572](https://github.com/JabRef/jabref/pull/10752) +- We added git support for backing up bib files with integration to local and remote repositories and support for SSH and username/password authentication [#578](https://github.com/koppor/jabref/issues/578) ### Changed diff --git a/build.gradle b/build.gradle index 0ccbd954707..b08262a6be9 100644 --- a/build.gradle +++ b/build.gradle @@ -144,6 +144,7 @@ dependencies { antlr4 'org.antlr:antlr4:4.13.1' implementation 'org.antlr:antlr4-runtime:4.13.1' + implementation group: 'org.eclipse.jgit', name: 'org.eclipse.jgit.ssh.apache', version: '6.7.0.202309050840-r' implementation group: 'org.eclipse.jgit', name: 'org.eclipse.jgit', version: '6.8.0.202311291450-r' implementation group: 'com.fasterxml.jackson.dataformat', name: 'jackson-dataformat-yaml', version: '2.16.1' @@ -253,6 +254,12 @@ dependencies { testImplementation "org.testfx:testfx-junit5:4.0.16-alpha" testImplementation "org.hamcrest:hamcrest-library:2.2" + testImplementation group: 'org.eclipse.jgit', name: 'org.eclipse.jgit.http.server', version: '6.7.0.202309050840-r' + testImplementation group: 'org.eclipse.jetty', name: 'jetty-servlet', version: '9.4.51.v20230217' + testImplementation group: 'org.eclipse.jetty', name: 'jetty-security', version: '9.4.51.v20230217' + + testCompileOnly group: 'javax.servlet', name: 'javax.servlet-api', version: '3.1.0' + checkstyle 'com.puppycrawl.tools:checkstyle:10.12.5' // xjc needs the runtime as well for the ant task, otherwise it fails xjc group: 'org.glassfish.jaxb', name: 'jaxb-xjc', version: '3.0.2' @@ -722,3 +729,7 @@ jmh { iterations = 10 fork = 2 } + +configurations.all { + exclude group: "commons-logging", module: "commons-logging" +} \ No newline at end of file diff --git a/src/main/java/module-info.java b/src/main/java/module-info.java index 48267ada078..9c20bd945f8 100644 --- a/src/main/java/module-info.java +++ b/src/main/java/module-info.java @@ -133,6 +133,7 @@ requires com.sun.jna.platform; requires org.eclipse.jgit; + requires org.eclipse.jgit.ssh.apache; uses org.eclipse.jgit.transport.SshSessionFactory; uses org.eclipse.jgit.lib.GpgSigner; diff --git a/src/main/java/org/jabref/gui/exporter/SaveDatabaseAction.java b/src/main/java/org/jabref/gui/exporter/SaveDatabaseAction.java index 954fabcf47f..92b5e63bb1b 100644 --- a/src/main/java/org/jabref/gui/exporter/SaveDatabaseAction.java +++ b/src/main/java/org/jabref/gui/exporter/SaveDatabaseAction.java @@ -32,6 +32,7 @@ import org.jabref.logic.exporter.BibtexDatabaseWriter; import org.jabref.logic.exporter.SaveException; import org.jabref.logic.exporter.SelfContainedSaveConfiguration; +import org.jabref.logic.git.GitHandler; import org.jabref.logic.l10n.Encodings; import org.jabref.logic.l10n.Localization; import org.jabref.logic.pdf.search.PdfIndexerManager; @@ -45,6 +46,11 @@ import org.jabref.model.metadata.SelfContainedSaveOrder; import org.jabref.preferences.PreferencesService; +import org.eclipse.jgit.api.errors.CheckoutConflictException; +import org.eclipse.jgit.api.errors.DetachedHeadException; +import org.eclipse.jgit.api.errors.GitAPIException; +import org.eclipse.jgit.api.errors.TransportException; +import org.eclipse.jgit.errors.NoRemoteRepositoryException; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -239,14 +245,26 @@ private boolean save(Path targetPath, SaveDatabaseMode mode) { boolean success = saveDatabase(targetPath, false, encoding, BibDatabaseWriter.SaveType.WITH_JABREF_META_DATA, getSaveOrder()); + if (preferences.getGitPreferences().getAutoSync()) { + success = automaticGitPull(targetPath); + } + + if (preferences.getGitPreferences().getAutoCommit()) { + boolean commited = automaticGitCommit(targetPath); + if (commited && preferences.getGitPreferences().getAutoSync()) { + automaticGitPush(targetPath); + } + } + if (success) { libraryTab.getUndoManager().markUnchanged(); libraryTab.resetChangedProperties(); } dialogService.notify(Localization.lang("Library saved")); + return success; } catch (SaveException ex) { - LOGGER.error(String.format("A problem occurred when trying to save the file %s", targetPath), ex); + LOGGER.error("A problem occurred when trying to save the file {}", targetPath, ex); dialogService.showErrorDialogAndWait(Localization.lang("Save library"), Localization.lang("Could not save file."), ex); return false; } finally { @@ -316,4 +334,67 @@ private void saveWithDifferentEncoding(Path file, boolean selectedOnly, Charset } } } + + /** + * @param filePath of library + * @return true on successful git pull + */ + public boolean automaticGitPull(Path filePath) { + GitHandler git = new GitHandler(filePath.getParent(), preferences.getGitPreferences()); + try { + git.pullOnCurrentBranch(); + } catch (CheckoutConflictException e) { + git.forceGitPull(); + dialogService.showErrorDialogAndWait(Localization.lang("Git"), Localization.lang("Local repository is out of date, please review the library and save again")); + LOGGER.info("Failed to pull"); + return false; + } catch (NoRemoteRepositoryException e) { + dialogService.showErrorDialogAndWait(Localization.lang("Git"), Localization.lang("No remote repository detected")); + LOGGER.info("No remote repository detected"); + } catch (DetachedHeadException e) { + dialogService.showErrorDialogAndWait(Localization.lang("Git"), Localization.lang("Git detached head")); + LOGGER.info("Git detached head"); + } catch (TransportException e) { + dialogService.showErrorDialogAndWait(Localization.lang("Git"), Localization.lang("Git credentials error")); + LOGGER.info("Git credentials error"); + } catch (GitAPIException e) { + LOGGER.info("Failed to pull"); + throw new RuntimeException(e); + } catch (IOException e) { + dialogService.showErrorDialogAndWait(Localization.lang("Git"), Localization.lang("Failed to open repository")); + LOGGER.info("Failed to open repository"); + } + return true; + } + + /** + * @param filePath of library + * @return true on successful git commit + */ + public boolean automaticGitCommit(Path filePath) { + GitHandler git = new GitHandler(filePath.getParent(), preferences.getGitPreferences()); + String automaticCommitMsg = "Automatic update via JabRef"; + if (preferences.getGitPreferences().getAutoCommit()) { + try { + return git.createCommitWithSingleFileOnCurrentBranch(filePath.getFileName().toString(), automaticCommitMsg); + } catch (GitAPIException | IOException e) { + dialogService.showErrorDialogAndWait(Localization.lang("Git"), Localization.lang("Failed to open repository")); + LOGGER.info("Failed to open repository"); + } + } + return false; + } + + /** + * @param filePath of library + */ + public void automaticGitPush(Path filePath) { + GitHandler git = new GitHandler(filePath.getParent(), preferences.getGitPreferences()); + try { + git.pushCommitsToRemoteRepository(); + } catch (IOException e) { + dialogService.showErrorDialogAndWait(Localization.lang("Git"), Localization.lang("Failed to push file in remote repository")); + LOGGER.info("Failed to push file in remote repository"); + } + } } diff --git a/src/main/java/org/jabref/gui/git/GitCredentialsDialogView.java b/src/main/java/org/jabref/gui/git/GitCredentialsDialogView.java new file mode 100644 index 00000000000..44abf025b7d --- /dev/null +++ b/src/main/java/org/jabref/gui/git/GitCredentialsDialogView.java @@ -0,0 +1,74 @@ +package org.jabref.gui.git; + +import javafx.fxml.FXML; +import javafx.scene.control.ButtonBar; +import javafx.scene.control.ButtonType; +import javafx.scene.control.DialogPane; +import javafx.scene.control.Label; +import javafx.scene.control.PasswordField; +import javafx.scene.control.TextArea; +import javafx.scene.control.TextField; +import javafx.scene.layout.VBox; + +import org.jabref.gui.DialogService; +import org.jabref.gui.util.BaseDialog; +import org.jabref.logic.git.GitCredential; +import org.jabref.logic.l10n.Localization; + +import com.airhacks.afterburner.injection.Injector; + +public class GitCredentialsDialogView extends BaseDialog { + + @FXML private ButtonType copyVersionButton; + @FXML private TextArea textAreaVersions; + private DialogService dialogService; + private DialogPane pane; + + private ButtonType acceptButton; + private ButtonType cancelButton; + private TextField inputGitUsername; + private PasswordField inputGitPassword; + + public GitCredentialsDialogView() { + this.setTitle(Localization.lang("Git credentials")); + this.dialogService = Injector.instantiateModelOrService(DialogService.class); + + this.pane = new DialogPane(); + VBox vBox = new VBox(); + this.inputGitUsername = new TextField(); + this.inputGitPassword = new PasswordField(); + this.acceptButton = new ButtonType(Localization.lang("Accept"), ButtonBar.ButtonData.APPLY); + this.cancelButton = new ButtonType(Localization.lang("Cancel"), ButtonBar.ButtonData.CANCEL_CLOSE); + + vBox.getChildren().add(new Label(Localization.lang("Git username"))); + vBox.getChildren().add(this.inputGitUsername); + vBox.getChildren().add(new Label(Localization.lang("Git password"))); + vBox.getChildren().add(this.inputGitPassword); + + this.pane.setContent(vBox); + } + + public void showGitCredentialsDialog() { + dialogService.showCustomDialogAndWait(Localization.lang("Git credentials"), this.pane, this.acceptButton, this.cancelButton); + } + + public GitCredential getCredentials() { + dialogService.showCustomDialogAndWait(Localization.lang("Git credentials"), this.pane, this.acceptButton, this.cancelButton); + GitCredential gitCredentials = new GitCredential(this.inputGitUsername.getText(), this.inputGitPassword.getText()); + + return gitCredentials; + } + + public String getGitPassword() { + return this.inputGitPassword.getText(); + } + + public String getGitUsername() { + return this.inputGitUsername.getText(); + } + + @FXML + private void initialize() { + this.setResizable(false); + } +} diff --git a/src/main/java/org/jabref/gui/preferences/PreferencesDialogViewModel.java b/src/main/java/org/jabref/gui/preferences/PreferencesDialogViewModel.java index 3647a845a61..48c4e9dd5d5 100644 --- a/src/main/java/org/jabref/gui/preferences/PreferencesDialogViewModel.java +++ b/src/main/java/org/jabref/gui/preferences/PreferencesDialogViewModel.java @@ -24,6 +24,7 @@ import org.jabref.gui.preferences.external.ExternalTab; import org.jabref.gui.preferences.externalfiletypes.ExternalFileTypesTab; import org.jabref.gui.preferences.general.GeneralTab; +import org.jabref.gui.preferences.git.GitTab; import org.jabref.gui.preferences.groups.GroupsTab; import org.jabref.gui.preferences.journals.JournalAbbreviationsTab; import org.jabref.gui.preferences.keybindings.KeyBindingsTab; @@ -81,7 +82,8 @@ public PreferencesDialogViewModel(DialogService dialogService, PreferencesServic new XmpPrivacyTab(), new CustomImporterTab(), new CustomExporterTab(), - new NetworkTab() + new NetworkTab(), + new GitTab() ); } diff --git a/src/main/java/org/jabref/gui/preferences/git/GitTab.fxml b/src/main/java/org/jabref/gui/preferences/git/GitTab.fxml new file mode 100644 index 00000000000..bb251f2da14 --- /dev/null +++ b/src/main/java/org/jabref/gui/preferences/git/GitTab.fxml @@ -0,0 +1,25 @@ + + + + + + + + + diff --git a/src/main/java/org/jabref/gui/preferences/git/GitTab.java b/src/main/java/org/jabref/gui/preferences/git/GitTab.java new file mode 100644 index 00000000000..cdf9965b3e5 --- /dev/null +++ b/src/main/java/org/jabref/gui/preferences/git/GitTab.java @@ -0,0 +1,41 @@ +package org.jabref.gui.preferences.git; + +import javafx.fxml.FXML; +import javafx.scene.control.CheckBox; +import javafx.scene.control.PasswordField; +import javafx.scene.control.TextField; + +import org.jabref.gui.preferences.AbstractPreferenceTabView; +import org.jabref.logic.l10n.Localization; + +import com.airhacks.afterburner.views.ViewLoader; + +public class GitTab extends AbstractPreferenceTabView { + + @FXML private TextField username; + @FXML private PasswordField password; + @FXML private CheckBox autoCommit; + @FXML private CheckBox autoSync; + + public GitTab() { + ViewLoader.view(this) + .root(this) + .load(); + initialize(); + } + + @Override + public String getTabName() { + return Localization.lang("Git"); + } + + @FXML + private void initialize() { + viewModel = new GitTabViewModel(preferencesService.getGitPreferences()); + + username.textProperty().bindBidirectional(viewModel.getUsernameProperty()); + password.textProperty().bindBidirectional(viewModel.getPasswordProperty()); + autoCommit.selectedProperty().bindBidirectional(viewModel.getAutoCommitProperty()); + autoSync.selectedProperty().bindBidirectional(viewModel.getAutoSyncProperty()); + } +} diff --git a/src/main/java/org/jabref/gui/preferences/git/GitTabViewModel.java b/src/main/java/org/jabref/gui/preferences/git/GitTabViewModel.java new file mode 100644 index 00000000000..3a97e522ae4 --- /dev/null +++ b/src/main/java/org/jabref/gui/preferences/git/GitTabViewModel.java @@ -0,0 +1,71 @@ +package org.jabref.gui.preferences.git; + +import java.util.List; + +import javafx.beans.property.BooleanProperty; +import javafx.beans.property.StringProperty; + +import org.jabref.gui.preferences.PreferenceTabViewModel; +import org.jabref.logic.git.GitPreferences; + +public class GitTabViewModel implements PreferenceTabViewModel { + private final StringProperty username; + private final StringProperty password; + private final BooleanProperty autoCommit; + private final BooleanProperty autoSync; + private final GitPreferences gitPreferences; + + public GitTabViewModel(GitPreferences gitPreferences) { + this.gitPreferences = gitPreferences; + this.autoCommit = gitPreferences.getAutoCommitProperty(); + this.autoSync = gitPreferences.getAutoSyncProperty(); + this.username = gitPreferences.getUsernameProperty(); + this.password = gitPreferences.getPasswordProperty(); + } + + @Override + public void setValues() { + this.username.setValue(this.gitPreferences.getUsername()); + this.password.setValue(this.gitPreferences.getPassword()); + this.autoCommit.setValue(this.gitPreferences.getAutoCommit() || this.gitPreferences.getAutoSync()); + this.autoSync.setValue(this.gitPreferences.getAutoSync()); + } + + @Override + public void storeSettings() { + } + + @Override + public boolean validateSettings() { + return PreferenceTabViewModel.super.validateSettings(); + } + + @Override + public List getRestartWarnings() { + return PreferenceTabViewModel.super.getRestartWarnings(); + } + + public String getUsername() { + return this.username.get(); + } + + public StringProperty getUsernameProperty() { + return this.username; + } + + public String getPassword() { + return this.password.get(); + } + + public StringProperty getPasswordProperty() { + return this.password; + } + + public BooleanProperty getAutoCommitProperty() { + return this.autoCommit; + } + + public BooleanProperty getAutoSyncProperty() { + return this.autoSync; + } +} diff --git a/src/main/java/org/jabref/logic/git/GitCredential.java b/src/main/java/org/jabref/logic/git/GitCredential.java new file mode 100644 index 00000000000..9ec484b6d35 --- /dev/null +++ b/src/main/java/org/jabref/logic/git/GitCredential.java @@ -0,0 +1,19 @@ +package org.jabref.logic.git; + +public class GitCredential { + private final String username; + private final String password; + + public GitCredential(String username, String password) { + this.username = username; + this.password = password; + } + + public String getUsername() { + return username; + } + + public String getPassword() { + return password; + } +} diff --git a/src/main/java/org/jabref/logic/git/GitHandler.java b/src/main/java/org/jabref/logic/git/GitHandler.java index e865e9b9299..c18d52b6410 100644 --- a/src/main/java/org/jabref/logic/git/GitHandler.java +++ b/src/main/java/org/jabref/logic/git/GitHandler.java @@ -10,9 +10,13 @@ import org.jabref.logic.util.io.FileUtil; import org.eclipse.jgit.api.Git; +import org.eclipse.jgit.api.ResetCommand; import org.eclipse.jgit.api.RmCommand; import org.eclipse.jgit.api.Status; +import org.eclipse.jgit.api.TransportConfigCallback; import org.eclipse.jgit.api.errors.GitAPIException; +import org.eclipse.jgit.api.errors.TransportException; +import org.eclipse.jgit.errors.NoWorkTreeException; import org.eclipse.jgit.lib.Ref; import org.eclipse.jgit.merge.MergeStrategy; import org.eclipse.jgit.transport.CredentialsProvider; @@ -28,9 +32,10 @@ public class GitHandler { static final Logger LOGGER = LoggerFactory.getLogger(GitHandler.class); final Path repositoryPath; final File repositoryPathAsFile; - String gitUsername = Optional.ofNullable(System.getenv("GIT_EMAIL")).orElse(""); - String gitPassword = Optional.ofNullable(System.getenv("GIT_PW")).orElse(""); - final CredentialsProvider credentialsProvider = new UsernamePasswordCredentialsProvider(gitUsername, gitPassword); + String gitUsername; + String gitPassword; + CredentialsProvider credentialsProvider; + private GitPreferences gitPreferences; /** * Initialize the handler for the given repository @@ -40,6 +45,10 @@ public class GitHandler { public GitHandler(Path repositoryPath) { this.repositoryPath = repositoryPath; this.repositoryPathAsFile = this.repositoryPath.toFile(); + this.gitUsername = Optional.ofNullable(System.getenv("GIT_EMAIL")).orElse(""); + this.gitPassword = Optional.ofNullable(System.getenv("GIT_PW")).orElse(""); + this.credentialsProvider = new UsernamePasswordCredentialsProvider(this.gitUsername, this.gitPassword); + if (!isGitRepository()) { try { Git.init() @@ -59,7 +68,45 @@ public GitHandler(Path repositoryPath) { } } } catch (GitAPIException | IOException e) { - LOGGER.error("Initialization failed"); + LOGGER.error("Initialization failed", e); + } + } + } + + public GitHandler(Path repositoryPath, GitPreferences gitPreferences) { + this.gitPreferences = gitPreferences; + if (gitPreferences.getUsername() != null && gitPreferences.getPassword() != null) { + this.gitUsername = gitPreferences.getUsername(); + this.gitPassword = gitPreferences.getPassword(); + } else { + this.gitUsername = Optional.ofNullable(System.getenv("GIT_EMAIL")).orElse(""); + this.gitPassword = Optional.ofNullable(System.getenv("GIT_PW")).orElse(""); + } + this.credentialsProvider = new UsernamePasswordCredentialsProvider(this.gitUsername, this.gitPassword); + + this.repositoryPath = repositoryPath; + this.repositoryPathAsFile = this.repositoryPath.toFile(); + + if (!isGitRepository()) { + try { + Git.init() + .setDirectory(repositoryPathAsFile) + .setInitialBranch("main") + .call(); + setupGitIgnore(); + String initialCommit = "Initial commit"; + if (!createCommitOnCurrentBranch(initialCommit, false)) { + // Maybe, setupGitIgnore failed and did not add something + // Then, we create an empty commit + try (Git git = Git.open(repositoryPathAsFile)) { + git.commit() + .setAllowEmpty(true) + .setMessage(initialCommit) + .call(); + } + } + } catch (GitAPIException | IOException e) { + LOGGER.error("Initialization failed", e); } } } @@ -81,7 +128,8 @@ void setupGitIgnore() { boolean isGitRepository() { // For some reason the solution from https://www.eclipse.org/lists/jgit-dev/msg01892.html does not work // This solution is quite simple but might not work in special cases, for us it should suffice. - return Files.exists(Path.of(repositoryPath.toString(), ".git")); + Path gitFolderPath = Path.of(repositoryPath.toString(), ".git"); + return Files.exists(gitFolderPath) && Files.isDirectory(gitFolderPath); } /** @@ -147,6 +195,42 @@ public boolean createCommitOnCurrentBranch(String commitMessage, boolean amend) return commitCreated; } + /** + * Creates a commit on the currently checked out branch with a single file + * + * @param filename The name of the file to commit + * @return Returns true if a new commit was created. This is the case if the repository was not clean on method invocation + * @throws IOException + * @throws GitAPIException + * @throws NoWorkTreeException + */ + public boolean createCommitWithSingleFileOnCurrentBranch(String filename, String commitMessage) throws IOException, NoWorkTreeException, GitAPIException { + boolean commitCreated = false; + + Git git = Git.open(this.repositoryPathAsFile); + Status status = git.status().call(); + if (!status.isClean()) { + commitCreated = true; + + // Add new and changed files to index + git.add() + .addFilepattern(filename) + .call(); + + // Add all removed files to index + if (!status.getMissing().isEmpty()) { + RmCommand removeCommand = git.rm().setCached(true); + status.getMissing().forEach(removeCommand::addFilepattern); + removeCommand.call(); + } + git.commit() + .setAllowEmpty(false) + .setMessage(commitMessage) + .call(); + } + return commitCreated; + } + /** * Merges the source branch into the target branch * @@ -176,32 +260,104 @@ public void mergeBranches(String targetBranch, String sourceBranch, MergeStrateg * If pushing to remote fails, it fails silently. */ public void pushCommitsToRemoteRepository() throws IOException { - try (Git git = Git.open(this.repositoryPathAsFile)) { - try { + try { + Git git = Git.open(this.repositoryPathAsFile); + String remoteURL = git.getRepository().getConfig().getString("remote", "origin", "url"); + boolean isSshRemoteRepository = remoteURL != null && remoteURL.contains("git@"); + + git.verifySignature(); + + if (isSshRemoteRepository) { + TransportConfigCallback transportConfigCallback = new SshTransportConfigCallback(); git.push() - .setCredentialsProvider(credentialsProvider) - .call(); - } catch (GitAPIException e) { + .setTransportConfigCallback(transportConfigCallback) + .call(); + } else if (this.gitPassword.isEmpty() || this.gitUsername.isEmpty()) { + throw new IOException("No git credentials"); + } else { + git.push() + .setCredentialsProvider(this.credentialsProvider) + .call(); + } + } catch (GitAPIException e) { + if (e.getMessage().equals("origin: not found.")) { + LOGGER.info("No remote repository detected. Push skipped.", e); + } else { LOGGER.info("Failed to push"); + throw new RuntimeException(e); } } } - public void pullOnCurrentBranch() throws IOException { - try (Git git = Git.open(this.repositoryPathAsFile)) { - try { - git.pull() - .setCredentialsProvider(credentialsProvider) - .call(); - } catch (GitAPIException e) { - LOGGER.info("Failed to push"); - } + /** + * Pulls all commits made to the branch that is tracked by the currently checked out branch. + * If pulling to remote fails, it fails silently. + */ + public void pullOnCurrentBranch() throws IOException, GitAPIException { + Git git = Git.open(this.repositoryPathAsFile); + String remoteURL = git.getRepository().getConfig().getString("remote", "origin", "url"); + boolean isSshRemoteRepository = remoteURL != null && remoteURL.contains("git@"); + git.verifySignature(); + if (isSshRemoteRepository) { + TransportConfigCallback transportConfigCallback = new SshTransportConfigCallback(); + git.pull() + .setTransportConfigCallback(transportConfigCallback) + .call(); + } else if (this.gitPassword.isEmpty() || this.gitUsername.isEmpty()) { + throw new TransportException("No git credentials"); + } else { + git.pull() + .setCredentialsProvider(this.credentialsProvider) + .call(); } } - public String getCurrentlyCheckedOutBranch() throws IOException { + /** + * Get the short name of the current branch that HEAD points to. + * If checking out fails, it fails silently. + */ + public String getCurrentlyCheckedOutBranch() { try (Git git = Git.open(this.repositoryPathAsFile)) { return git.getRepository().getBranch(); + } catch (IOException ex) { + LOGGER.info("Failed get current branch"); + return String.valueOf(Optional.empty()); + } + } + + /** + * Force git pull and overwrite files. + */ + public void forceGitPull() { + try { + Git git = Git.open(this.repositoryPathAsFile); + String remoteURL = git.getRepository().getConfig().getString("remote", "origin", "url"); + boolean isSshRemoteRepository = remoteURL != null && remoteURL.contains("git@"); + if (isSshRemoteRepository) { + TransportConfigCallback transportConfigCallback = new SshTransportConfigCallback(); + git.verifySignature(); + git.fetch() + .setTransportConfigCallback(transportConfigCallback) + .setRemote("origin") + .call(); + git.reset() + .setMode(ResetCommand.ResetType.HARD) + .call(); + git.merge().include(git.getRepository().findRef("origin/" + getCurrentlyCheckedOutBranch())).call(); + } else { + git.verifySignature(); + git.fetch() + .setCredentialsProvider(this.credentialsProvider) + .setRemote("origin") + .call(); + git.reset() + .setMode(ResetCommand.ResetType.HARD) + .call(); + git.merge().include(git.getRepository().findRef("origin/" + getCurrentlyCheckedOutBranch())).call(); + } + } catch (GitAPIException | IOException e) { + LOGGER.error("Failed to force git pull", e); + throw new RuntimeException(e); } } } diff --git a/src/main/java/org/jabref/logic/git/GitPreferences.java b/src/main/java/org/jabref/logic/git/GitPreferences.java new file mode 100644 index 00000000000..6398169703d --- /dev/null +++ b/src/main/java/org/jabref/logic/git/GitPreferences.java @@ -0,0 +1,75 @@ +package org.jabref.logic.git; + +import javafx.beans.property.BooleanProperty; +import javafx.beans.property.SimpleBooleanProperty; +import javafx.beans.property.SimpleStringProperty; +import javafx.beans.property.StringProperty; + +public class GitPreferences { + private StringProperty username; + private StringProperty password; + private BooleanProperty autoCommit; + private BooleanProperty autoSync; + + public GitPreferences(String username, String password, Boolean autoCommit, Boolean autoSync) { + this.username = new SimpleStringProperty(username); + this.password = new SimpleStringProperty(password); + this.autoCommit = new SimpleBooleanProperty(autoCommit); + this.autoSync = new SimpleBooleanProperty(autoSync); + } + + public GitPreferences(StringProperty username, StringProperty password, BooleanProperty autoCommit, BooleanProperty autoSync) { + this.username = username; + this.password = password; + this.autoCommit = autoCommit; + this.autoSync = autoSync; + } + + public StringProperty getUsernameProperty() { + return this.username; + } + + public Boolean getAutoCommit() { + return this.autoCommit.get(); + } + + public BooleanProperty getAutoCommitProperty() { + return this.autoCommit; + } + + public Boolean getAutoSync() { + return this.autoSync.get(); + } + + public BooleanProperty getAutoSyncProperty() { + return this.autoSync; + } + + public String getUsername() { + return this.username.get(); + } + + public StringProperty getPasswordProperty() { + return this.password; + } + + public String getPassword() { + return this.password.get(); + } + + public void setPassword(StringProperty password) { + this.password = password; + } + + public void setPassword(String password) { + this.password = new SimpleStringProperty(password); + } + + public void setUsername(StringProperty username) { + this.username = username; + } + + public void setUsername(String username) { + this.username = new SimpleStringProperty(username); + } +} diff --git a/src/main/java/org/jabref/logic/git/SshTransportConfigCallback.java b/src/main/java/org/jabref/logic/git/SshTransportConfigCallback.java new file mode 100644 index 00000000000..1bad485bea0 --- /dev/null +++ b/src/main/java/org/jabref/logic/git/SshTransportConfigCallback.java @@ -0,0 +1,44 @@ +package org.jabref.logic.git; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +import org.eclipse.jgit.api.TransportConfigCallback; +import org.eclipse.jgit.transport.SshTransport; +import org.eclipse.jgit.transport.Transport; +import org.eclipse.jgit.transport.sshd.SshdSessionFactory; +import org.eclipse.jgit.util.FS; + +public class SshTransportConfigCallback implements TransportConfigCallback { + private final SshdSessionFactory sshSessionFactory; + + public SshTransportConfigCallback() { + Path sshDir = new File(FS.DETECTED.userHome(), "/.ssh").toPath(); + this.sshSessionFactory = new CustomSshSessionFactory(sshDir); + } + + @Override + public void configure(Transport transport) { + SshTransport sshTransport = (SshTransport) transport; + sshTransport.setSshSessionFactory(this.sshSessionFactory); + } + + public static final class CustomSshSessionFactory extends SshdSessionFactory { + private final Path sshDir; + + public CustomSshSessionFactory(Path sshDir) { + this.sshDir = sshDir; + } + + @Override + public File getSshDirectory() { + try { + return Files.createDirectories(sshDir).toFile(); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + } +} diff --git a/src/main/java/org/jabref/preferences/JabRefPreferences.java b/src/main/java/org/jabref/preferences/JabRefPreferences.java index 35fff0a5713..5f2e814337d 100644 --- a/src/main/java/org/jabref/preferences/JabRefPreferences.java +++ b/src/main/java/org/jabref/preferences/JabRefPreferences.java @@ -69,6 +69,7 @@ import org.jabref.logic.exporter.MetaDataSerializer; import org.jabref.logic.exporter.SelfContainedSaveConfiguration; import org.jabref.logic.exporter.TemplateExporter; +import org.jabref.logic.git.GitPreferences; import org.jabref.logic.importer.ImportFormatPreferences; import org.jabref.logic.importer.ImporterPreferences; import org.jabref.logic.importer.fetcher.ACMPortalFetcher; @@ -384,6 +385,13 @@ public class JabRefPreferences implements PreferencesService { private static final String PROXY_PASSWORD = "proxyPassword"; private static final String PROXY_PERSIST_PASSWORD = "persistPassword"; + // Git + private static final String GIT_USERNAME = "gitUsername"; + private static final String GIT_PASSWORD = "gitPassword"; + + private static final String GIT_AUTOCOMMIT = "autoCommit"; + private static final String GIT_AUTOSYNC = "autoSync"; + // Web search private static final String FETCHER_CUSTOM_KEY_NAMES = "fetcherCustomKeyNames"; private static final String FETCHER_CUSTOM_KEY_USES = "fetcherCustomKeyUses"; @@ -491,6 +499,7 @@ public class JabRefPreferences implements PreferencesService { private ColumnPreferences searchDialogColumnPreferences; private JournalAbbreviationPreferences journalAbbreviationPreferences; private FieldPreferences fieldPreferences; + private GitPreferences gitPreferences; // The constructor is made private to enforce this as a singleton class: private JabRefPreferences() { @@ -673,6 +682,9 @@ private JabRefPreferences() { defaults.put(PROTECTED_TERMS_ENABLED_EXTERNAL, ""); defaults.put(PROTECTED_TERMS_DISABLED_EXTERNAL, ""); + defaults.put(GIT_AUTOCOMMIT, Boolean.TRUE); + defaults.put(GIT_AUTOSYNC, Boolean.TRUE); + // OpenOffice/LibreOffice if (OS.WINDOWS) { defaults.put(OO_EXECUTABLE_PATH, OpenOfficePreferences.DEFAULT_WIN_EXEC_PATH); @@ -1614,12 +1626,27 @@ private String getProxyPassword() { } catch (PasswordAccessException ex) { LOGGER.warn("JabRef uses proxy password from key store but no password is stored"); } catch (Exception ex) { - LOGGER.warn("JabRef could not open the key store"); + LOGGER.warn("JabRef could not open the git key store"); } } return (String) defaults.get(PROXY_PASSWORD); } + private String getGitPassword() { + try { + final Keyring keyring = Keyring.create(); + return new Password( + keyring.getPassword("org.jabref", "git"), + getInternalPreferences().getUserAndHost()) + .decrypt(); + } catch (PasswordAccessException ex) { + LOGGER.warn("JabRef uses git password from key store but no password is stored"); + } catch (Exception ex) { + LOGGER.warn("JabRef could not open the git key store"); + } + return (String) defaults.get(GIT_PASSWORD); + } + private void setProxyPassword(String password) { if (getProxyPreferences().shouldPersistPassword()) { try (final Keyring keyring = Keyring.create()) { @@ -1637,6 +1664,22 @@ private void setProxyPassword(String password) { } } + private void setGitPassword(String password) { + try { + final Keyring keyring = Keyring.create(); + if (StringUtil.isBlank(password)) { + keyring.deletePassword("org.jabref", "git"); + } else { + keyring.setPassword("org.jabref", "git", new Password( + password.trim(), + getInternalPreferences().getUserAndHost()) + .encrypt()); + } + } catch (Exception ex) { + LOGGER.warn("Unable to open key store", ex); + } + } + @Override public SSLPreferences getSSLPreferences() { if (Objects.nonNull(sslPreferences)) { @@ -2829,6 +2872,26 @@ public ProtectedTermsPreferences getProtectedTermsPreferences() { return protectedTermsPreferences; } + @Override + public GitPreferences getGitPreferences() { + if (Objects.nonNull(gitPreferences)) { + return gitPreferences; + } + + gitPreferences = new GitPreferences( + get(GIT_USERNAME), + getGitPassword(), + getBoolean(GIT_AUTOCOMMIT), + getBoolean(GIT_AUTOSYNC) + ); + + EasyBind.listen(gitPreferences.getUsernameProperty(), (obs, oldValue, newValue) -> put(GIT_USERNAME, newValue)); + EasyBind.listen(gitPreferences.getPasswordProperty(), (obs, oldValue, newValue) -> setGitPassword(newValue)); + EasyBind.listen(gitPreferences.getAutoCommitProperty(), (obs, oldValue, newValue) -> putBoolean(GIT_AUTOCOMMIT, newValue)); + EasyBind.listen(gitPreferences.getAutoSyncProperty(), (obs, oldValue, newValue) -> putBoolean(GIT_AUTOSYNC, newValue)); + + return gitPreferences; + } //************************************************************************************************************* // Importer preferences //************************************************************************************************************* diff --git a/src/main/java/org/jabref/preferences/PreferencesService.java b/src/main/java/org/jabref/preferences/PreferencesService.java index a03123740d6..ea5104c8c2d 100644 --- a/src/main/java/org/jabref/preferences/PreferencesService.java +++ b/src/main/java/org/jabref/preferences/PreferencesService.java @@ -16,6 +16,7 @@ import org.jabref.logic.bibtex.FieldPreferences; import org.jabref.logic.citationkeypattern.CitationKeyPatternPreferences; import org.jabref.logic.exporter.SelfContainedSaveConfiguration; +import org.jabref.logic.git.GitPreferences; import org.jabref.logic.importer.ImportFormatPreferences; import org.jabref.logic.importer.ImporterPreferences; import org.jabref.logic.importer.fetcher.GrobidPreferences; @@ -149,4 +150,6 @@ public interface PreferencesService { MrDlibPreferences getMrDlibPreferences(); ProtectedTermsPreferences getProtectedTermsPreferences(); + + GitPreferences getGitPreferences(); } diff --git a/src/main/resources/csl-styles b/src/main/resources/csl-styles index 9c43a7dacc1..5bea241e5a2 160000 --- a/src/main/resources/csl-styles +++ b/src/main/resources/csl-styles @@ -1 +1 @@ -Subproject commit 9c43a7dacc170d7f0225b53603e5dbc1666aaeb0 +Subproject commit 5bea241e5a2acacc29b947e21539be9cbe79c8bd diff --git a/src/main/resources/l10n/JabRef_en.properties b/src/main/resources/l10n/JabRef_en.properties index d1d613fd39c..38a413d6052 100644 --- a/src/main/resources/l10n/JabRef_en.properties +++ b/src/main/resources/l10n/JabRef_en.properties @@ -1287,6 +1287,21 @@ SSL\ certificate\ file=SSL certificate file Duplicate\ Certificates=Duplicate Certificates You\ already\ added\ this\ certificate=You already added this certificate +Git=Git +Credential=Credential +Git\ credentials=Git credentials +Git\ password=Git password +Git\ username=Git username +Email=Email +Enable\ git\ auto\ commit=Enable git auto commit +Enable\ git\ auto\ push\ and\ merge=Enable git auto push and merge +Local\ repository\ is\ out\ of\ date,\ please\ review\ the\ library\ and\ save\ again=Local repository is out of date, please review the library and save again +No\ remote\ repository\ detected=No remote repository detected +Git\ detached\ head=Git detached head +Git\ credentials\ error=Git credentials error +Failed\ to\ open\ repository=Failed to open repository +Failed\ to\ push\ file\ in\ remote\ repository=Failed to push file in remote repository + Open\ folder=Open folder Export\ sort\ order=Export sort order Save\ sort\ order=Save sort order diff --git a/src/test/java/org/jabref/gui/exporter/SaveDatabaseActionTest.java b/src/test/java/org/jabref/gui/exporter/SaveDatabaseActionTest.java index 9b36a5a712b..6901c1b9d2b 100644 --- a/src/test/java/org/jabref/gui/exporter/SaveDatabaseActionTest.java +++ b/src/test/java/org/jabref/gui/exporter/SaveDatabaseActionTest.java @@ -19,6 +19,7 @@ import org.jabref.logic.citationkeypattern.GlobalCitationKeyPattern; import org.jabref.logic.exporter.BibDatabaseWriter; import org.jabref.logic.exporter.SaveConfiguration; +import org.jabref.logic.git.GitPreferences; import org.jabref.logic.shared.DatabaseLocation; import org.jabref.model.database.BibDatabase; import org.jabref.model.database.BibDatabaseContext; @@ -62,6 +63,7 @@ public void setUp() { when(filePreferences.getWorkingDirectory()).thenReturn(Path.of(TEST_BIBTEX_LIBRARY_LOCATION)); when(preferences.getFilePreferences()).thenReturn(filePreferences); when(preferences.getExportPreferences()).thenReturn(mock(ExportPreferences.class)); + when(preferences.getGitPreferences()).thenReturn(mock(GitPreferences.class)); saveDatabaseAction = spy(new SaveDatabaseAction(libraryTab, dialogService, preferences, mock(BibEntryTypesManager.class))); } diff --git a/src/test/java/org/jabref/logic/git/GitHandlerTest.java b/src/test/java/org/jabref/logic/git/GitHandlerTest.java index d5dd4c694c3..59523ffa297 100644 --- a/src/test/java/org/jabref/logic/git/GitHandlerTest.java +++ b/src/test/java/org/jabref/logic/git/GitHandlerTest.java @@ -1,29 +1,84 @@ package org.jabref.logic.git; +import java.io.File; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; import java.util.Iterator; +import java.util.Objects; +import org.jabref.preferences.PreferencesService; + +import org.eclipse.jetty.security.ConstraintMapping; +import org.eclipse.jetty.security.ConstraintSecurityHandler; +import org.eclipse.jetty.security.HashLoginService; +import org.eclipse.jetty.security.SecurityHandler; +import org.eclipse.jetty.security.UserStore; +import org.eclipse.jetty.security.authentication.BasicAuthenticator; +import org.eclipse.jetty.server.Server; +import org.eclipse.jetty.servlet.ServletContextHandler; +import org.eclipse.jetty.servlet.ServletHolder; +import org.eclipse.jetty.util.security.Constraint; +import org.eclipse.jetty.util.security.Credential; import org.eclipse.jgit.api.Git; import org.eclipse.jgit.api.errors.GitAPIException; +import org.eclipse.jgit.http.server.GitServlet; import org.eclipse.jgit.lib.AnyObjectId; import org.eclipse.jgit.lib.Constants; +import org.eclipse.jgit.lib.Repository; import org.eclipse.jgit.revwalk.RevCommit; +import org.eclipse.jgit.storage.file.FileRepositoryBuilder; +import org.eclipse.jgit.transport.CredentialsProvider; +import org.eclipse.jgit.transport.UsernamePasswordCredentialsProvider; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; class GitHandlerTest { @TempDir Path repositoryPath; private GitHandler gitHandler; + private GitPreferences gitPreferences; + + private static Repository createRepository() throws IOException, GitAPIException { + File localPath = File.createTempFile("TestGitRepository", ""); + if (!localPath.delete()) { + throw new IOException("Could not delete temporary file " + localPath); + } + if (!localPath.mkdirs()) { + throw new IOException("Could not create directory " + localPath); + } + Repository repository = FileRepositoryBuilder.create(new File(localPath, ".git")); + repository.create(); + repository.getConfig().setBoolean("http", null, "receivepack", true); + Git git = new Git(repository); + git.commit().setMessage("Initial commit").call(); + return repository; + } + + private static Path createFolder() throws IOException { + File localPath = File.createTempFile("TestGitRepository", ""); + if (!localPath.delete()) { + throw new IOException("Could not delete temporary file " + localPath); + } + if (!localPath.mkdirs()) { + throw new IOException("Could not create directory " + localPath); + } + return localPath.toPath(); + } @BeforeEach public void setUpGitHandler() { - gitHandler = new GitHandler(repositoryPath); + gitPreferences = new GitPreferences("testUser", "testPassword", true, true); + PreferencesService preferences = mock(PreferencesService.class); + when(preferences.getGitPreferences()).thenReturn(gitPreferences); + gitHandler = new GitHandler(repositoryPath, preferences.getGitPreferences()); } @Test @@ -52,7 +107,103 @@ void createCommitOnCurrentBranch() throws IOException, GitAPIException { } @Test - void getCurrentlyCheckedOutBranch() throws IOException { + void createCommitWithSingleFileOnCurrentBranch() throws IOException, GitAPIException { + try (Git git = Git.open(repositoryPath.toFile())) { + // Create commit + Files.createFile(Path.of(repositoryPath.toString(), "Test.txt")); + assertTrue(gitHandler.createCommitWithSingleFileOnCurrentBranch("Test.txt", "TestCommit")); + + AnyObjectId head = git.getRepository().resolve(Constants.HEAD); + Iterator log = git.log() + .add(head) + .call().iterator(); + assertEquals("TestCommit", log.next().getFullMessage()); + assertEquals("Initial commit", log.next().getFullMessage()); + assertFalse(gitHandler.createCommitWithSingleFileOnCurrentBranch("Test.txt", "TestCommit")); + } + } + + @Test + void getCurrentlyCheckedOutBranch() { assertEquals("main", gitHandler.getCurrentlyCheckedOutBranch()); } + + @Test + void pushSingleFile() throws Exception { + String username = "test"; + String password = "test"; + + // Server + Repository repository = createRepository(); + Server server = createServer(username, password, repository); + server.start(); + + // Clone + CredentialsProvider credentialsProvider = new UsernamePasswordCredentialsProvider(username, password); + Path clonedRepPath = createFolder(); + Git.cloneRepository() + .setCredentialsProvider(credentialsProvider) + .setURI("http://localhost:8080/repoTest/.git") + .setDirectory(clonedRepPath.toFile()) + .call(); + + // Add files + Files.createFile(Path.of(clonedRepPath.toString(), "bib_1.bib")); + + // Commit + gitPreferences = new GitPreferences(username, password, true, true); + PreferencesService preferences = mock(PreferencesService.class); + when(preferences.getGitPreferences()).thenReturn(gitPreferences); + GitHandler git = new GitHandler(clonedRepPath, preferences.getGitPreferences()); + git.createCommitWithSingleFileOnCurrentBranch("bib_1.bib", "PushSingleFile"); + + // Push + git.pushCommitsToRemoteRepository(); + + server.stop(); + } + + private static SecurityHandler basicAuth(String username, String password) { + HashLoginService l = new HashLoginService(); + UserStore userStore = new UserStore(); + String[] roles = new String[] {"user"}; + Credential credential = Credential.getCredential(password); + userStore.addUser(username, credential, roles); + l.setUserStore(userStore); + l.setName("Private!"); + + Constraint constraint = new Constraint(); + constraint.setName(Constraint.__BASIC_AUTH); + constraint.setRoles(new String[]{"user"}); + constraint.setAuthenticate(true); + + ConstraintMapping cm = new ConstraintMapping(); + cm.setConstraint(constraint); + cm.setPathSpec("/*"); + + ConstraintSecurityHandler csh = new ConstraintSecurityHandler(); + csh.setAuthenticator(new BasicAuthenticator()); + csh.setRealmName("myrealm"); + csh.addConstraintMapping(cm); + csh.setLoginService(l); + + return csh; + } + + private Server createServer(String username, String password, Repository repository) { + GitServlet gs = new GitServlet(); + gs.setRepositoryResolver((req, name) -> { + if (Objects.equals(name, ".git")) { + repository.incrementOpen(); + return repository; + } + return null; + }); + Server server = new Server(8080); + ServletContextHandler context = new ServletContextHandler(server, "/repoTest", ServletContextHandler.SESSIONS); + context.setSecurityHandler(basicAuth(username, password)); + context.addServlet(new ServletHolder(gs), "/*"); + server.setHandler(context); + return server; + } }