blob: 7bb73026a60f43971ad05e4df5697e7ab79ca7c5 [file] [log] [blame]
/*******************************************************************************
* Copyright (c) 2011 Oak Ridge National Laboratory and others.
* All rights reserved. This program and the accompanying materials
* are made available under the terms of the Eclipse Public License v1.0
* which accompanies this distribution, and is available at
* http://www.eclipse.org/legal/epl-v10.html
*
* Contributors:
* John Eblen - initial implementation
*******************************************************************************/
package org.eclipse.ptp.rdt.sync.git.core;
import java.io.BufferedReader;
import java.io.File;
import java.io.IOException;
import java.io.StringReader;
import java.net.URISyntaxException;
import java.util.HashSet;
import java.util.LinkedList;
import java.util.List;
import java.util.Set;
import org.eclipse.core.runtime.CoreException;
import org.eclipse.jgit.api.AddCommand;
import org.eclipse.jgit.api.CommitCommand;
import org.eclipse.jgit.api.Git;
import org.eclipse.jgit.api.MergeCommand;
import org.eclipse.jgit.api.errors.CheckoutConflictException;
import org.eclipse.jgit.api.errors.ConcurrentRefUpdateException;
import org.eclipse.jgit.api.errors.GitAPIException;
import org.eclipse.jgit.api.errors.InvalidMergeHeadsException;
import org.eclipse.jgit.api.errors.NoFilepatternException;
import org.eclipse.jgit.api.errors.NoHeadException;
import org.eclipse.jgit.api.errors.NoMessageException;
import org.eclipse.jgit.api.errors.WrongRepositoryStateException;
import org.eclipse.jgit.errors.NotSupportedException;
import org.eclipse.jgit.errors.TransportException;
import org.eclipse.jgit.errors.UnmergedPathException;
import org.eclipse.jgit.lib.NullProgressMonitor;
import org.eclipse.jgit.lib.Ref;
import org.eclipse.jgit.lib.Repository;
import org.eclipse.jgit.lib.StoredConfig;
import org.eclipse.jgit.storage.file.FileRepositoryBuilder;
import org.eclipse.jgit.transport.CredentialsProvider;
import org.eclipse.jgit.transport.RefSpec;
import org.eclipse.jgit.transport.RemoteConfig;
import org.eclipse.jgit.transport.RemoteSession;
import org.eclipse.jgit.transport.SshSessionFactory;
import org.eclipse.jgit.transport.Transport;
import org.eclipse.jgit.transport.TransportGitSsh;
import org.eclipse.jgit.transport.URIish;
import org.eclipse.jgit.util.FS;
import org.eclipse.ptp.rdt.sync.git.core.CommandRunner.CommandResults;
import org.eclipse.ptp.remote.core.AbstractRemoteProcess;
import org.eclipse.ptp.remote.core.IRemoteConnection;
import org.eclipse.ptp.remote.core.exception.RemoteConnectionException;
/**
*
* This class implements a remote sync tool using git, as accessed through the jgit library.
*
*/
public class GitRemoteSyncConnection {
private final static String remoteProjectName = "eclipse_auto"; //$NON-NLS-1$
private final static String commitMessage = "Eclipse Automatic Commit"; //$NON-NLS-1$
private final static String remotePushBranch = "ptp-push"; //$NON-NLS-1$
private final IRemoteConnection connection;
private final String localDirectory;
private final String remoteDirectory;
private Git git;
private TransportGitSsh transport;
/**
* Create a remote sync connection using git. Assumes that the local directory exists but not necessarily the remote directory.
* It is created if not.
*
* @param conn
* @param localDir
* @param remoteDir
* @throws RemoteSyncException
* on problems building the remote repository. Specific exception nested. Upon such an exception, the instance is
* invalid and should not be used.
*/
public GitRemoteSyncConnection(IRemoteConnection conn, String localDir, String remoteDir) throws RemoteSyncException {
connection = conn;
localDirectory = localDir;
remoteDirectory = remoteDir;
// Build repo, creating it if it is not already present.
try {
buildRepo();
} catch (final CoreException e) {
throw new RemoteSyncException(e);
} catch (final IOException e) {
throw new RemoteSyncException(e);
} catch (final RemoteExecutionException e) {
throw new RemoteSyncException(e);
}
// Build transport
final RemoteConfig remoteConfig = buildRemoteConfig(git.getRepository().getConfig());
buildTransport(remoteConfig);
}
/**
* Builds the remote configuration for the connection, setting up fetch and push operations between local and remote master
* branches.
*
* @param config
* configuration for the local repository
* @return the remote configuration
* @throws RuntimeException
* if the URI in the passed configuration is not properly formatted.
*/
private RemoteConfig buildRemoteConfig(StoredConfig config) {
RemoteConfig rconfig = null;
try {
rconfig = new RemoteConfig(config, remoteProjectName);
} catch (final URISyntaxException e) {
throw new RuntimeException(e);
}
final RefSpec refSpecFetch = new RefSpec("+refs/heads/master:refs/remotes/" + //$NON-NLS-1$
remoteProjectName + "/master"); //$NON-NLS-1$
final RefSpec refSpecPush = new RefSpec("+master:" + remotePushBranch); //$NON-NLS-1$
rconfig.addFetchRefSpec(refSpecFetch);
rconfig.addPushRefSpec(refSpecPush);
return rconfig;
}
/**
*
* @param localDirectory
* @param remoteHost
* @return the repository
* @throws CoreException
* on problems creating the remote directory.
* @throws IOException
* on problems writing to the file system.
* @throws RemoteExecutionException
* on failure to run remote commands.
* @throws RemoteSyncException
* on problems with initial local commit.
* TODO: Consider the consequences of exceptions that occur at various points, which can leave the repo in a partial state.
* For example, if the repo is created but the initial commit fails.
* TODO: Consider evaluating the output of "git init".
*/
private Git buildRepo() throws CoreException, IOException, RemoteExecutionException, RemoteSyncException {
final File localDir = new File(localDirectory);
final FileRepositoryBuilder repoBuilder = new FileRepositoryBuilder();
Repository repository = null;
try {
repository = repoBuilder.setWorkTree(localDir).build();
} catch (final IOException e) {
throw e;
}
git = new Git(repository);
// Create and configure local repository if it is not already present. Set the git instance.
if (repoReady() == false) {
try {
repository.create(false);
} catch (final IOException e) {
throw e;
}
// An initial commit to create the master branch.
try {
doCommit();
} catch (final RemoteSyncException e) {
throw e;
}
}
// Create remote directory if necessary.
try {
CommandRunner.createRemoteDirectory(connection, remoteDirectory);
} catch (final CoreException e) {
throw e;
}
// Initialize remote directory if necessary
try {
doRemoteInit();
} catch (final IOException e) {
throw e;
} catch (final RemoteExecutionException e) {
throw e;
}
// Commit remote files if necessary
try {
doRemoteCommit();
} catch (final IOException e) {
throw e;
} catch (final RemoteExecutionException e) {
throw e;
}
return new Git(repository);
}
/**
* Create and configure remote repository if it is not already present. Note that "git init" is "safe" on a repo already
* created, so we can simply rerun it each time.
* @throws IOException
* @throws RemoteExecutionException
*/
private void doRemoteInit() throws IOException, RemoteExecutionException {
String command = null;
CommandResults commandResults = null;
command = "git init"; //$NON-NLS-1$
try {
commandResults = CommandRunner.executeRemoteCommand(connection, command, remoteDirectory, null);
} catch (final IOException e) {
throw e;
} catch (final InterruptedException e) {
throw new RemoteExecutionException(e);
} catch (RemoteConnectionException e) {
throw new RemoteExecutionException(e);
}
if (commandResults.getExitCode() != 0) {
throw new RemoteExecutionException("remote git init failed with message: " + //$NON-NLS-1$
commandResults.getStderr());
}
}
/*
* Do a commit on the remote repository. First we add and delete files from the git index as needed, and then we call "git commit"
* TODO: Modified files already added by "git add" will not be found by "getRemoteFileStatus". Thus, a commit may not happen even
* though there are outstanding changes. Note that this can only occur by accessing the repo outside of Eclipse.
*/
private void doRemoteCommit() throws IOException, RemoteExecutionException {
Set<String> filesToAdd = new HashSet<String>();
Set<String> filesToDelete = new HashSet<String>();
boolean needToCommit = false;
try {
getRemoteFileStatus(filesToAdd, filesToDelete);
for (String fileName : filesToDelete) {
if (filesToAdd.contains(fileName)) {
filesToAdd.remove(fileName);
}
}
if (filesToAdd.size() > 0) {
addRemoteFiles(filesToAdd);
needToCommit = true;
}
if (filesToDelete.size() > 0) {
deleteRemoteFiles(filesToDelete);
needToCommit = true;
}
if (needToCommit) {
commitRemoteFiles();
}
} catch (IOException e) {
throw e;
} catch (RemoteExecutionException e) {
throw e;
}
}
/*
* Do a "git commit" on the remote host
*/
private void commitRemoteFiles() throws IOException, RemoteExecutionException {
final String command = "git commit -m \"" + commitMessage + "\""; //$NON-NLS-1$ //$NON-NLS-2$
CommandResults commandResults = null;
try {
commandResults = CommandRunner.executeRemoteCommand(connection, command, remoteDirectory, null);
} catch (final IOException e) {
throw e;
} catch (final InterruptedException e) {
throw new RemoteExecutionException(e);
} catch (RemoteConnectionException e) {
throw new RemoteExecutionException(e);
}
if (commandResults.getExitCode() != 0) {
throw new RemoteExecutionException("remote git commit failed with message: " + //$NON-NLS-1$
commandResults.getStderr());
}
}
/*
* Do a "git rm <Files>" on the remote host
*/
private void deleteRemoteFiles(Set<String> filesToDelete) throws IOException, RemoteExecutionException {
String command = "git rm"; //$NON-NLS-1$
for (String fileName : filesToDelete) {
command = command.concat(" "); //$NON-NLS-1$
command = command.concat(fileName);
}
CommandResults commandResults = null;
try {
commandResults = CommandRunner.executeRemoteCommand(connection, command, remoteDirectory, null);
} catch (final IOException e) {
throw e;
} catch (final InterruptedException e) {
throw new RemoteExecutionException(e);
} catch (RemoteConnectionException e) {
throw new RemoteExecutionException(e);
}
if (commandResults.getExitCode() != 0) {
throw new RemoteExecutionException("remote git rm failed with message: " + //$NON-NLS-1$
commandResults.getStderr());
}
}
/*
* Do a "git add <Files>" on the remote host
*/
private void addRemoteFiles(Set<String> filesToAdd) throws IOException, RemoteExecutionException {
String command = "git add"; //$NON-NLS-1$
for (String fileName : filesToAdd) {
command = command.concat(" "); //$NON-NLS-1$
command = command.concat(fileName);
}
CommandResults commandResults = null;
try {
commandResults = CommandRunner.executeRemoteCommand(connection, command, remoteDirectory, null);
} catch (final IOException e) {
throw e;
} catch (final InterruptedException e) {
throw new RemoteExecutionException(e);
} catch (RemoteConnectionException e) {
throw new RemoteExecutionException(e);
}
if (commandResults.getExitCode() != 0) {
throw new RemoteExecutionException("remote git add failed with message: " + //$NON-NLS-1$
commandResults.getStderr());
}
}
/*
* Use "git ls-files" to obtain a list of files that need to be added or deleted from the git index.
*/
private void getRemoteFileStatus(Set<String> filesToAdd, Set<String> filesToDelete)
throws IOException, RemoteExecutionException {
final String command = "git ls-files -t --modified --others --deleted"; //$NON-NLS-1$
CommandResults commandResults = null;
try {
commandResults = CommandRunner.executeRemoteCommand(connection, command, remoteDirectory, null);
} catch (final IOException e) {
throw e;
} catch (final InterruptedException e) {
throw new RemoteExecutionException(e);
} catch (RemoteConnectionException e) {
throw new RemoteExecutionException(e);
}
if (commandResults.getExitCode() != 0) {
throw new RemoteExecutionException("remote git ls-files failed with message: " + //$NON-NLS-1$
commandResults.getStderr());
}
BufferedReader statusReader = new BufferedReader(new StringReader(commandResults.getStdout()));
String line = null;
while ((line = statusReader.readLine()) != null) {
String[] lineParts = line.split("\\s+"); //$NON-NLS-1$
if (lineParts.length < 2) {
continue;
}
if (lineParts[0].startsWith("R")) { //$NON-NLS-1$
filesToDelete.add(lineParts[1]);
} else {
filesToAdd.add(lineParts[1]);
}
}
statusReader.close();
}
// Subclass JGit's generic RemoteSession to set up running of remote commands using the available process builder.
public class PTPSession implements RemoteSession {
private URIish uri;
public PTPSession(URIish uri) {
this.uri = uri;
}
public Process exec(String command, int timeout) throws TransportException {
// TODO: Use a library for command splitting.
List<String> commandList = new LinkedList<String>();
commandList.add("sh"); //$NON-NLS-1$
commandList.add("-c"); //$NON-NLS-1$
commandList.add(command);
try {
if (!connection.isOpen()) {
connection.open(null);
}
return (AbstractRemoteProcess) connection.getRemoteServices().getProcessBuilder(connection, commandList).start();
} catch (IOException e) {
throw new TransportException(uri, e.getMessage(), e);
} catch (RemoteConnectionException e) {
throw new TransportException(uri, e.getMessage(), e);
}
}
public void disconnect() {
// Nothing to do
}
}
/**
* Creates the transport object that JGit uses for executing commands remotely.
*
* @param remoteConfig
* the remote configuration for our local Git repo
* @return the transport
* @throws RuntimeException
* if the requested transport is not supported by JGit.
*/
private void buildTransport(RemoteConfig remoteConfig) {
final URIish uri = buildURI();
try {
transport = (TransportGitSsh) Transport.open(git.getRepository(), uri);
} catch (NotSupportedException e) {
throw new RuntimeException(e);
} catch (TransportException e) {
throw new RuntimeException(e);
}
// Set the transport to use our own means of executing remote commands.
transport.setSshSessionFactory(new SshSessionFactory() {
@Override
public RemoteSession getSession(URIish uri, CredentialsProvider credentialsProvider, FS fs, int tms) throws TransportException {
return new PTPSession(uri);
}
});
transport.applyConfig(remoteConfig);
}
/**
* Build the URI for the remote host as needed by the transport. Since the transport will use an external SSH session, we do
* not need to provide user, host, or password. However, the function for opening a transport throws an exception if the host
* is null or empty length. So we set it to a dummy string.
*
* @return URIish
*/
private URIish buildURI() {
return new URIish()
// .setUser(connection.getUsername())
.setHost("/not/a/real/host") //$NON-NLS-1$
// .setPass("")
.setScheme("ssh") //$NON-NLS-1$
.setPath(remoteDirectory);
}
public void close() {
transport.close();
}
/**
* Commits files in working directory. For now, we just commit all files. So adding ".", handles all files, including newly
* created files, and setting the all flag (-a) ensures that deleted files are updated.
* TODO: Figure out how to do this more efficiently, as was done remotely (using git ls-files)
*
* @throws RemoteSyncException
* on problems committing.
*/
private void doCommit() throws RemoteSyncException {
final AddCommand addCommand = git.add();
addCommand.addFilepattern("."); //$NON-NLS-1$
try {
addCommand.call();
} catch (final NoFilepatternException e) {
throw new RemoteSyncException(e);
}
final CommitCommand commitCommand = git.commit();
commitCommand.setAll(true);
commitCommand.setMessage(commitMessage);
try {
commitCommand.call();
} catch (final GitAPIException e) {
throw new RemoteSyncException(e);
} catch (final UnmergedPathException e) {
throw new RemoteSyncException(e);
}
}
/**
* @return the connection (IRemoteConnection)
*/
public IRemoteConnection getConnection() {
return connection;
}
/**
* @return the localDirectory
*/
public String getLocalDirectory() {
return localDirectory;
}
/**
* @return the remoteDirectory
*/
public String getRemoteDirectory() {
return remoteDirectory;
}
/**
*
* @param localDirectory
* @return If the repo has actually been initialized
* TODO: Consider the ways this could go wrong. What if the directory name already ends in a slash? What if ".git" is a file or
* does not contain the appropriate files?
*/
private boolean repoReady() {
final String repoDirectory = localDirectory + "/.git"; //$NON-NLS-1$
final File repoDir = new File(repoDirectory);
return repoDir.exists();
}
/**
* Many of the listed exceptions appear to be unrecoverable, caused by errors in the initial setup. It is vital, though, that
* failed syncs are reported and handled. So all exceptions are checked exceptions, embedded in a RemoteSyncException.
*
* @throws RemoteSyncException
* for various problems sync'ing. The specific exception is nested within the RemoteSyncException.
* TODO: Consider possible platform dependency.
*/
public void syncLocalToRemote() throws RemoteSyncException {
// First commit changes to the local repository.
try {
doCommit();
} catch (final RemoteSyncException e) {
throw e;
}
// Then push them to the remote site.
try {
transport.push(NullProgressMonitor.INSTANCE, null);
} catch (final NotSupportedException e) {
throw new RemoteSyncException(e);
} catch (final TransportException e) {
throw new RemoteSyncException(e);
}
// Now remotely merge changes with master branch
CommandResults mergeResults;
final String command = "git merge " + remotePushBranch; //$NON-NLS-1$
try {
mergeResults = CommandRunner.executeRemoteCommand(connection, command, remoteDirectory, null);
} catch (final IOException e) {
throw new RemoteSyncException(e);
} catch (final InterruptedException e) {
throw new RemoteSyncException(e);
} catch (RemoteConnectionException e) {
throw new RemoteSyncException(e);
}
if (mergeResults.getExitCode() != 0) {
throw new RemoteSyncException(new RemoteExecutionException("Remote merge failed with message: " + //$NON-NLS-1$
mergeResults.getStderr()));
}
}
/**
* @throws RemoteSyncException
* for various problems sync'ing. The specific exception is nested within the RemoteSyncException. Many of the
* listed exceptions appear to be unrecoverable, caused by errors in the initial setup. It is vital, though, that
* failed syncs are reported and handled. So all exceptions are checked exceptions, embedded in a
* RemoteSyncException.
*/
public void syncRemoteToLocal() throws RemoteSyncException {
// TODO: Figure out why pull doesn't work and why we have to fetch and merge instead.
// PullCommand pullCommand = gitConnection.getGit().pull().
// try {
// pullCommand.call();
// } catch (WrongRepositoryStateException e) {
// throw new RemoteSyncException(e);
// } catch (InvalidConfigurationException e) {
// throw new RemoteSyncException(e);
// } catch (DetachedHeadException e) {
// throw new RemoteSyncException(e);
// } catch (InvalidRemoteException e) {
// throw new RemoteSyncException(e);
// } catch (CanceledException e) {
// throw new RemoteSyncException(e);
// }
// First, commit in case any changes have occurred remotely.
try {
doRemoteCommit();
} catch (IOException e) {
throw new RemoteSyncException(e);
} catch (RemoteExecutionException e) {
throw new RemoteSyncException(e);
}
// Next, fetch the remote repository
try {
transport.fetch(NullProgressMonitor.INSTANCE, null);
} catch (final NotSupportedException e) {
throw new RemoteSyncException(e);
} catch (final TransportException e) {
throw new RemoteSyncException(e);
}
// Now merge. Before merging we set the head for merging to master.
Ref masterRef = null;
try {
masterRef = git.getRepository().getRef("refs/remotes/" + remoteProjectName + "/master"); //$NON-NLS-1$ //$NON-NLS-2$
} catch (final IOException e) {
throw new RemoteSyncException(e);
}
final MergeCommand mergeCommand = git.merge().include(masterRef);
try {
mergeCommand.call();
} catch (final NoHeadException e) {
throw new RemoteSyncException(e);
} catch (final ConcurrentRefUpdateException e) {
throw new RemoteSyncException(e);
} catch (final CheckoutConflictException e) {
throw new RemoteSyncException(e);
} catch (final InvalidMergeHeadsException e) {
throw new RemoteSyncException(e);
} catch (final WrongRepositoryStateException e) {
throw new RemoteSyncException(e);
} catch (final NoMessageException e) {
throw new RemoteSyncException(e);
}
}
}