All Downloads are FREE. Search and download functionalities are using the official Maven repository.

org.conqat.engine.index.shared.GitUtils Maven / Gradle / Ivy

There is a newer version: 2025.1.0
Show newest version
/*-----------------------------------------------------------------------+
 | com.teamscale.index
 |                                                                       |
   $Id$
 |                                                                       |
 | Copyright (c)  2009-2013 CQSE GmbH                                 |
 +-----------------------------------------------------------------------*/
package org.conqat.engine.index.shared;

import java.io.File;
import java.io.IOException;
import java.net.URI;
import java.net.URISyntaxException;
import java.util.Optional;
import java.util.regex.Pattern;

import org.conqat.lib.commons.filesystem.FileSystemUtils;
import org.conqat.lib.commons.string.StringUtils;
import org.eclipse.jgit.api.CloneCommand;
import org.eclipse.jgit.api.FetchCommand;
import org.eclipse.jgit.api.Git;
import org.eclipse.jgit.api.TransportCommand;
import org.eclipse.jgit.api.errors.GitAPIException;
import org.eclipse.jgit.diff.DiffEntry;
import org.eclipse.jgit.diff.DiffEntry.ChangeType;
import org.eclipse.jgit.diff.DiffFormatter;
import org.eclipse.jgit.diff.RawTextComparator;
import org.eclipse.jgit.errors.UnsupportedCredentialItem;
import org.eclipse.jgit.lib.FileMode;
import org.eclipse.jgit.lib.ObjectId;
import org.eclipse.jgit.lib.Ref;
import org.eclipse.jgit.lib.Repository;
import org.eclipse.jgit.revwalk.RevCommit;
import org.eclipse.jgit.revwalk.RevWalk;
import org.eclipse.jgit.transport.CredentialItem;
import org.eclipse.jgit.transport.SshSessionFactory;
import org.eclipse.jgit.transport.SshTransport;
import org.eclipse.jgit.transport.URIish;
import org.eclipse.jgit.transport.UsernamePasswordCredentialsProvider;
import org.eclipse.jgit.treewalk.TreeWalk;
import org.eclipse.jgit.treewalk.filter.TreeFilter;
import org.eclipse.jgit.util.io.DisabledOutputStream;

/**
 * Utility methods for working with a Git repository.
 */
public class GitUtils {

	/** Timeout in number of seconds, used for general remote git operations. */
	public static final int REMOTE_OPERATION_TIMEOUT_SECONDS = 60;

	/** Timeout in number of seconds, used for the initial clone of the repo. */
	private static final int INITIAL_CLONE_OPERATION_TIMEOUT_SECONDS = 300;

	/** Exception message when a repository is not found. */
	private static final String REPOSITORY_NOT_FOUND_EXCEPTION_MESSAGE = "Can neither find a bare nor a cloned git repository along the path: ";

	/** Name of the master branch. */
	public static final String MASTER_BRANCH = "master";

	/** Separator used for paths returned via the API. */
	public static final String SEPARATOR = "/";

	/** Pattern for a SHA-1 hash which identifies a Git commit uniquely. */
	private static final Pattern COMMIT_HASH_PATTERN = Pattern.compile("^[0-9a-f]{40}$");

	/** Git HEAD ref. */
	public static final String HEAD = "HEAD";

	/** Base name for anonymous branches. */
	public static final String ANONYMOUS_BRANCH_NAME = "_anonymous_";

	/**
	 * Tries to find an existing bare or cloned repository along the given URI (with
	 * "file" scheme).
	 * 
	 * @throws RepositoryException
	 *             if no repository could be found
	 */
	public static Repository getExistingRepository(URI localGitRepoURI) throws RepositoryException {
		File localGitRepo = new File(localGitRepoURI);
		try {
			return Git.open(localGitRepo).getRepository();
		} catch (IOException e) {
			// Could not find git, try harder below
		}

		Optional bareRepo = searchForBareRepositoryAlongPath(localGitRepo);
		if (bareRepo.isPresent()) {
			return bareRepo.get();
		}
		throw new RepositoryException(REPOSITORY_NOT_FOUND_EXCEPTION_MESSAGE + localGitRepo.getPath());
	}

	/**
	 * Retrieves an existing bare repository along the given path. If there is none,
	 * empty is returned.
	 */
	private static Optional searchForBareRepositoryAlongPath(File repoPath) {
		while (repoPath != null) {
			Repository repo = openBareRepository(repoPath);
			if (repo != null) {
				return Optional.of(repo);
			}
			repoPath = repoPath.getParentFile();
		}
		return Optional.empty();
	}

	/**
	 * Tries to open a bare repository at the given path. If it could not be opened
	 * null is returned.
	 */
	private static Repository openBareRepository(File repoPath) {
		if (!repoPath.exists()) {
			return null;
		}
		try {
			return Git.open(repoPath).getRepository();
		} catch (IOException e) {
			return null;
		}
	}

	/**
	 * Sets up a repository by cloning it. If a cloned version already exists it
	 * will be reused.
	 */
	public static Repository cloneAndSetUpRepository(File localDirectory, URI location,
			TeamscaleGitCredentialsProvider credentials) throws RepositoryException {
		if (localGitCloneExists(localDirectory)) {
			return GitUtils.getExistingRepository(localDirectory.toURI());
		}
		try {

			CloneCommand cloneCommand = Git.cloneRepository();
			configureCommand(location, credentials, cloneCommand);
			return cloneCommand.setBare(true).setCredentialsProvider(credentials).setURI(location.toString())
					.setTimeout(INITIAL_CLONE_OPERATION_TIMEOUT_SECONDS).setDirectory(localDirectory).call()
					.getRepository();
		} catch (GitAPIException e) {
			// if the cloning failed there is still an empty local repository
			// left which needs to be removed
			if (localDirectory.exists()) {
				FileSystemUtils.deleteRecursively(localDirectory);
			}
			throw new RepositoryException(e);
		}
	}

	/**
	 * Configures the given command by adding ssh credentials and configuring a
	 * timeeout.
	 */
	public static void configureCommand(URI location, TeamscaleGitCredentialsProvider credentials,
			TransportCommand command) {
		command.setTransportConfigCallback(transport -> {
			transport.setTimeout(REMOTE_OPERATION_TIMEOUT_SECONDS);
			if (transport instanceof SshTransport && hasSshPrivateKeyConfigured(credentials)) {
				SshTransport sshTransport = (SshTransport) transport;
				SshSessionFactory sshSessionFactory = new ApacheMinaSshSessionFactory(toURIish(location), credentials);
				sshTransport.setSshSessionFactory(sshSessionFactory);
			}
		});
	}

	private static boolean hasSshPrivateKeyConfigured(TeamscaleGitCredentialsProvider credentials) {
		return credentials != null && !StringUtils.isEmpty(credentials.getSshPrivateKey());
	}

	private static URIish toURIish(URI uri) {
		try {
			return new URIish(uri.toString());
		} catch (URISyntaxException e) {
			throw new IllegalArgumentException("Every URI should be an URIish", e);
		}
	}

	/** Checks whether a local git clone at the given location exists. */
	private static boolean localGitCloneExists(File localDir) {
		try {
			Git.open(localDir);
			return true;
		} catch (IOException e) {
			return false;
		}
	}

	/**
	 * Creates a user-name password credentials provider that may also keep an SSH
	 * private key.
	 * 

* Note: The SSH private key is not used automatically but must * be explicitly requested by calling * {@link #configureCommand(URI, TeamscaleGitCredentialsProvider, TransportCommand)} * prior to the Git command. */ public static TeamscaleGitCredentialsProvider createCredentialsProvider(String userName, String password, String sshPrivateKey) { return new TeamscaleGitCredentialsProvider(StringUtils.emptyIfNull(userName), StringUtils.emptyIfNull(password), sshPrivateKey); } /** Returns the commit denoted by the given commit id/tag/head. */ public static RevCommit getCommit(Repository repository, String revisionBranchOrTag) throws RepositoryException { try (RevWalk revWalk = new RevWalk(repository)) { Ref head = repository.findRef(revisionBranchOrTag); if (head != null) { return revWalk.parseCommit(head.getLeaf().getObjectId()); } return revWalk.parseCommit(ObjectId.fromString(revisionBranchOrTag)); } catch (IOException e) { throw new RepositoryException(e); } } /** * Returns the id of the object described by the given path in the given * revision. If no object is found an empty optional is returned. */ public static Optional getId(Repository repository, RevCommit commit, String path) throws RepositoryException { try (TreeWalk walk = TreeWalk.forPath(repository, path, commit.getTree())) { if (walk == null) { return Optional.empty(); } return Optional.of(walk.getObjectId(0)); } catch (IOException e) { throw new RepositoryException(e); } } /** * Creates a {@link DiffFormatter} to retrieve changes from the repository */ public static DiffFormatter createDiffFormatter(Repository repository) { DiffFormatter diffFormatter = new DiffFormatter(DisabledOutputStream.INSTANCE); diffFormatter.setRepository(repository); diffFormatter.setDiffComparator(RawTextComparator.DEFAULT); diffFormatter.setDetectRenames(true); return diffFormatter; } /** Creates a {@link TreeWalk} instance for the given repository */ public static TreeWalk createTreeWalk(Repository repository) { TreeWalk treeWalk = new TreeWalk(repository); treeWalk.setFilter(TreeFilter.ANY_DIFF); treeWalk.setRecursive(true); return treeWalk; } /** * Determine protocol used in given url. Returns null if url is * malformed. */ public static EGitProtocol getProtocolFromUrl(String url) { return EGitProtocol.fromUrl(url).orElse(null); } /** * Returns a timestamp for the given revision in the given repository. If there * is no commit with the given id empty is returned. */ public static Optional getTimestampFromRevision(Repository repository, String revision) { try (RevWalk revWalk = new RevWalk(repository)) { ObjectId commitId = repository.resolve(revision); RevCommit commit = revWalk.parseCommit(commitId); return Optional.of(commit.getCommitterIdent().getWhen().getTime()); } catch (IOException e) { return Optional.empty(); } } /** * Returns whether the given revision is a valid commit hash or HEAD reference. */ public static boolean isCommitHash(String revision) { return revision.equals(HEAD) || COMMIT_HASH_PATTERN.matcher(revision).matches(); } /** * Returns true, if and only if the branch name starts with the * {@link #ANONYMOUS_BRANCH_NAME}. */ public static boolean isAnonymousBranchName(String branchName) { return branchName != null && branchName.startsWith(ANONYMOUS_BRANCH_NAME); } /** * Basic credentials provider for the jgit library that automatically accepts * requests whether we trust a source. This must be done, as we have no * possibility to ask the user. Additionaly this class carries information about * a possible git ssh key that should be used, so that it can later be used for * configuration. */ public static final class TeamscaleGitCredentialsProvider extends UsernamePasswordCredentialsProvider { /** * The private key that should be used with these credentials. May be * null */ private final String sshPrivateKey; private TeamscaleGitCredentialsProvider(String username, String password, String sshPrivateKey) { super(username, password); this.sshPrivateKey = sshPrivateKey; } @Override public boolean supports(CredentialItem... items) { for (CredentialItem item : items) { if (item instanceof CredentialItem.YesNoType) { continue; } if (!super.supports(item)) { return false; } } return true; } @Override public boolean get(URIish uri, CredentialItem... items) throws UnsupportedCredentialItem { for (CredentialItem item : items) { if (item instanceof CredentialItem.YesNoType) { // Always answer the question about whether we trust a source to true, we cannot // ask the user. ((CredentialItem.YesNoType) item).setValue(true); continue; } super.get(uri, item); } return true; } /** @see #sshPrivateKey */ public String getSshPrivateKey() { return sshPrivateKey; } } /** * Because the git library does not support the git@ url format, we fix it by * converting it to a plain ssh url */ public static String rewriteGitAtUrl(String url) { if (url.startsWith("git@")) { return "ssh://" + url.replaceFirst(":", "/"); } return url; } /** * Determines the change type for a given entry. This method respects the mode * flags for the old and new file (if present) and uses them to override the * stored mode. For example, changing a symlink to a plain file would be treated * as an {@link ChangeType#ADD}. Returns {@link Optional#empty()} if the entry * was and still is not an actual file and should hence be ignored entirely. */ public static Optional determineChangeType(DiffEntry diff) { boolean wasActualFile = isActualFile(diff.getOldMode()); boolean isActualFile = isActualFile(diff.getNewMode()); if (wasActualFile && isActualFile) { return Optional.of(diff.getChangeType()); } else if (!wasActualFile && isActualFile) { return Optional.of(ChangeType.ADD); } else if (wasActualFile && !isActualFile) { return Optional.of(ChangeType.DELETE); } return Optional.empty(); } /** * Returns whether a "file" with given file mode is an actual file and not, * e.g., a symlink which is not tracked by Teamscale. */ private static boolean isActualFile(FileMode mode) { return isActualFile(mode.getBits()); } /** * Returns whether a "file" with the given mode bits is an actual file and not, * e.g., a symlink which is not tracked by Teamscale. */ public static boolean isActualFile(int modeBits) { return FileMode.REGULAR_FILE.equals(modeBits) || FileMode.EXECUTABLE_FILE.equals(modeBits); } /** Returns a fetch command for updating a local git repository. */ public static FetchCommand createFetchCommand(Git git, URI location, TeamscaleGitCredentialsProvider credentialsProvider) { FetchCommand fetchCommand = git.fetch().setTimeout(GitUtils.REMOTE_OPERATION_TIMEOUT_SECONDS) .setCredentialsProvider(credentialsProvider); GitUtils.configureCommand(location, credentialsProvider, fetchCommand); return fetchCommand; } }