001/*-----------------------------------------------------------------------+
002 | com.teamscale.index
003 |                                                                       |
004   $Id$
005 |                                                                       |
006 | Copyright (c)  2009-2013 CQSE GmbH                                 |
007 +-----------------------------------------------------------------------*/
008package org.conqat.engine.index.shared;
009
010import java.io.File;
011import java.io.IOException;
012import java.net.URI;
013import java.net.URISyntaxException;
014import java.util.Optional;
015import java.util.regex.Pattern;
016
017import org.conqat.lib.commons.filesystem.FileSystemUtils;
018import org.conqat.lib.commons.string.StringUtils;
019import org.eclipse.jgit.api.CloneCommand;
020import org.eclipse.jgit.api.FetchCommand;
021import org.eclipse.jgit.api.Git;
022import org.eclipse.jgit.api.TransportCommand;
023import org.eclipse.jgit.api.errors.GitAPIException;
024import org.eclipse.jgit.diff.DiffEntry;
025import org.eclipse.jgit.diff.DiffEntry.ChangeType;
026import org.eclipse.jgit.diff.DiffFormatter;
027import org.eclipse.jgit.diff.RawTextComparator;
028import org.eclipse.jgit.errors.UnsupportedCredentialItem;
029import org.eclipse.jgit.lib.FileMode;
030import org.eclipse.jgit.lib.ObjectId;
031import org.eclipse.jgit.lib.Ref;
032import org.eclipse.jgit.lib.Repository;
033import org.eclipse.jgit.revwalk.RevCommit;
034import org.eclipse.jgit.revwalk.RevWalk;
035import org.eclipse.jgit.transport.CredentialItem;
036import org.eclipse.jgit.transport.SshSessionFactory;
037import org.eclipse.jgit.transport.SshTransport;
038import org.eclipse.jgit.transport.URIish;
039import org.eclipse.jgit.transport.UsernamePasswordCredentialsProvider;
040import org.eclipse.jgit.treewalk.TreeWalk;
041import org.eclipse.jgit.treewalk.filter.TreeFilter;
042import org.eclipse.jgit.util.io.DisabledOutputStream;
043
044/**
045 * Utility methods for working with a Git repository.
046 */
047public class GitUtils {
048
049        /** Timeout in number of seconds, used for general remote git operations. */
050        public static final int REMOTE_OPERATION_TIMEOUT_SECONDS = 60;
051
052        /** Timeout in number of seconds, used for the initial clone of the repo. */
053        private static final int INITIAL_CLONE_OPERATION_TIMEOUT_SECONDS = 300;
054
055        /** Exception message when a repository is not found. */
056        private static final String REPOSITORY_NOT_FOUND_EXCEPTION_MESSAGE = "Can neither find a bare nor a cloned git repository along the path: ";
057
058        /** Name of the master branch. */
059        public static final String MASTER_BRANCH = "master";
060
061        /** Separator used for paths returned via the API. */
062        public static final String SEPARATOR = "/";
063
064        /** Pattern for a SHA-1 hash which identifies a Git commit uniquely. */
065        private static final Pattern COMMIT_HASH_PATTERN = Pattern.compile("^[0-9a-f]{40}$");
066
067        /** Git HEAD ref. */
068        public static final String HEAD = "HEAD";
069
070        /** Base name for anonymous branches. */
071        public static final String ANONYMOUS_BRANCH_NAME = "_anonymous_";
072
073        /**
074         * Tries to find an existing bare or cloned repository along the given URI (with
075         * "file" scheme).
076         * 
077         * @throws RepositoryException
078         *             if no repository could be found
079         */
080        public static Repository getExistingRepository(URI localGitRepoURI) throws RepositoryException {
081                File localGitRepo = new File(localGitRepoURI);
082                try {
083                        return Git.open(localGitRepo).getRepository();
084                } catch (IOException e) {
085                        // Could not find git, try harder below
086                }
087
088                Optional<Repository> bareRepo = searchForBareRepositoryAlongPath(localGitRepo);
089                if (bareRepo.isPresent()) {
090                        return bareRepo.get();
091                }
092                throw new RepositoryException(REPOSITORY_NOT_FOUND_EXCEPTION_MESSAGE + localGitRepo.getPath());
093        }
094
095        /**
096         * Retrieves an existing bare repository along the given path. If there is none,
097         * empty is returned.
098         */
099        private static Optional<Repository> searchForBareRepositoryAlongPath(File repoPath) {
100                while (repoPath != null) {
101                        Repository repo = openBareRepository(repoPath);
102                        if (repo != null) {
103                                return Optional.of(repo);
104                        }
105                        repoPath = repoPath.getParentFile();
106                }
107                return Optional.empty();
108        }
109
110        /**
111         * Tries to open a bare repository at the given path. If it could not be opened
112         * <code>null</code> is returned.
113         */
114        private static Repository openBareRepository(File repoPath) {
115                if (!repoPath.exists()) {
116                        return null;
117                }
118                try {
119                        return Git.open(repoPath).getRepository();
120                } catch (IOException e) {
121                        return null;
122                }
123        }
124
125        /**
126         * Sets up a repository by cloning it. If a cloned version already exists it
127         * will be reused.
128         */
129        public static Repository cloneAndSetUpRepository(File localDirectory, URI location,
130                        TeamscaleGitCredentialsProvider credentials) throws RepositoryException {
131                if (localGitCloneExists(localDirectory)) {
132                        return GitUtils.getExistingRepository(localDirectory.toURI());
133                }
134                try {
135
136                        CloneCommand cloneCommand = Git.cloneRepository();
137                        configureCommand(location, credentials, cloneCommand);
138                        return cloneCommand.setBare(true).setCredentialsProvider(credentials).setURI(location.toString())
139                                        .setTimeout(INITIAL_CLONE_OPERATION_TIMEOUT_SECONDS).setDirectory(localDirectory).call()
140                                        .getRepository();
141                } catch (GitAPIException e) {
142                        // if the cloning failed there is still an empty local repository
143                        // left which needs to be removed
144                        if (localDirectory.exists()) {
145                                FileSystemUtils.deleteRecursively(localDirectory);
146                        }
147                        throw new RepositoryException(e);
148                }
149        }
150
151        /**
152         * Configures the given command by adding ssh credentials and configuring a
153         * timeeout.
154         */
155        public static void configureCommand(URI location, TeamscaleGitCredentialsProvider credentials,
156                        TransportCommand<?, ?> command) {
157                command.setTransportConfigCallback(transport -> {
158                        transport.setTimeout(REMOTE_OPERATION_TIMEOUT_SECONDS);
159                        if (transport instanceof SshTransport && hasSshPrivateKeyConfigured(credentials)) {
160                                SshTransport sshTransport = (SshTransport) transport;
161                                SshSessionFactory sshSessionFactory = new ApacheMinaSshSessionFactory(toURIish(location), credentials);
162                                sshTransport.setSshSessionFactory(sshSessionFactory);
163                        }
164                });
165        }
166
167        private static boolean hasSshPrivateKeyConfigured(TeamscaleGitCredentialsProvider credentials) {
168                return credentials != null && !StringUtils.isEmpty(credentials.getSshPrivateKey());
169        }
170
171        private static URIish toURIish(URI uri) {
172                try {
173                        return new URIish(uri.toString());
174                } catch (URISyntaxException e) {
175                        throw new IllegalArgumentException("Every URI should be an URIish", e);
176                }
177        }
178
179        /** Checks whether a local git clone at the given location exists. */
180        private static boolean localGitCloneExists(File localDir) {
181                try {
182                        Git.open(localDir);
183                        return true;
184                } catch (IOException e) {
185                        return false;
186                }
187        }
188
189        /**
190         * Creates a user-name password credentials provider that may also keep an SSH
191         * private key.
192         * <p>
193         * <strong>Note:</strong> The SSH private key is not used automatically but must
194         * be explicitly requested by calling
195         * {@link #configureCommand(URI, TeamscaleGitCredentialsProvider, TransportCommand)}
196         * prior to the Git command.
197         */
198        public static TeamscaleGitCredentialsProvider createCredentialsProvider(String userName, String password,
199                        String sshPrivateKey) {
200                return new TeamscaleGitCredentialsProvider(StringUtils.emptyIfNull(userName), StringUtils.emptyIfNull(password),
201                                sshPrivateKey);
202        }
203
204        /** Returns the commit denoted by the given commit id/tag/head. */
205        public static RevCommit getCommit(Repository repository, String revisionBranchOrTag) throws RepositoryException {
206                try (RevWalk revWalk = new RevWalk(repository)) {
207                        Ref head = repository.findRef(revisionBranchOrTag);
208                        if (head != null) {
209                                return revWalk.parseCommit(head.getLeaf().getObjectId());
210                        }
211                        return revWalk.parseCommit(ObjectId.fromString(revisionBranchOrTag));
212                } catch (IOException e) {
213                        throw new RepositoryException(e);
214                }
215        }
216
217        /**
218         * Returns the id of the object described by the given path in the given
219         * revision. If no object is found an empty optional is returned.
220         */
221        public static Optional<ObjectId> getId(Repository repository, RevCommit commit, String path)
222                        throws RepositoryException {
223                try (TreeWalk walk = TreeWalk.forPath(repository, path, commit.getTree())) {
224
225                        if (walk == null) {
226                                return Optional.empty();
227                        }
228
229                        return Optional.of(walk.getObjectId(0));
230                } catch (IOException e) {
231                        throw new RepositoryException(e);
232                }
233        }
234
235        /**
236         * Creates a {@link DiffFormatter} to retrieve changes from the repository
237         */
238        public static DiffFormatter createDiffFormatter(Repository repository) {
239                DiffFormatter diffFormatter = new DiffFormatter(DisabledOutputStream.INSTANCE);
240                diffFormatter.setRepository(repository);
241                diffFormatter.setDiffComparator(RawTextComparator.DEFAULT);
242                diffFormatter.setDetectRenames(true);
243                return diffFormatter;
244        }
245
246        /** Creates a {@link TreeWalk} instance for the given repository */
247        public static TreeWalk createTreeWalk(Repository repository) {
248                TreeWalk treeWalk = new TreeWalk(repository);
249                treeWalk.setFilter(TreeFilter.ANY_DIFF);
250                treeWalk.setRecursive(true);
251                return treeWalk;
252        }
253
254        /**
255         * Determine protocol used in given url. Returns <code>null<code> if url is
256         * malformed.
257         */
258        public static EGitProtocol getProtocolFromUrl(String url) {
259                return EGitProtocol.fromUrl(url).orElse(null);
260        }
261
262        /**
263         * Returns a timestamp for the given revision in the given repository. If there
264         * is no commit with the given id empty is returned.
265         */
266        public static Optional<Long> getTimestampFromRevision(Repository repository, String revision) {
267                try (RevWalk revWalk = new RevWalk(repository)) {
268                        ObjectId commitId = repository.resolve(revision);
269                        RevCommit commit = revWalk.parseCommit(commitId);
270                        return Optional.of(commit.getCommitterIdent().getWhen().getTime());
271                } catch (IOException e) {
272                        return Optional.empty();
273                }
274        }
275
276        /**
277         * Returns whether the given revision is a valid commit hash or HEAD reference.
278         */
279        public static boolean isCommitHash(String revision) {
280                return revision.equals(HEAD) || COMMIT_HASH_PATTERN.matcher(revision).matches();
281        }
282
283        /**
284         * Returns true, if and only if the branch name starts with the
285         * {@link #ANONYMOUS_BRANCH_NAME}.
286         */
287        public static boolean isAnonymousBranchName(String branchName) {
288                return branchName != null && branchName.startsWith(ANONYMOUS_BRANCH_NAME);
289        }
290
291        /**
292         * Basic credentials provider for the jgit library that automatically accepts
293         * requests whether we trust a source. This must be done, as we have no
294         * possibility to ask the user. Additionaly this class carries information about
295         * a possible git ssh key that should be used, so that it can later be used for
296         * configuration.
297         */
298        public static final class TeamscaleGitCredentialsProvider extends UsernamePasswordCredentialsProvider {
299
300                /**
301                 * The private key that should be used with these credentials. May be
302                 * <code>null</code>
303                 */
304                private final String sshPrivateKey;
305
306                private TeamscaleGitCredentialsProvider(String username, String password, String sshPrivateKey) {
307                        super(username, password);
308                        this.sshPrivateKey = sshPrivateKey;
309                }
310
311                @Override
312                public boolean supports(CredentialItem... items) {
313                        for (CredentialItem item : items) {
314                                if (item instanceof CredentialItem.YesNoType) {
315                                        continue;
316                                }
317                                if (!super.supports(item)) {
318                                        return false;
319                                }
320                        }
321                        return true;
322                }
323
324                @Override
325                public boolean get(URIish uri, CredentialItem... items) throws UnsupportedCredentialItem {
326                        for (CredentialItem item : items) {
327                                if (item instanceof CredentialItem.YesNoType) {
328                                        // Always answer the question about whether we trust a source to true, we cannot
329                                        // ask the user.
330                                        ((CredentialItem.YesNoType) item).setValue(true);
331                                        continue;
332                                }
333                                super.get(uri, item);
334                        }
335                        return true;
336                }
337
338                /** @see #sshPrivateKey */
339                public String getSshPrivateKey() {
340                        return sshPrivateKey;
341                }
342        }
343
344        /**
345         * Because the git library does not support the git@ url format, we fix it by
346         * converting it to a plain ssh url
347         */
348        public static String rewriteGitAtUrl(String url) {
349                if (url.startsWith("git@")) {
350                        return "ssh://" + url.replaceFirst(":", "/");
351                }
352                return url;
353        }
354
355        /**
356         * Determines the change type for a given entry. This method respects the mode
357         * flags for the old and new file (if present) and uses them to override the
358         * stored mode. For example, changing a symlink to a plain file would be treated
359         * as an {@link ChangeType#ADD}. Returns {@link Optional#empty()} if the entry
360         * was and still is not an actual file and should hence be ignored entirely.
361         */
362        public static Optional<ChangeType> determineChangeType(DiffEntry diff) {
363                boolean wasActualFile = isActualFile(diff.getOldMode());
364                boolean isActualFile = isActualFile(diff.getNewMode());
365
366                if (wasActualFile && isActualFile) {
367                        return Optional.of(diff.getChangeType());
368                } else if (!wasActualFile && isActualFile) {
369                        return Optional.of(ChangeType.ADD);
370                } else if (wasActualFile && !isActualFile) {
371                        return Optional.of(ChangeType.DELETE);
372                }
373                return Optional.empty();
374        }
375
376        /**
377         * Returns whether a "file" with given file mode is an actual file and not,
378         * e.g., a symlink which is not tracked by Teamscale.
379         */
380        private static boolean isActualFile(FileMode mode) {
381                return isActualFile(mode.getBits());
382        }
383
384        /**
385         * Returns whether a "file" with the given mode bits is an actual file and not,
386         * e.g., a symlink which is not tracked by Teamscale.
387         */
388        public static boolean isActualFile(int modeBits) {
389                return FileMode.REGULAR_FILE.equals(modeBits) || FileMode.EXECUTABLE_FILE.equals(modeBits);
390        }
391
392        /** Returns a fetch command for updating a local git repository. */
393        public static FetchCommand createFetchCommand(Git git, URI location,
394                        TeamscaleGitCredentialsProvider credentialsProvider) {
395                FetchCommand fetchCommand = git.fetch().setTimeout(GitUtils.REMOTE_OPERATION_TIMEOUT_SECONDS)
396                                .setCredentialsProvider(credentialsProvider);
397                GitUtils.configureCommand(location, credentialsProvider, fetchCommand);
398
399                return fetchCommand;
400        }
401}