1.9.30 sync.
[gae.git] / java / src / main / com / google / appengine / tools / admin / RepoInfo.java
blobca272108aaa773c74096abf827d443adb6a5b7b4
1 package com.google.appengine.tools.admin;
3 import com.google.auto.value.AutoValue;
4 import com.google.common.base.Strings;
5 import com.google.common.collect.ImmutableList;
6 import com.google.common.collect.ImmutableMultimap;
7 import com.google.common.collect.Multimap;
9 import java.io.File;
10 import java.io.IOException;
11 import java.io.PrintWriter;
12 import java.io.StringWriter;
13 import java.util.Map;
14 import java.util.logging.Logger;
15 import java.util.regex.Matcher;
16 import java.util.regex.Pattern;
18 import javax.annotation.Nullable;
20 /**
21 * Auto-detects source context that was used to build and deploy an application by scanning
22 * its git directory.
24 final class RepoInfo {
25 /**
26 * SourceContext is a reference to a persistent snapshot of the source tree stored in a
27 * version control repository.
29 @AutoValue
30 abstract static class SourceContext {
31 /** A URL string identifying the repository. */
32 @Nullable
33 abstract String getRepositoryUrl();
34 /** The canonical, unique, and persistent identifier of the deployed revision. */
35 abstract String getRevisionId();
36 /** The source context message in JSON format.*/
37 @Nullable
38 abstract String getJson();
39 /** The cloud repo project id, if available. */
40 @Nullable
41 abstract String getProjectId();
42 /** The cloud repo id, if available. */
43 @Nullable
44 abstract String getRepoId();
45 /** The cloud repo name, if available. */
46 @Nullable
47 abstract String getRepoName();
49 boolean isCloudRepo() {
50 return getRepoId() != null || getProjectId() != null;
53 private static final Pattern CLOUD_REPO_RE = Pattern.compile(
54 "^https://"
55 + "(?<hostname>[^/]*)/"
56 + "(?<idtype>p|id)/"
57 + "(?<projectOrRepoId>[^/?#]+)"
58 + "(/r/(?<repoName>[^/?#]+))?"
59 + "([/#?].*)?");
61 static SourceContext createLocal(String revisionId) {
62 return new AutoValue_RepoInfo_SourceContext(null, revisionId, null, null, null, null);
65 /**
66 * Builds the source context from a URL and revision ID.
68 * <p>If {@code repoUrl} conforms to the predefined format of Google repo URLs, it parses out
69 * the components of a Source API CloudRepoSourceContext. If {@code repoUrl} is not a valid
70 * Google repo URL, it is treated as a generic GitSourceContext URL. The function assembles
71 * everything into a JSON string. JSON values are escaped.
73 * <p>It would be better to use some JSON library to build JSON string (like gson). We craft
74 * the JSON string manually to avoid new dependencies for the SDK.
76 * @param repoUrl remote git URL corresponding to Google Cloud repo
77 * @param revision the HEAD revision of the current branch
78 * @return source context BLOB serialized as JSON string, or null if we fail to
79 * parse {@code repoUrl}
81 static SourceContext createFromUrl(@Nullable String repoUrl, String revisionId) {
82 if (repoUrl == null) {
83 return createLocal(revisionId);
86 Matcher match = CLOUD_REPO_RE.matcher(repoUrl);
87 if (match.matches()) {
88 String idType = match.group("idtype");
89 if ("id".equals(idType)) {
90 String rawRepoId = match.group("projectOrRepoId");
91 if (!Strings.isNullOrEmpty(rawRepoId)
92 && Strings.isNullOrEmpty(match.group("repoName"))) {
93 return SourceContext.createFromRepoId(repoUrl, revisionId, rawRepoId);
95 } else if ("p".equals(idType)) {
96 String projectId = match.group("projectOrRepoId");
97 if (!Strings.isNullOrEmpty(projectId)) {
98 String repoName = match.group("repoName");
99 if (Strings.isNullOrEmpty(repoName)) {
100 repoName = "default";
102 return SourceContext.createFromRepoName(repoUrl, revisionId, projectId, repoName);
106 return SourceContext.createGit(repoUrl, revisionId);
109 static SourceContext createFromRepoId(String repoUrl, String revisionId, String repoId) {
110 String json = String.format(
111 "{\"cloudRepo\": {\"repoId\": {\"uid\": \"%s\"}, \"revisionId\": \"%s\"}}",
112 Utility.jsonEscape(repoId), Utility.jsonEscape(revisionId));
113 return new AutoValue_RepoInfo_SourceContext(repoUrl, revisionId, json, null, repoId, null);
116 static SourceContext createFromRepoName(
117 String repoUrl, String revisionId, String projectId, String repoName) {
118 String jsonRepoId = String.format(
119 "{\"projectRepoId\": {\"projectId\": \"%s\", \"repoName\": \"%s\"}}",
120 Utility.jsonEscape(projectId), Utility.jsonEscape(repoName));
121 String json = String.format("{\"cloudRepo\": {\"repoId\": %s, \"revisionId\": \"%s\"}}",
122 jsonRepoId, Utility.jsonEscape(revisionId));
123 return new AutoValue_RepoInfo_SourceContext(
124 repoUrl, revisionId, json, projectId, null, repoName);
127 static SourceContext createGit(String repoUrl, String revisionId) {
128 String json = String.format("{\"git\": {\"url\": \"%s\", \"revisionId\": \"%s\"}}",
129 Utility.jsonEscape(repoUrl), Utility.jsonEscape(revisionId));
130 return new AutoValue_RepoInfo_SourceContext(repoUrl, revisionId, json, null, null, null);
135 * Exception for all problems calling git or parsing its output.
137 static final class GitException extends Exception {
138 GitException(String message) {
139 super(message);
142 GitException(String message, Throwable cause) {
143 super(message, cause);
148 * Abstraction over calling git for unit tests.
150 interface GitClient {
152 * Calls git with the given args.
154 * <p>The working directory is set to the deployed target directory. This is the potential
155 * git repository directory. The current working directory (i.e. directory from which
156 * appcfg is called) is irrelevant.
158 * <p>Git might not be used by the developer. In this case {@code baseDir} is not a git
159 * repository or git might not be even installed on the system. In these cases this
160 * function will throw {@link GitException}.
162 * @param args arguments for the git command
163 * @return raw output of the git command (stdout, not stderr)
164 * @throws GitException if not a git repository or problem calling git
166 String callGit(String... args) throws GitException;
170 * Implements {@link GitClient} interface by invoking git command as a separate process.
172 private static final class GitCommandClient implements GitClient {
174 * Potential git repo directory (doesn't have to be root repo directory).
176 private final File baseDir;
179 * Class constructor.
181 * @param baseDir potential git repo directory (doesn't have to be root repo directory)
183 GitCommandClient(File baseDir) {
184 this.baseDir = baseDir;
187 @Override
188 public String callGit(String... args) throws GitException {
189 ImmutableList<String> command = ImmutableList.<String>builder()
190 .add(Utility.isOsWindows() ? "git.exe" : "git")
191 .add(args)
192 .build();
194 try {
195 Process process = new ProcessBuilder(command)
196 .directory(baseDir)
197 .start();
199 StringWriter stdOutWriter = new StringWriter();
200 Thread stdOutPumpThread =
201 new Thread(new OutputPump(process.getInputStream(), new PrintWriter(stdOutWriter)));
202 stdOutPumpThread.start();
204 StringWriter stdErrWriter = new StringWriter();
205 Thread stdErrPumpThread =
206 new Thread(new OutputPump(process.getErrorStream(), new PrintWriter(stdErrWriter)));
207 stdErrPumpThread.start();
209 int rc = process.waitFor();
210 stdOutPumpThread.join();
211 stdErrPumpThread.join();
213 String stdout = stdOutWriter.toString();
214 String stderr = stdErrWriter.toString();
216 logger.fine(String.format("%s completed with code %d\n%s%s",
217 command, rc, stdout, stderr));
219 if (rc != 0) {
220 throw new GitException(String.format(
221 "git command failed (exit code = %d), command: %s\n%s%s",
222 rc, command, stdout, stderr));
225 return stdout;
226 } catch (InterruptedException ex) {
227 throw new GitException(String.format(
228 "InterruptedException caught while executing git command: %s", command), ex);
229 } catch (IOException ex) {
230 throw new GitException(String.format("Failed to invoke git: %s", command), ex);
235 private static final Logger logger = Logger.getLogger(RepoInfo.class.getName());
238 * Regular expression pattern to capture list of origins for the local repo.
240 private static final String REMOTE_URL_PATTERN = "remote\\.(.*)\\.url";
243 * Calls git to obtain information about the repository.
245 private final GitClient git;
248 * Class constructor.
250 * @param baseDir potential git repo directory (doesn't have to be root repo directory)
252 RepoInfo(File baseDir) {
253 this(new GitCommandClient(baseDir));
257 * Class constructor.
259 * @param git git client interface
261 RepoInfo(GitClient git) {
262 this.git = git;
266 * Constructs a SourceContext for the HEAD revision.
268 * @return Returns null if there is no local revision ID.<p><ul>
269 * <li>If there is exactly one remote repo associated with the local repo, its context will be
270 * returned.
271 * <li>If there is exactly one Google-hosted remote repo associated with the local repo, its
272 * {@code SourceContext} will be returned, even if there other non-Google remote repos
273 * associated with the local repo.
274 * </ul><p>In all other cases, the return value will contain only the local head revision ID.
276 @Nullable
277 SourceContext getSourceContext() {
278 Multimap<String, String> remoteUrls;
279 String revision = null;
281 try {
282 revision = getGitHeadRevision();
284 remoteUrls = getGitRemoteUrls();
285 if (remoteUrls.isEmpty()) {
286 logger.fine("Local git repo has no remote URLs");
287 return SourceContext.createLocal(revision);
290 } catch (GitException e) {
291 logger.fine("not a git repository or problem calling git");
292 return revision == null ? null : SourceContext.createLocal(revision);
295 SourceContext bestReturn = null;
296 boolean hasAmbiguousRepos = false;
297 for (Map.Entry<String, String> remoteUrl : remoteUrls.entries()) {
298 SourceContext candidate = SourceContext.createFromUrl(remoteUrl.getValue(), revision);
299 if (candidate.isCloudRepo()) {
300 if (bestReturn != null && bestReturn.isCloudRepo()
301 && !bestReturn.getJson().equals(candidate.getJson())) {
302 logger.fine("Ambiguous Google Cloud Repository in the remote URLs");
303 return SourceContext.createLocal(revision);
305 bestReturn = candidate;
306 } else if (bestReturn == null) {
307 bestReturn = candidate;
308 } else if (!bestReturn.isCloudRepo()) {
309 hasAmbiguousRepos = true;
313 if (bestReturn != null && !bestReturn.isCloudRepo() && hasAmbiguousRepos) {
314 logger.fine("Remote URLs include multiple non-Google repos and no Google repo.");
315 return SourceContext.createLocal(revision);
318 return bestReturn;
322 * Calls git to print every configured remote URL.
324 * @return raw output of the command
325 * @throws GitException if not a git repository or problem calling git
327 private String getGitRemoteUrlConfigs() throws GitException {
328 return git.callGit("config", "--get-regexp", REMOTE_URL_PATTERN);
332 * Finds the list of git remotes for the given source directory.
334 * @return A list of remote name to remote URL mappings, empty if no remotes are found
335 * @throws GitException if not a git repository or problem calling git
337 private Multimap<String, String> getGitRemoteUrls() throws GitException {
338 String remoteUrlConfigOutput = getGitRemoteUrlConfigs();
339 if (remoteUrlConfigOutput.isEmpty()) {
340 return ImmutableMultimap.of();
343 Pattern remoteUrlRe = Pattern.compile(REMOTE_URL_PATTERN);
345 ImmutableMultimap.Builder<String, String> result = ImmutableMultimap.builder();
347 String[] configLines = remoteUrlConfigOutput.split("\\r?\\n");
348 for (String configLine : configLines) {
349 if (configLine.isEmpty()) {
350 continue;
353 String[] parts = configLine.split(" +");
354 if (parts.length != 2) {
355 logger.fine(String.format("Skipping unexpected git config line, incorrect segments: %s",
356 configLine));
357 continue;
360 String remoteUrlConfigName = parts[0];
361 String remoteUrl = parts[1];
363 Matcher matcher = remoteUrlRe.matcher(remoteUrlConfigName);
364 if (!matcher.matches()) {
365 logger.fine(String.format("Skipping unexpected git config line, could not match remote: %s",
366 configLine));
367 continue;
370 String remoteUrlName = matcher.group(1);
372 result.put(remoteUrlName, remoteUrl);
375 logger.fine(String.format("Remote git URLs: %s", result.toString()));
377 return result.build();
381 * Finds the current HEAD revision for the given source directory
383 * @return the HEAD revision of the current branch
384 * @throws GitException if not a git repository or problem calling git
386 private String getGitHeadRevision() throws GitException {
387 String head = git.callGit("rev-parse", "HEAD").trim();
388 if (head.isEmpty()) {
389 throw new GitException("Empty head revision returned by git");
392 return head;