App Engine Java SDK version 1.9.25
[gae.git] / java / src / main / com / google / appengine / tools / admin / RepoInfo.java
blob9a82fb16db4853d335ccd4d6e6133f34d212e55d
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 (if available).*/
37 @Nullable
38 abstract String getJson();
40 static SourceContext create(String revisionId) {
41 return create(null, revisionId, null);
44 static SourceContext create(
45 @Nullable String repoUrl, String revisionId, @Nullable String json) {
46 return new AutoValue_RepoInfo_SourceContext(repoUrl, revisionId, json);
50 /**
51 * Exception for all problems calling git or parsing its output.
53 static final class GitException extends Exception {
54 GitException(String message) {
55 super(message);
58 GitException(String message, Throwable cause) {
59 super(message, cause);
63 /**
64 * Abstraction over calling git for unit tests.
66 interface GitClient {
67 /**
68 * Calls git with the given args.
70 * <p>The working directory is set to the deployed target directory. This is the potential
71 * git repository directory. The current working directory (i.e. directory from which
72 * appcfg is called) is irrelevant.
74 * <p>Git might not be used by the developer. In this case {@code baseDir} is not a git
75 * repository or git might not be even installed on the system. In these cases this
76 * function will throw {@link GitException}.
78 * @param args arguments for the git command
79 * @return raw output of the git command (stdout, not stderr)
80 * @throws GitException if not a git repository or problem calling git
82 String callGit(String... args) throws GitException;
85 /**
86 * Implements {@link GitClient} interface by invoking git command as a separate process.
88 private static final class GitCommandClient implements GitClient {
89 /**
90 * Potential git repo directory (doesn't have to be root repo directory).
92 private final File baseDir;
94 /**
95 * Class constructor.
97 * @param baseDir potential git repo directory (doesn't have to be root repo directory)
99 GitCommandClient(File baseDir) {
100 this.baseDir = baseDir;
103 @Override
104 public String callGit(String... args) throws GitException {
105 ImmutableList<String> command = ImmutableList.<String>builder()
106 .add(Utility.isOsWindows() ? "git.exe" : "git")
107 .add(args)
108 .build();
110 try {
111 Process process = new ProcessBuilder(command)
112 .directory(baseDir)
113 .start();
115 StringWriter stdOutWriter = new StringWriter();
116 Thread stdOutPumpThread =
117 new Thread(new OutputPump(process.getInputStream(), new PrintWriter(stdOutWriter)));
118 stdOutPumpThread.start();
120 StringWriter stdErrWriter = new StringWriter();
121 Thread stdErrPumpThread =
122 new Thread(new OutputPump(process.getErrorStream(), new PrintWriter(stdErrWriter)));
123 stdErrPumpThread.start();
125 int rc = process.waitFor();
126 stdOutPumpThread.join();
127 stdErrPumpThread.join();
129 String stdout = stdOutWriter.toString();
130 String stderr = stdErrWriter.toString();
132 logger.fine(String.format("%s completed with code %d\n%s%s",
133 command, rc, stdout, stderr));
135 if (rc != 0) {
136 throw new GitException(String.format(
137 "git command failed (exit code = %d), command: %s\n%s%s",
138 rc, command, stdout, stderr));
141 return stdout;
142 } catch (InterruptedException ex) {
143 throw new GitException(String.format(
144 "InterruptedException caught while executing git command: %s", command), ex);
145 } catch (IOException ex) {
146 throw new GitException(String.format("Failed to invoke git: %s", command), ex);
151 private static final Logger logger = Logger.getLogger(RepoInfo.class.getName());
154 * Regular expression pattern to capture list of origins for the local repo.
156 private static final String REMOTE_URL_PATTERN = "remote\\.(.*)\\.url";
159 * Calls git to obtain information about the repository.
161 private final GitClient git;
164 * Class constructor.
166 * @param baseDir potential git repo directory (doesn't have to be root repo directory)
168 RepoInfo(File baseDir) {
169 this(new GitCommandClient(baseDir));
173 * Class constructor.
175 * @param git git client interface
177 RepoInfo(GitClient git) {
178 this.git = git;
182 * Constructs SourceContext for the HEAD revision. Repository URL and json fields are populated
183 * only for Google-hosted repositories.
185 @Nullable
186 SourceContext getSourceContext() {
187 Multimap<String, String> remoteUrls;
188 String revision = null;
190 try {
191 revision = getGitHeadRevision();
193 remoteUrls = getGitRemoteUrls();
194 if (remoteUrls.isEmpty()) {
195 logger.fine("Local git repo has no remote URLs");
196 return SourceContext.create(revision);
199 } catch (GitException e) {
200 logger.fine("not a git repository or problem calling git");
201 return revision == null ? null : SourceContext.create(revision);
204 String repositoryURL = null;
205 String sourceContextJson = null;
206 for (Map.Entry<String, String> remoteUrl : remoteUrls.entries()) {
207 String json = buildSourceContextJsonString(remoteUrl.getValue(), revision);
208 if (json == null) {
209 continue;
212 if (sourceContextJson != null && !json.equals(sourceContextJson)) {
213 logger.fine("No unambiguous Google Cloud Repository in the remote URLs");
214 return SourceContext.create(revision);
216 sourceContextJson = json;
217 repositoryURL = remoteUrl.getValue();
220 if (sourceContextJson == null) {
221 logger.fine("Could not find a Google Cloud Repository in the remote URLs");
222 return SourceContext.create(revision);
225 return SourceContext.create(repositoryURL, revision, sourceContextJson);
229 * Calls git to print every configured remote URL.
231 * @return raw output of the command
232 * @throws GitException if not a git repository or problem calling git
234 private String getGitRemoteUrlConfigs() throws GitException {
235 return git.callGit("config", "--get-regexp", REMOTE_URL_PATTERN);
239 * Finds the list of git remotes for the given source directory.
241 * @return A list of remote name to remote URL mappings, empty if no remotes are found
242 * @throws GitException if not a git repository or problem calling git
244 private Multimap<String, String> getGitRemoteUrls() throws GitException {
245 String remoteUrlConfigOutput = getGitRemoteUrlConfigs();
246 if (remoteUrlConfigOutput.isEmpty()) {
247 return ImmutableMultimap.of();
250 Pattern remoteUrlRe = Pattern.compile(REMOTE_URL_PATTERN);
252 ImmutableMultimap.Builder<String, String> result = ImmutableMultimap.builder();
254 String[] configLines = remoteUrlConfigOutput.split("\\r?\\n");
255 for (String configLine : configLines) {
256 if (configLine.isEmpty()) {
257 continue;
260 String[] parts = configLine.split(" +");
261 if (parts.length != 2) {
262 logger.fine(String.format("Skipping unexpected git config line, incorrect segments: %s",
263 configLine));
264 continue;
267 String remoteUrlConfigName = parts[0];
268 String remoteUrl = parts[1];
270 Matcher matcher = remoteUrlRe.matcher(remoteUrlConfigName);
271 if (!matcher.matches()) {
272 logger.fine(String.format("Skipping unexpected git config line, could not match remote: %s",
273 configLine));
274 continue;
277 String remoteUrlName = matcher.group(1);
279 result.put(remoteUrlName, remoteUrl);
282 logger.fine(String.format("Remote git URLs: %s", result.toString()));
284 return result.build();
288 * Finds the current HEAD revision for the given source directory
290 * @return the HEAD revision of the current branch
291 * @throws GitException if not a git repository or problem calling git
293 private String getGitHeadRevision() throws GitException {
294 String head = git.callGit("rev-parse", "HEAD").trim();
295 if (head.isEmpty()) {
296 throw new GitException("Empty head revision returned by git");
299 return head;
303 * Builds the source context message in JSON format.
305 * <p>Parses {@code remoteUrl} according to the predefined format of Google repo URLs. Then
306 * assembles everything into a JSON string. JSON values are escaped.
308 * <p>It would be better to use some JSON library to build JSON string (like gson). We craft
309 * the JSON string manually to avoid new dependency for the SDK.
311 * @param remoteUrl remote git URL corresponding to Google Cloud repo
312 * @param revision the HEAD revision of the current branch
313 * @return source context BLOB serialized as JSON string, or null if we fail to
314 * parse {@code remoteUrl}
316 private String buildSourceContextJsonString(String remoteUrl, String revision) {
317 Pattern cloudRepoRe = Pattern.compile(
318 "^https://"
319 + "(?<hostname>[^/]*)/"
320 + "(?<idtype>p|id)/"
321 + "(?<part1>[^/?#]+)"
322 + "(/r/(?<part2>[^/?#]+))?"
323 + "([/#?].*)?");
324 Matcher match = cloudRepoRe.matcher(remoteUrl);
325 if (!match.matches()) {
326 logger.fine(String.format("Skipping remote URL %s", remoteUrl));
327 return null;
330 String idType = match.group("idtype");
331 String jsonRepoId;
332 if ("id".equals(idType)) {
333 String rawRepoId = match.group("part1");
334 if (Strings.isNullOrEmpty(rawRepoId) || !Strings.isNullOrEmpty(match.group("part2"))) {
335 logger.fine(String.format("Skipping ill-formed remote URL %s", remoteUrl));
336 return null;
339 jsonRepoId = String.format("{\"uid\": \"%s\"}", Utility.jsonEscape(rawRepoId));
340 } else if ("p".equals(idType)) {
341 String projectId = match.group("part1");
342 if (Strings.isNullOrEmpty(projectId)) {
343 logger.fine(String.format("Skipping ill-formed remote URL %s", remoteUrl));
344 return null;
347 String repoName = match.group("part2");
348 if (Strings.isNullOrEmpty(repoName)) {
349 repoName = "default";
352 jsonRepoId = String.format(
353 "{\"projectRepoId\": {\"projectId\": \"%s\", \"repoName\": \"%s\"}}",
354 Utility.jsonEscape(projectId), Utility.jsonEscape(repoName));
355 } else {
356 logger.fine(String.format("Unexpected ID type %s, skipping remote URL %s",
357 idType, remoteUrl));
358 return null;
361 return String.format("{\"cloudRepo\": {\"repoId\": %s, \"revisionId\": \"%s\"}}",
362 jsonRepoId, Utility.jsonEscape(revision));