org.conqat.engine.index.shared.GitUtils Maven / Gradle / Ivy
Show all versions of teamscale-commons Show documentation
/*
* 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));
}
}