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}