GitConnection.java

package com.github.jonasrutishauser.maven.wagon.git;

/*
 * Copyright (C) 2017 Jonas Rutishauser
 * 
 * This program is free software: you can redistribute it and/or modify
 * it under the terms of the GNU Lesser General Public License as published by
 * the Free Software Foundation version 3 of the License.
 * 
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU Lesser General Public License for more details.
 * 
 * You should have received a copy of the GNU Lesser General Public License
 * along with this program.
 * If not, see <http://www.gnu.org/licenses/lgpl-3.0.txt>.
 */

import static org.eclipse.jgit.transport.RemoteRefUpdate.Status.OK;

import java.io.File;
import java.io.IOException;
import java.net.URISyntaxException;
import java.nio.file.CopyOption;
import java.nio.file.FileVisitResult;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.SimpleFileVisitor;
import java.nio.file.StandardCopyOption;
import java.nio.file.attribute.BasicFileAttributes;
import java.time.Instant;
import java.util.Optional;

import org.eclipse.jgit.api.FetchCommand;
import org.eclipse.jgit.api.Git;
import org.eclipse.jgit.api.LogCommand;
import org.eclipse.jgit.api.PushCommand;
import org.eclipse.jgit.api.RemoteAddCommand;
import org.eclipse.jgit.api.errors.GitAPIException;
import org.eclipse.jgit.api.errors.TransportException;
import org.eclipse.jgit.lib.Constants;
import org.eclipse.jgit.lib.ProgressMonitor;
import org.eclipse.jgit.revwalk.RevCommit;
import org.eclipse.jgit.transport.CredentialsProvider;
import org.eclipse.jgit.transport.PushResult;
import org.eclipse.jgit.transport.RefSpec;
import org.eclipse.jgit.transport.RemoteConfig;
import org.eclipse.jgit.transport.RemoteRefUpdate;
import org.eclipse.jgit.transport.URIish;
import org.eclipse.jgit.transport.UsernamePasswordCredentialsProvider;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import com.github.jonasrutishauser.maven.wagon.git.exception.GitAuthenticationException;
import com.github.jonasrutishauser.maven.wagon.git.exception.GitCloneException;
import com.github.jonasrutishauser.maven.wagon.git.exception.GitException;
import com.github.jonasrutishauser.maven.wagon.git.exception.GitPushException;
import com.github.jonasrutishauser.maven.wagon.git.exception.NoSuchResourceInGitException;
import com.github.jonasrutishauser.maven.wagon.git.util.LoggerProgressMonitor;

public class GitConnection implements AutoCloseable {

    private static final Logger LOGGER = LoggerFactory.getLogger(GitConnection.class);

    private final Git git;

    private final CredentialsProvider credentialsProvider;

    private final Path workingDirectory;

    private GitConnection(Git git, CredentialsProvider credentialsProvider, Path pathInWorkingDirectory) {
        this.git = git;
        this.credentialsProvider = credentialsProvider;
        this.workingDirectory = git.getRepository().getWorkTree().toPath().resolve(pathInWorkingDirectory);
    }

    public static GitConnection open(GitConfiguration configuration, Optional<String> username,
            Optional<String> password) throws GitCloneException, GitAuthenticationException {
        String branch = configuration.getBranch().orElse(Constants.MASTER);
        CredentialsProvider credentialsProvider = null;
        if (username.isPresent()) {
            credentialsProvider = new UsernamePasswordCredentialsProvider(username.get(),
                    password.map(String::toCharArray).orElse(null));
        }
        File workingDirectory = null;
        Git git;
        try {
            workingDirectory = configuration.getWorkingDirectory().toFile();
            git = Git.init().setDirectory(workingDirectory).call();
            RemoteConfig remoteConfig = setRemote(configuration.getUrl(), git);
            if (remoteHasBranch(git, branch)) {
                fetchAndCheckoutBranch(git, branch, remoteConfig, credentialsProvider);
            } else if (configuration.getBranch().isPresent()) {
                git.checkout().setName(branch).setOrphan(true).call();
            }
        } catch (GitAPIException | IOException | URISyntaxException e) {
            if (workingDirectory != null) {
                deleteWorkTree(workingDirectory);
            }
            if (e instanceof TransportException && isAuthenticationFailureMessage(e.getMessage())) {
                throw new GitAuthenticationException("invalid credentials for repository: " + configuration.getUrl(),
                        e);
            }
            throw new GitCloneException("failed to clone from remote repository: " + e.getMessage(), e);
        }
        return new GitConnection(git, credentialsProvider, configuration.getPath().orElse(Paths.get("")));
    }

    public boolean getIfNewer(Path resource, Path destination, long timestamp) throws GitException {
        Path realResource = workingDirectory.resolve(resource);
        if (!realResource.toFile().exists()) {
            throw new NoSuchResourceInGitException("resource '" + realResource + "' does not exist");
        }
        if (getCommitTime(realResource) <= timestamp) {
            return false;
        }
        try {
            copy(realResource, destination, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.COPY_ATTRIBUTES);
        } catch (IOException e) {
            throw new GitException("failed to read resource: " + e.getMessage(), e);
        }
        return true;
    }

    public void put(Path source, Path destination) throws GitException {
        Path realDestination = workingDirectory.resolve(destination).normalize();
        try {
            if (!workingDirectory.toFile().isDirectory()) {
                Files.createDirectories(workingDirectory);
            }
            copy(source, realDestination, StandardCopyOption.REPLACE_EXISTING);
        } catch (IOException e) {
            throw new GitException("failed to write resource: " + e.getMessage(), e);
        }
        try {
            git.add().addFilepattern(getRepoPath(realDestination)).call();
        } catch (GitAPIException e) {
            throw new GitException("failed to write resource: " + e.getMessage(), e);
        }
    }

    @Override
    public void close() throws GitPushException, GitAuthenticationException {
        try {
            if (needsPush()) {
                pushChanges();
            }
        } catch (GitAPIException e) {
            throw new GitPushException("failed to push all changes to the remote repository: " + e.getMessage(), e);
        }
        File workTree = git.getRepository().getWorkTree();
        git.close();
        deleteWorkTree(workTree);
    }

    private void copy(Path source, Path destination, CopyOption... copyOptions) throws IOException {
        if (source.toFile().isDirectory()) {
            copyDirectory(source, destination, copyOptions);
        } else {
            Files.copy(source, destination, copyOptions);
        }
    }

    private void copyDirectory(Path source, Path destination, CopyOption... copyOptions) throws IOException {
        Files.walkFileTree(source, new SimpleFileVisitor<Path>() {

            @Override
            public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs) throws IOException {
                Path targetDir = destination.resolve(source.relativize(dir));
                if (!targetDir.toFile().isDirectory()) {
                    Files.createDirectory(targetDir);
                }
                return FileVisitResult.CONTINUE;
            }

            @Override
            public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException {
                Files.copy(file, destination.resolve(source.relativize(file)), copyOptions);
                return FileVisitResult.CONTINUE;
            }
        });
    }

    private long getCommitTime(Path realResource) throws GitException {
        LogCommand logCommand = git.log().addPath(getRepoPath(realResource)).setMaxCount(1);
        long commitTime = Instant.now().getEpochSecond();
        try {
            for (RevCommit commit : logCommand.call()) {
                commitTime = commit.getCommitTime();
            }
        } catch (GitAPIException e) {
            throw new GitException("failed to get git history", e);
        }
        return commitTime;
    }

    private String getRepoPath(Path realPath) {
        String path = git.getRepository().getWorkTree().toPath().relativize(realPath).toString();
        return path.isEmpty() ? "." : path;
    }

    private boolean needsPush() throws GitAPIException {
        return git.status().call().hasUncommittedChanges();
    }

    private void pushChanges() throws GitAPIException, GitPushException {
        git.commit().setMessage("[wagon-git] adding files to repository").call();
        PushCommand pushCommand = git.push().setCredentialsProvider(credentialsProvider);
        pushCommand.setProgressMonitor(getProgressMonitor());
        for (PushResult result : pushCommand.call()) {
            for (RemoteRefUpdate update : result.getRemoteUpdates()) {
                if (update.getStatus() != OK) {
                    throw new GitPushException(
                            "failed to push all changes to the remote repository: " + update.getStatus());
                }
            }
        }
    }

    private static void deleteWorkTree(File workTree) {
        if (!workTree.exists()) {
            return;
        }
        try {
            Files.walkFileTree(workTree.toPath(), new SimpleFileVisitor<Path>() {

                @Override
                public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException {
                    Files.delete(file);
                    return FileVisitResult.CONTINUE;
                }

                @Override
                public FileVisitResult postVisitDirectory(Path dir, IOException exc) throws IOException {
                    Files.delete(dir);
                    return FileVisitResult.CONTINUE;
                }
            });
        } catch (IOException e) {
            LOGGER.warn("failed to delete closed local repository: " + e.getMessage(), e);
        }
    }

    private static RemoteConfig setRemote(String url, Git git) throws URISyntaxException, GitAPIException {
        RemoteAddCommand remoteAdd = git.remoteAdd();
        remoteAdd.setName(Constants.DEFAULT_REMOTE_NAME);
        remoteAdd.setUri(new URIish(url));
        return remoteAdd.call();
    }

    private static boolean remoteHasBranch(Git git, String branch) throws GitAPIException {
        return git.lsRemote().callAsMap().containsKey(Constants.R_HEADS + branch);
    }

    private static void fetchAndCheckoutBranch(Git git, String branch, RemoteConfig remoteConfig,
            CredentialsProvider credentialsProvider) throws GitAPIException {
        RefSpec refSpec = remoteConfig.getFetchRefSpecs().get(0).expandFromSource(Constants.R_HEADS + branch);
        FetchCommand fetchCommand = git.fetch().setCredentialsProvider(credentialsProvider);
        fetchCommand.setRefSpecs(refSpec).setProgressMonitor(getProgressMonitor()).call();
        git.checkout().setName(branch).setCreateBranch(true).setStartPoint(refSpec.getDestination()).call();
    }

    private static boolean isAuthenticationFailureMessage(String message) {
        return message.contains("CredentialsProvider") || message.toLowerCase().contains("auth");
    }

    private static ProgressMonitor getProgressMonitor() {
        return new LoggerProgressMonitor();
    }

}