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-rc2
Show newest version
/*
 * Copyright (c) CQSE GmbH
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

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.LsRemoteCommand;
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 {

	/**
	 * The timeout (number of seconds), used e.g. for the initial clone of the repo.
	 * Can be overwritten using the mentioned JVM flag. The default is 5 minutes.
	 */
	private static final int GIT_OPERATIONS_TIMEOUT_SECONDS = Integer.getInteger("com.teamscale.git-timeout-secs",
			5 * 60);

	/** 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}$");

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

	/**
	 * The Git {@link Ref#getName()} for the HEAD {@link Ref}. Note that it's very
	 * important to distinguish the meaning of this {@link Ref} for different kinds
	 * of Git repositories when determining the default branch (i.e. branch checked
	 * out upon cloning the repository).
*
* For a regular local Git clone, which doesn't use the {@code --bare} or * {@code --mirror} command line flags, the HEAD {@link Ref} points to the * revision of the currently checked out branch, tag or specific revision.
*
* For a Git checkout with the command line flags {@code --bare} or * {@code --mirror} the HEAD points to the revision of the default branch.
*
* If {@link Ref}s are loaded from a remote Git using the JGit * {@link LsRemoteCommand} the (remote) HEAD points to the revision of the * default branch.
*
* However, for any of the different kinds of Git repositories there may be * multiple branch head {@link Ref}s, pointing to the same revision as the HEAD * {@link Ref}. Hence the {@link Ref} for the default branch isn't always * uniquely defined and there may be multiple options for choosing it for a Git * repository.
*
* There exists a Git capability (see * StackOverFlow, Official * Github Release Notes) for determining the branch head {@link Ref} to * which the remote HEAD also points. However JGit doesn't support this * capability and we're currently limited to guessing the default branch names. */ public static final String HEAD_REF_NAME = "HEAD"; /** Prefix for the {@link Ref#getName()} for Git branch heads. */ private static final String BRANCH_HEAD_REF_PREFIX = "refs/heads/"; /** * 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(GIT_OPERATIONS_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 * timeout. */ public static void configureCommand(URI location, TeamscaleGitCredentialsProvider credentials, TransportCommand command) { command.setTransportConfigCallback(transport -> { transport.setTimeout(GIT_OPERATIONS_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); } /** * Creates a credentials provider without username, password, and SSH private * key. */ public static TeamscaleGitCredentialsProvider createEmptyCredentialsProvider() { return new TeamscaleGitCredentialsProvider(StringUtils.EMPTY_STRING, StringUtils.EMPTY_STRING, StringUtils.EMPTY_STRING); } /** 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); if (commitId != null) { RevCommit commit = revWalk.parseCommit(commitId); return Optional.of(getCommitTimestamp(commit)); } return Optional.empty(); } catch (IOException e) { return Optional.empty(); } } /** Returns the commit timestamp for a commit. */ public static long getCommitTimestamp(RevCommit commit) { return commit.getCommitterIdent().getWhen().getTime(); } /** * Returns whether the given revision is a valid commit hash or the HEAD ref. * * @see #HEAD_REF_NAME */ public static boolean isCommitHashOrHeadRef(String revision) { return revision.equals(HEAD_REF_NAME) || 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) { 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(GIT_OPERATIONS_TIMEOUT_SECONDS) .setCredentialsProvider(credentialsProvider); GitUtils.configureCommand(location, credentialsProvider, fetchCommand); return fetchCommand; } /** * Returns true if the {@link Ref} is the HEAD reference. * * @see #HEAD_REF_NAME */ public static boolean isHeadRef(Ref ref) { return HEAD_REF_NAME.equals(ref.getName()); } /** * Returns true if this is a {@link Ref} for a branch head. * * @see #BRANCH_HEAD_REF_PREFIX */ public static boolean isBranchHeadRef(Ref ref) { return ref.getName().startsWith(BRANCH_HEAD_REF_PREFIX); } /** Creates the name of a {@link Ref} for a branch name. */ public static String createBranchHeadRefName(String branchName) { return BRANCH_HEAD_REF_PREFIX + branchName; } /** * Returns the branch name for a {@link Ref} or {@link Optional#empty()} if the * it isn't a branch head {@link Ref}. * * @see #isBranchHeadRef(Ref) * @see #BRANCH_HEAD_REF_PREFIX */ public static Optional getBranchNameFromRef(Ref ref) { if (!isBranchHeadRef(ref)) { return Optional.empty(); } return Optional.of(StringUtils.stripPrefix(ref.getName(), BRANCH_HEAD_REF_PREFIX)); } }