package alien4cloud.git;
import java.io.BufferedWriter;
import java.io.File;
import java.io.IOException;
import java.net.URISyntaxException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.*;
import org.apache.commons.lang3.StringUtils;
import org.eclipse.jgit.api.*;
import org.eclipse.jgit.api.errors.GitAPIException;
import org.eclipse.jgit.api.errors.NoHeadException;
import org.eclipse.jgit.errors.RepositoryNotFoundException;
import org.eclipse.jgit.lib.Ref;
import org.eclipse.jgit.lib.Repository;
import org.eclipse.jgit.lib.RepositoryState;
import org.eclipse.jgit.lib.StoredConfig;
import org.eclipse.jgit.revwalk.RevCommit;
import org.eclipse.jgit.transport.*;
import com.google.common.collect.Lists;
import alien4cloud.exception.GitConflictException;
import alien4cloud.exception.GitException;
import alien4cloud.exception.GitMergingStateException;
import alien4cloud.exception.GitStateException;
import alien4cloud.utils.FileUtil;
import lombok.extern.slf4j.Slf4j;
/**
* Utility to manage git repositories.
*/
@Slf4j
public class RepositoryManager {
private static final String REMOTE_ALIEN_CONFLICTS_PREFIX_BRANCH_NAME = "alien-conflicts-";
/**
* Close a repository.
*
* @param repository The repository to close.
*/
public static void close(Git repository) {
if (repository != null) {
repository.close();
}
}
/**
* Check if a given directory is a git repository.
*
* @param targetDirectory The directory to check.
* @return true if the directory is a git repository, false if not.
*/
public static boolean isGitRepository(Path targetDirectory) {
Git repository = null;
try {
repository = Git.open(targetDirectory.toFile());
return true;
} catch (IOException e) {
return false;
} finally {
close(repository);
}
}
/**
* Create a git repository that includes an optional readme file.
*
* @param targetDirectory The path of the repository to create.
* @param readmeContentIfEmpty
*/
public static void create(Path targetDirectory, String readmeContentIfEmpty) {
Git repository = null;
try {
repository = Git.init().setDirectory(targetDirectory.toFile()).call();
if (readmeContentIfEmpty != null) {
Path readmePath = targetDirectory.resolve("readme.txt");
File file = readmePath.toFile();
file.createNewFile();
try (BufferedWriter writer = Files.newBufferedWriter(readmePath)) {
writer.write(readmeContentIfEmpty);
}
}
} catch (GitAPIException | IOException e) {
throw new GitException("Error while creating git repository", e);
} finally {
close(repository);
}
}
/**
* Commit all changes in the given repository.
*
* @param targetDirectory The target directory.
*/
public static void commitAll(Path targetDirectory, String userName, String userEmail, String commitMessage) {
Git repository = null;
try {
repository = Git.open(targetDirectory.toFile());
repository.add().addFilepattern(".").call();
repository.commit().setCommitter(userName, userEmail).setMessage(commitMessage).call();
} catch (GitAPIException | IOException e) {
throw new GitException("Unable to commit to the git repository", e);
} finally {
close(repository);
}
}
/**
* Clone or checkout a git repository in a local directory relative to the given targetDirectory.
*
* @param targetDirectory The root directory that will contains the localDirectory in which to checkout the archives.
* @param repositoryUrl The url of the repository to checkout or clone.
* @param branch The branch to checkout or clone.
* @param localDirectory The path, relative to targetDirectory, in which to checkout or clone the git directory.
*/
public static void cloneOrCheckout(Path targetDirectory, String repositoryUrl, String branch, String localDirectory) {
Git repository = null;
try {
repository = cloneOrCheckout(targetDirectory, repositoryUrl, null, null, branch, localDirectory);
} finally {
close(repository);
}
}
/**
* @param targetDirectory The root directory that will contains the localDirectory in which to checkout the archives.
* @param repositoryUrl The url of the repository to checkout or clone.
* @param username The username to use for the repository connection.
* @param password The password to use for the repository connection.
* @param branch The branch to checkout or clone.
* @param localDirectory The path, relative to targetDirectory, in which to checkout or clone the git directory.
*/
public static Git cloneOrCheckout(Path targetDirectory, String repositoryUrl, String username, String password, String branch, String localDirectory) {
try {
Files.createDirectories(targetDirectory);
Path targetPath = targetDirectory.resolve(localDirectory);
Git repository;
if (Files.exists(targetPath)) {
try {
repository = Git.open(targetPath.toFile());
fetch(repository, username, password);
checkoutRepository(repository, branch);
} catch (RepositoryNotFoundException e) {
// TODO delete the folder
FileUtil.delete(targetPath);
repository = cloneRepository(repositoryUrl, username, password, branch, targetPath);
}
} else {
Files.createDirectories(targetPath);
repository = cloneRepository(repositoryUrl, username, password, branch, targetPath);
}
return repository;
} catch (IOException e) {
throw new GitException("Error while creating target directory", e);
}
}
/**
* Check if a given branchId is a tag
*
* @param repository the Git repository
* @param branch the branchId
* @return <code>true</code> if the branchId refer to a tag, <code>false</code> otherwise.
*/
public static boolean isATag(Git repository, String branch) {
String fullBranchReference = getFullReference(repository, branch);
String[] segments = fullBranchReference.split("/");
if (segments.length > 2 && branch.equals(segments[segments.length - 1]) && "tags".equals(segments[segments.length - 2])) {
return true;
}
return false;
}
private static String addPrefixOnTag(Git repository, String branch) {
if (isATag(repository, branch)) {
return "tags/" + branch;
}
return branch;
}
private static boolean branchExistsLocally(Git git, String branch) throws GitAPIException {
return git.branchList().call().stream().anyMatch(ref -> ref.getName().replace("refs/heads/", "").equals(branch));
}
private static String getFullReference(Git repository, String branch) {
Map<String, Ref> refs = repository.getRepository().getAllRefs();
for (String refId : refs.keySet()) {
String[] segments = refId.split("/");
if (segments.length > 1 && branch.equals(segments[segments.length - 1])) {
return refId;
}
}
return branch;
}
private static void checkoutRepository(Git repository, String branch) {
try {
String fullBranchReference = addPrefixOnTag(repository, branch);
CheckoutCommand checkoutCommand = repository.checkout();
checkoutCommand.setName(fullBranchReference);
if (!branchExistsLocally(repository, fullBranchReference)) {
checkoutCommand.setCreateBranch(true).setStartPoint(getFullReference(repository, branch));
}
checkoutCommand.call();
} catch (GitAPIException e) {
throw new GitException("Failed to checkout git repository", e);
}
}
private static Git cloneRepository(String url, String username, String password, String branch, Path targetPath) throws IOException {
log.debug("Cloning from [{}] branch [{}] to [{}]", url, branch, targetPath.toString());
Git result;
try {
CloneCommand cloneCommand = Git.cloneRepository().setURI(url).setBranch(branch).setDirectory(targetPath.toFile());
setCredentials(cloneCommand, username, password);
result = cloneCommand.call();
log.debug("Cloned repository to [{}]: ", result.getRepository().getDirectory());
return result;
} catch (GitAPIException e) {
// if the import fails then we should try to remove the created directory
FileUtil.delete(targetPath);
throw new GitException("Failed to clone git repository", e);
}
}
/**
* Trigger a Pull Request on an existing repository.
*
* @param repository The git repository to pull.
* @param username The username to use for the repository connection.
* @param password The password to use for the repository connection.
* @return True if the pull request has updated the data, false if the repository was already up to date.
*/
public static boolean pull(Git repository, String username, String password) {
try {
PullCommand pullCommand = repository.pull();
setCredentials(pullCommand, username, password);
PullResult result = pullCommand.call();
MergeResult mergeResult = result.getMergeResult();
if (mergeResult != null && MergeResult.MergeStatus.ALREADY_UP_TO_DATE == mergeResult.getMergeStatus()) {
return false; // nothing has changed
}
return true;
} catch (GitAPIException e) {
throw new GitException("Failed to pull git repository", e);
}
}
/**
* Get the hash of the last commit on the current branch of the given repository.
*
* @param git The repository from which to get the last commit hash.
* @return The hash of the last commit.
*/
public static String getLastHash(Git git) {
try {
Iterator<RevCommit> revCommitIterator = git.log().setMaxCount(1).call().iterator();
if (revCommitIterator.hasNext()) {
return revCommitIterator.next().getName();
}
} catch (GitAPIException e) {
throw new GitException("Failed to log git repository", e);
}
return null;
}
private static void setCredentials(TransportCommand<?, ?> command, String username, String password) {
if (StringUtils.isNotBlank(username)) {
if (password == null) {
// If an user accessing a GitHub repository through HTTPS with an OAuth access token
password = "";
}
command.setCredentialsProvider(new UsernamePasswordCredentialsProvider(username, password));
}
}
/**
* Return a simplified git commit history list.
*
* @param repositoryDirectory The directory in which the git repo exists.
* @param from Start to query from the given history.
* @param count The number of history entries to retrieve.
* @return A list of simplified history entries.
*/
public static List<SimpleGitHistoryEntry> getHistory(Path repositoryDirectory, int from, int count) {
Git repository = null;
try {
repository = Git.open(repositoryDirectory.toFile());
Iterable<RevCommit> commits = repository.log().setSkip(from).setMaxCount(count).call();
List<SimpleGitHistoryEntry> historyEntries = Lists.newArrayList();
for (RevCommit commit : commits) {
historyEntries.add(new SimpleGitHistoryEntry(commit.getId().getName(), commit.getAuthorIdent().getName(),
commit.getAuthorIdent().getEmailAddress(), commit.getFullMessage(), new Date(commit.getCommitTime() * 1000L)));
}
return historyEntries;
} catch (NoHeadException e) {
log.debug("Your repository has no head, you need to save your topology before using the git history.");
return Lists.newArrayList();
} catch (GitAPIException | IOException e) {
throw new GitException("Unable to get history from the git repository", e);
} finally {
close(repository);
}
}
/**
* Set a remote repository path.
*
* @param repositoryDirectory The directory in which the git repo exists.
* @param remoteName The name of the remote (i.e. 'origin')
* @param remoteUrl The url of the repository.
*/
public static void setRemote(Path repositoryDirectory, String remoteName, String remoteUrl) {
Git git = null;
try {
git = Git.open(repositoryDirectory.toFile());
StoredConfig config = git.getRepository().getConfig();
config.unsetSection("remote", remoteName);
RemoteConfig remoteConfig = new RemoteConfig(config, remoteName);
remoteConfig.addURI(new URIish(remoteUrl));
remoteConfig.addFetchRefSpec(new RefSpec("+refs/heads/*:refs/remotes/" + remoteName + "/*"));
remoteConfig.update(config);
config.save();
} catch (URISyntaxException | IOException e) {
throw new GitException("Unable to set the remote repository", e);
} finally {
close(git);
}
}
/**
* Get the URL of the git remote.
*
* @param repositoryDirectory The directory in which the git repo exists.
* @param remoteName The name of the remote
* @return The url of the git remote.
*/
public static String getRemoteUrl(Path repositoryDirectory, String remoteName) {
Git git = null;
try {
git = Git.open(repositoryDirectory.toFile());
return git.getRepository().getConfig().getString("remote", remoteName, "url");
} catch (IOException e) {
throw new GitException("Unable to open the git repository", e);
} finally {
close(git);
}
}
/**
* Git push to a remote.
*
* @param repositoryDirectory The directory in which the git repo exists.
* @param username The username to use for the repository connection.
* @param password The password to use for the repository connection.
* @return Returns <code>true</code> pushed, <code>false</code> otherwise.
*/
public static boolean push(Path repositoryDirectory, String username, String password) {
return push(repositoryDirectory, username, password, null);
}
/**
* Git push to a remote.
*
* @param repositoryDirectory The directory in which the git repo exists.
* @param username The username to use for the repository connection.
* @param password The password to use for the repository connection.
* @return <code>true</code> pushed, <code>false</code> otherwise.
*/
public static boolean push(Path repositoryDirectory, String username, String password, String remoteBranch) {
Git git = null;
try {
git = Git.open(repositoryDirectory.toFile());
checkRepositoryState(git.getRepository().getRepositoryState(), "Git push operation failed.");
Repository repository = git.getRepository();
// If no given remoteBranch, use the default one (i.e. master).
String targetRemoteBranch = remoteBranch == null ? repository.getBranch() : remoteBranch;
boolean isPushed = push(git, username, password, repository.getBranch(), targetRemoteBranch);
if (!isPushed) {
// If not pushed, then we have a conflict.
// Push the current commit into a new alien branch.
// Then rebranch to the current branch.
String remoteName = repository.getRemoteNames().iterator().next(); // Only handle one remote (default: 'origin')
log.debug(String.format("Couldn't push git repository=%s to remote=%s on the branch=%s", git.getRepository().getDirectory(), remoteName,
repository.getBranch()));
fetch(git, username, password);
String conflictBranchName = generateConflictBranchName(repository, remoteName);
isPushed = push(git, username, password, repository.getBranch(), conflictBranchName);
if (isPushed) {
log.debug(String.format("Pushed git repository=%s on branch=%s", git.getRepository().getDirectory(), conflictBranchName));
rebranch(git, repository.getBranch(), targetRemoteBranch);
}
throw new GitConflictException(remoteName, repository.getBranch(), conflictBranchName);
} else {
log.debug(String.format("Pushed git repository=%s on branch=%s", git.getRepository().getDirectory(), targetRemoteBranch));
}
return isPushed;
} catch (IOException e) {
throw new GitException("Unable to open the remote repository", e);
} finally {
close(git);
}
}
/**
* Generate the conflict branch name to push to.
*/
private static String generateConflictBranchName(Repository repository, String remoteName) {
Map<String, Ref> allRefs = repository.getAllRefs();
String remoteAlienRefSpecPrefixName = String.format("refs/remotes/%s/%s", remoteName, REMOTE_ALIEN_CONFLICTS_PREFIX_BRANCH_NAME);
long count = allRefs.keySet().stream().filter(key -> key.startsWith(remoteAlienRefSpecPrefixName)).count();
return String.format("%s%d-%d", REMOTE_ALIEN_CONFLICTS_PREFIX_BRANCH_NAME, new Date().getTime(), count + 1);
}
/**
* Rebranch the local and remote branch.
*
* @param git The git repository.
* @param localBranch The name of the local branch.
* @param remoteBranch The name of the remote branch.
*/
public static void rebranch(Git git, String localBranch, String remoteBranch) {
String tmpBranchName = "a4c-switch";
try {
log.debug(String.format("Prepare git repository=%s to re-branch=%s on remote branch=%s", git.getRepository().getDirectory(), localBranch,
remoteBranch));
CheckoutCommand checkoutCommand = git.checkout();
checkoutCommand.setStartPoint("origin/" + remoteBranch);
checkoutCommand.setName(tmpBranchName);
checkoutCommand.setCreateBranch(true);
checkoutCommand.call();
log.debug(String.format("Delete branch=%s from git repository=%s", localBranch, git.getRepository().getDirectory()));
DeleteBranchCommand deleteBranchCommand = git.branchDelete();
deleteBranchCommand.setBranchNames(localBranch);
deleteBranchCommand.setForce(true);
deleteBranchCommand.call();
log.debug(String.format("Finalize git re-branch=%s for repository=%s", localBranch, git.getRepository().getDirectory()));
RenameBranchCommand renameBranchCommand = git.branchRename();
renameBranchCommand.setOldName(tmpBranchName);
renameBranchCommand.setNewName(localBranch);
renameBranchCommand.call();
} catch (GitAPIException e) {
throw new GitException("Couldn't rebranch to origin common branch", e);
}
}
/**
* Git push to a remote.
*
* @param git The git repository.
* @param username The username to use for the repository connection.
* @param password The password to use for the repository connection.
* @param localBranch The name of the local branch to push.
* @param remoteBranch The name of the remote branch to push to.
* @return <code>true</code> pushed, <code>false</code> otherwise.
*/
public static boolean push(Git git, String username, String password, String localBranch, String remoteBranch) {
try {
if (git.getRepository().getRemoteNames().isEmpty()) {
throw new GitException("No remote found for the repository");
}
PushCommand pushCommand = git.push();
setCredentials(pushCommand, username, password);
RefSpec refSpec = new RefSpec(String.format("refs/heads/%s:refs/heads/%s", localBranch, remoteBranch));
pushCommand.setRefSpecs(refSpec);
Iterable<PushResult> call = pushCommand.call();
return isPushed(call);
} catch (GitAPIException e) {
throw new GitException(String.format("Error when trying to git push: %s", e.getMessage()), e);
}
}
/**
* Inspect the push returns to know if the push command has succeeded.
*
* @param call The push results object.
* @return Returns <code>true</code> pushed, <code>false</code> otherwise.
*/
private static boolean isPushed(Iterable<PushResult> call) {
for (PushResult pr : call) {
Optional<RemoteRefUpdate> any = pr.getRemoteUpdates().stream()
.filter(ru -> !(RemoteRefUpdate.Status.OK.equals(ru.getStatus()) || RemoteRefUpdate.Status.UP_TO_DATE.equals(ru.getStatus()))).findAny();
if (any.isPresent()) {
return false;
}
}
return true;
}
/**
* Fetch a git repository.
*
* @param git The git repository.
* @param username The username to use for the repository connection.
* @param password The password to use for the repository connection.
*/
public static void fetch(Git git, String username, String password) {
try {
FetchCommand fetchCommand = git.fetch();
setCredentials(fetchCommand, username, password);
FetchResult fetchResult = fetchCommand.call();
log.debug(String.format("Fetched git repository=%s messages=%s", git.getRepository().getDirectory(), fetchResult.getMessages()));
} catch (GitAPIException e) {
throw new GitException("Unable to fetch git repository", e);
}
}
/**
* Pull modifications from the default branch a git repository.
*
* @param repositoryDirectory The directory in which the git repo exists.
* @param username The username for the git repository connection, null if none.
* @param password The password for the git repository connection, null if none.
*/
public static void pull(Path repositoryDirectory, String username, String password) {
pull(repositoryDirectory, username, password, null);
}
/**
* Pull modifications a git repository.
*
* @param repositoryDirectory The directory in which the git repo exists.
* @param username The username for the git repository connection, null if none.
* @param password The password for the git repository connection, null if none.
* @param remoteBranch The name of the remote branch to pull from.
*/
public static void pull(Path repositoryDirectory, String username, String password, String remoteBranch) {
Git git = null;
try {
git = Git.open(repositoryDirectory.resolve(".git").toFile());
if (git.getRepository().getRemoteNames().isEmpty()) {
throw new GitException("No remote found for the repository");
}
checkRepositoryState(git.getRepository().getRepositoryState(), "Git pull operation failed");
PullCommand pullCommand = git.pull();
setCredentials(pullCommand, username, password);
pullCommand.setRemoteBranchName(remoteBranch);
PullResult call = pullCommand.call();
if (call.getMergeResult() != null && call.getMergeResult().getConflicts() != null && !call.getMergeResult().getConflicts().isEmpty()) {
throw new GitConflictException(git.getRepository().getBranch());
}
log.debug(String.format("Successfully pulled from %s", call.getFetchedFrom()));
} catch (IOException e) {
throw new GitException("Unable to open the git repository", e);
} catch (GitAPIException e) {
throw new GitException("Unable to pull the git repository", e);
} finally {
close(git);
}
}
/**
* Clean the current modifications of the repository
*
* @param repositoryDirectory
*/
public static void clean(Path repositoryDirectory) {
Git repository = null;
try {
repository = Git.open(repositoryDirectory.resolve(".git").toFile());
CleanCommand cleanCommand = repository.clean();
cleanCommand.setIgnore(true);
cleanCommand.call();
} catch (IOException e) {
throw new GitException("Unable to open the git repository", e);
} catch (GitAPIException e) {
throw new GitException("Unable to clean the git repository", e);
} finally {
close(repository);
}
}
/**
* Check the given state of a git repository.
* This method throws exceptions if the state is not SAFE.
*
* @param repositoryState The state of the repository.
* @param errorMessage A message error.
*/
private static void checkRepositoryState(RepositoryState repositoryState, String errorMessage) {
switch (repositoryState) {
case SAFE:
return;
case MERGING:
throw new GitMergingStateException(errorMessage);
default:
throw new GitStateException(errorMessage, repositoryState.toString());
}
}
}