/* * Copyright 2013-2019 the original author or authors. * * 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 * * https://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 releaser.internal.git; import java.io.File; import java.io.FileNotFoundException; import java.io.IOException; import java.util.Collections; import java.util.Comparator; import java.util.Date; import java.util.LinkedList; import java.util.List; import java.util.Optional; import java.util.stream.Stream; import com.jcraft.jsch.IdentityRepository; import com.jcraft.jsch.JSch; import com.jcraft.jsch.JSchException; import com.jcraft.jsch.Session; import com.jcraft.jsch.agentproxy.AgentProxyException; import com.jcraft.jsch.agentproxy.Connector; import com.jcraft.jsch.agentproxy.RemoteIdentityRepository; import com.jcraft.jsch.agentproxy.USocketFactory; import com.jcraft.jsch.agentproxy.connector.SSHAgentConnector; import com.jcraft.jsch.agentproxy.usocket.JNAUSocketFactory; import org.eclipse.jgit.api.CheckoutCommand; import org.eclipse.jgit.api.CloneCommand; import org.eclipse.jgit.api.CreateBranchCommand; import org.eclipse.jgit.api.FetchCommand; import org.eclipse.jgit.api.Git; import org.eclipse.jgit.api.ListBranchCommand; import org.eclipse.jgit.api.PushCommand; import org.eclipse.jgit.api.ResetCommand; import org.eclipse.jgit.api.TransportConfigCallback; import org.eclipse.jgit.api.errors.EmptyCommitException; import org.eclipse.jgit.api.errors.GitAPIException; import org.eclipse.jgit.lib.Constants; 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.CredentialsProvider; import org.eclipse.jgit.transport.FetchResult; import org.eclipse.jgit.transport.JschConfigSessionFactory; import org.eclipse.jgit.transport.OpenSshConfig; import org.eclipse.jgit.transport.RefSpec; import org.eclipse.jgit.transport.SshTransport; import org.eclipse.jgit.transport.URIish; import org.eclipse.jgit.transport.UsernamePasswordCredentialsProvider; import org.eclipse.jgit.util.FS; import org.eclipse.jgit.util.FileUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import releaser.internal.ReleaserProperties; import org.springframework.util.ResourceUtils; import org.springframework.util.StringUtils; /** * Abstraction over a Git repo. Can cloned repo from a given location and check its * branch. * * @author Marcin Grzejszczak */ class GitRepo { private static final Logger log = LoggerFactory.getLogger(GitRepo.class); private final GitRepo.JGitFactory gitFactory; private final File basedir; GitRepo(File basedir, ReleaserProperties properties) { this.basedir = basedir; this.gitFactory = new GitRepo.JGitFactory(properties); } // for tests GitRepo(File basedir) { this.basedir = basedir; this.gitFactory = new GitRepo.JGitFactory(); } // for tests GitRepo(File basedir, GitRepo.JGitFactory factory) { this.basedir = basedir; this.gitFactory = factory; } /** * Clones the project. * @param projectUri - URI of the project * @return file where the project was cloned */ File cloneProject(URIish projectUri) { try { log.info("Cloning repo from [{}] to [{}]", projectUri, humanishDestination(projectUri, this.basedir)); Git git = cloneToBasedir(projectUri, this.basedir); if (git != null) { git.close(); } File clonedRepo = git.getRepository().getWorkTree(); log.info("Cloned repo to [{}]", clonedRepo); return clonedRepo; } catch (Exception e) { throw new IllegalStateException("Exception occurred while cloning repo", e); } } /** * Checks out a branch for a project. * @param branch - branch to check out */ void checkout(String branch) { try { log.info("Checking out branch [{}] for repo [{}]", branch, this.basedir); checkoutBranch(this.basedir, branch); log.info("Successfully checked out the branch [{}]", branch); } catch (Exception e) { throw new IllegalStateException(e); } } /** * Fetch changes. */ void fetch() { try { log.info("Pull changes for repo [{}]", this.basedir); fetch(this.basedir); log.info("Successfully pulled the changes"); } catch (Exception e) { throw new IllegalStateException(e); } } /** * Reset changes. */ void reset() { try { log.info("Resetting changes for repo [{}]", this.basedir); reset(this.basedir); log.info("Successfully reset any changes"); } catch (Exception e) { throw new IllegalStateException(e); } } /** * Performs a commit. * @param message - commit message */ void commit(String message) { try (Git git = this.gitFactory.open(file(this.basedir))) { git.add().addFilepattern(".").call(); git.commit().setAllowEmpty(false).setMessage(message).call(); printLog(git); } catch (EmptyCommitException e) { log.info("There were no changes detected. Will not commit an empty commit"); } catch (Exception e) { throw new IllegalStateException(e); } } /** * Attempt to retrieve a tag id from a name, prepending the name with /refs/tags/. */ Optional<ObjectId> findTagIdByName(String tagName, boolean unpeel) { try (Git git = this.gitFactory.open(file(this.basedir))) { return git.tagList().call().stream() .filter(ref -> ref.getName().equals("refs/tags/" + tagName)) .findFirst().map(ref -> { if (ref.isPeeled() && unpeel) { return ref.getPeeledObjectId(); } return ref.getObjectId(); }); } catch (Exception e) { throw new IllegalStateException( "Unable to fetch git tag id for refs/tags/" + tagName, e); } } /** * Logs {@link RevCommit} between two tags / branches / hashes. * @param from oldest revision * @param to newest revision */ List<RevCommit> log(String from, String to) { try (Git git = this.gitFactory.open(file(this.basedir))) { final Optional<Ref> fromRevisionOptional = findTagOrBranchHeadRevision(git, from); final Optional<Ref> toRevisionOptional = findTagOrBranchHeadRevision(git, to); ObjectId fromRevision; if (fromRevisionOptional.isPresent()) { Ref ref = fromRevisionOptional.get(); if (ref.isPeeled()) { fromRevision = ref.getPeeledObjectId(); } else { fromRevision = ref.getObjectId(); } } else { fromRevision = ObjectId.fromString(from); } ObjectId toRevision; if (toRevisionOptional.isPresent()) { Ref ref = toRevisionOptional.get(); if (ref.isPeeled()) { toRevision = ref.getPeeledObjectId(); } else { toRevision = ref.getObjectId(); } } else { toRevision = ObjectId.fromString(to); } LinkedList<RevCommit> commits = new LinkedList<>(); git.log().addRange(fromRevision, toRevision).call().forEach(commits::add); return commits; } catch (Exception e) { throw new IllegalStateException( "Unable to fetch git log for " + from + ".." + to, e); } } /** * List all the tags in the repository. * @return a {@link List} of all the tags in the repository. */ Stream<String> listTags() { try (Git git = this.gitFactory.open(file(this.basedir))) { final RevWalk walk = new RevWalk(git.getRepository()); List<Ref> allTagsNewestFirst = git.tagList().call(); Collections.sort(allTagsNewestFirst, (Comparator<Ref>) (o1, o2) -> { Date d1; Date d2; try { d1 = walk.parseTag(o1.getObjectId()).getTaggerIdent().getWhen(); } catch (IOException ioe) { return 1; // put at end } try { d2 = walk.parseTag(o2.getObjectId()).getTaggerIdent().getWhen(); } catch (IOException ioe) { return -1; // put ahead } return d2.compareTo(d1); // more recent first }); return allTagsNewestFirst.stream() .map(ref -> Repository.shortenRefName(ref.getName())); } catch (Exception e) { throw new IllegalStateException("Unable to fetch git tags", e); } } /** * Look for a tag with the given name, and if not found looks for a branch. */ private Optional<Ref> findTagOrBranchHeadRevision(Git git, String tagOrBranch) throws GitAPIException, IOException { if (tagOrBranch.equals("HEAD")) { return Optional.of(git.getRepository().exactRef(Constants.HEAD).getTarget()); } final Optional<Ref> tag = git.tagList().call().stream() .filter(ref -> ref.getName().equals("refs/tags/" + tagOrBranch)) .findFirst(); if (tag.isPresent()) { return tag; } return git.branchList().setListMode(ListBranchCommand.ListMode.ALL).call() .stream().filter(ref -> ref.getName().equals("refs/heads/" + tagOrBranch)) .findFirst(); } boolean hasBranch(String branch) { try (Git git = this.gitFactory.open(file(this.basedir))) { List<Ref> refs = git.branchList().setListMode(ListBranchCommand.ListMode.ALL) .call(); boolean present = refs.stream() .anyMatch(ref -> branch.equals(nameOfBranch(ref.getName()))); if (log.isDebugEnabled()) { log.debug("Branch [{}] is present [{}]", branch, present); } return present; } catch (Exception e) { throw new IllegalStateException(e); } } private String nameOfBranch(String branch) { // TODO careful: this doesn't take into account branches that follow a pattern // like `experiments/foo` return branch.substring(branch.lastIndexOf("/") + 1); } private void printLog(Git git) throws GitAPIException, IOException { int maxCount = 5; String currentBranch = git.getRepository().getBranch(); log.info("Printing [{}] last commits for branch [{}]", maxCount, currentBranch); Iterable<RevCommit> commits = git.log().setMaxCount(maxCount).call(); for (RevCommit commit : commits) { log.info("Name [{}], msg [{}]", commit.getId().name(), commit.getShortMessage()); } } /** * Creates a tag with a given name. * @param tagName name of the tag to set */ void tag(String tagName) { try (Git git = this.gitFactory.open(file(this.basedir))) { git.tag().setName(tagName).call(); } catch (Exception e) { throw new IllegalStateException(e); } } /** * Pushes the commits to {@code origin} remote branch. * @param branch - remote branch to which the code should be pushed */ void pushBranch(String branch) { try (Git git = this.gitFactory.open(file(this.basedir))) { String localBranch = git.getRepository().getFullBranch(); RefSpec refSpec = new RefSpec(localBranch + ":" + branch); this.gitFactory.push(git).setPushTags().setRefSpecs(refSpec).call(); } catch (Exception e) { throw new IllegalStateException(e); } } /** * Pushes the commits od current branch. */ void pushCurrentBranch() { try (Git git = this.gitFactory.open(file(this.basedir))) { this.gitFactory.push(git).call(); } catch (Exception e) { throw new IllegalStateException(e); } } /** * Pushes the commits to {@code origin} remote tag. * @param tagName - remote tag to which the code should be pushed */ void pushTag(String tagName) { try (Git git = this.gitFactory.open(file(this.basedir))) { String localBranch = git.getRepository().getFullBranch(); RefSpec refSpec = new RefSpec(localBranch + ":" + "refs/tags/" + tagName); this.gitFactory.push(git).setPushTags().setRefSpecs(refSpec).call(); } catch (Exception e) { throw new IllegalStateException(e); } } void revert(String message) { try (Git git = this.gitFactory.open(file(this.basedir))) { RevCommit commit = git.log().setMaxCount(1).call().iterator().next(); String shortMessage = commit.getShortMessage(); String id = commit.getId().getName(); if (!shortMessage.contains("Update SNAPSHOT to ")) { throw new IllegalStateException( "Won't revert the commit with id [" + id + "] " + "and message [" + shortMessage + "]. Only commit that updated " + "snapshot to another version can be reverted"); } log.debug("The commit to be reverted is [{}]", commit); git.revert().include(commit).call(); git.commit().setAmend(true).setMessage(message).call(); printLog(git); } catch (Exception e) { throw new IllegalStateException(e); } } String currentBranch() { try (Git git = this.gitFactory.open(file(this.basedir))) { return git.getRepository().getBranch(); } catch (Exception e) { throw new IllegalStateException(e); } } private File file(File project) throws FileNotFoundException { return ResourceUtils.getFile(project.toURI()).getAbsoluteFile(); } private Git cloneToBasedir(URIish projectUrl, File destinationFolder) throws GitAPIException { CloneCommand command = this.gitFactory.getCloneCommandByCloneRepository() .setURI(projectUrl.toString() + ".git") .setDirectory(humanishDestination(projectUrl, destinationFolder)); try { return command.call(); } catch (GitAPIException e) { deleteBaseDirIfExists(); throw e; } } private File humanishDestination(URIish projectUrl, File destinationFolder) { return new File(destinationFolder, projectUrl.getHumanishName()); } private FetchResult fetch(File projectDir) throws GitAPIException { Git git = this.gitFactory.open(projectDir); FetchCommand command = git.fetch(); try { return command.call(); } catch (GitAPIException e) { deleteBaseDirIfExists(); throw e; } finally { git.close(); } } private Ref reset(File projectDir) throws GitAPIException { Git git = this.gitFactory.open(projectDir); ResetCommand command = git.reset().setMode(ResetCommand.ResetType.HARD); try { return command.call(); } catch (GitAPIException e) { deleteBaseDirIfExists(); throw e; } finally { git.close(); } } private Ref checkoutBranch(File projectDir, String branch) throws GitAPIException { Git git = this.gitFactory.open(projectDir); CheckoutCommand command = git.checkout().setName(branch); try { if (shouldTrack(git, branch)) { trackBranch(command, branch); } return command.call(); } catch (GitAPIException e) { deleteBaseDirIfExists(); throw e; } finally { git.close(); } } private boolean shouldTrack(Git git, String label) throws GitAPIException { return isBranch(git, label) && !isLocalBranch(git, label); } private void trackBranch(CheckoutCommand checkout, String label) { checkout.setCreateBranch(true).setName(label) .setUpstreamMode(CreateBranchCommand.SetupUpstreamMode.TRACK) .setStartPoint("origin/" + label); } private boolean isBranch(Git git, String label) throws GitAPIException { return containsBranch(git, label, ListBranchCommand.ListMode.ALL); } private boolean isLocalBranch(Git git, String label) throws GitAPIException { return containsBranch(git, label, null); } private boolean containsBranch(Git git, String label, ListBranchCommand.ListMode listMode) throws GitAPIException { ListBranchCommand command = git.branchList(); if (listMode != null) { command.setListMode(listMode); } List<Ref> branches = command.call(); for (Ref ref : branches) { if (ref.getName().endsWith("/" + label)) { return true; } } return false; } private void deleteBaseDirIfExists() { if (this.basedir.exists()) { try { FileUtils.delete(this.basedir, FileUtils.RECURSIVE); } catch (IOException e) { throw new IllegalStateException("Failed to initialize base directory", e); } } } /** * Wraps the static method calls to {@link org.eclipse.jgit.api.Git} and * {@link org.eclipse.jgit.api.CloneCommand} allowing for easier unit testing. */ static class JGitFactory { private static final Logger log = LoggerFactory.getLogger(JGitFactory.class); private final JschConfigSessionFactory factory = new JschConfigSessionFactory() { @Override protected void configure(OpenSshConfig.Host host, Session session) { } @Override protected JSch createDefaultJSch(FS fs) throws JSchException { Connector connector = null; try { if (SSHAgentConnector.isConnectorAvailable()) { USocketFactory usf = new JNAUSocketFactory(); connector = new SSHAgentConnector(usf); } log.info("Successfully connected to an agent"); } catch (AgentProxyException e) { log.error( "Exception occurred while trying to connect to agent. Will create" + "the default JSch connection", e); return super.createDefaultJSch(fs); } final JSch jsch = super.createDefaultJSch(fs); if (connector != null) { JSch.setConfig("PreferredAuthentications", "publickey,password"); IdentityRepository identityRepository = new RemoteIdentityRepository( connector); jsch.setIdentityRepository(identityRepository); } return jsch; } }; private final CredentialsProvider provider; private final TransportConfigCallback callback = transport -> { if (transport instanceof SshTransport) { SshTransport sshTransport = (SshTransport) transport; sshTransport.setSshSessionFactory(this.factory); } }; JGitFactory(ReleaserProperties releaserProperties) { if (StringUtils.hasText(releaserProperties.getGit().getUsername())) { log.info( "Passed username and password - will set a custom credentials provider"); this.provider = credentialsProvider(releaserProperties); } else { log.info("No custom credentials provider will be set"); this.provider = null; } } // for tests JGitFactory() { this.provider = null; } CredentialsProvider credentialsProvider(ReleaserProperties properties) { return new UsernamePasswordCredentialsProvider( properties.getGit().getUsername(), properties.getGit().getPassword()); } CloneCommand getCloneCommandByCloneRepository() { return Git.cloneRepository().setCredentialsProvider(this.provider) .setTransportConfigCallback(this.callback); } PushCommand push(Git git) { return git.push().setCredentialsProvider(this.provider) .setTransportConfigCallback(this.callback); } Git open(File file) { try { return Git.open(file); } catch (IOException e) { throw new IllegalStateException(e); } } Git init(File file) { try { return Git.init().setDirectory(file).call(); } catch (Exception e) { throw new IllegalStateException(e); } } } }