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
;
10 import java
.io
.IOException
;
11 import java
.io
.PrintWriter
;
12 import java
.io
.StringWriter
;
14 import java
.util
.logging
.Logger
;
15 import java
.util
.regex
.Matcher
;
16 import java
.util
.regex
.Pattern
;
18 import javax
.annotation
.Nullable
;
21 * Auto-detects source context that was used to build and deploy an application by scanning
24 final class RepoInfo
{
26 * SourceContext is a reference to a persistent snapshot of the source tree stored in a
27 * version control repository.
30 abstract static class SourceContext
{
31 /** A URL string identifying the repository. */
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.*/
38 abstract String
getJson();
39 /** The cloud repo project id, if available. */
41 abstract String
getProjectId();
42 /** The cloud repo id, if available. */
44 abstract String
getRepoId();
45 /** The cloud repo name, if available. */
47 abstract String
getRepoName();
49 boolean isCloudRepo() {
50 return getRepoId() != null || getProjectId() != null;
53 private static final Pattern CLOUD_REPO_RE
= Pattern
.compile(
55 + "(?<hostname>[^/]*)/"
57 + "(?<projectOrRepoId>[^/?#]+)"
58 + "(/r/(?<repoName>[^/?#]+))?"
61 static SourceContext
createLocal(String revisionId
) {
62 return new AutoValue_RepoInfo_SourceContext(null, revisionId
, null, null, null, null);
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
) {
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
;
181 * @param baseDir potential git repo directory (doesn't have to be root repo directory)
183 GitCommandClient(File baseDir
) {
184 this.baseDir
= baseDir
;
188 public String
callGit(String
... args
) throws GitException
{
189 ImmutableList
<String
> command
= ImmutableList
.<String
>builder()
190 .add(Utility
.isOsWindows() ?
"git.exe" : "git")
195 Process process
= new ProcessBuilder(command
)
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
));
220 throw new GitException(String
.format(
221 "git command failed (exit code = %d), command: %s\n%s%s",
222 rc
, command
, stdout
, stderr
));
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
;
250 * @param baseDir potential git repo directory (doesn't have to be root repo directory)
252 RepoInfo(File baseDir
) {
253 this(new GitCommandClient(baseDir
));
259 * @param git git client interface
261 RepoInfo(GitClient 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
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.
277 SourceContext
getSourceContext() {
278 Multimap
<String
, String
> remoteUrls
;
279 String revision
= null;
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
);
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()) {
353 String
[] parts
= configLine
.split(" +");
354 if (parts
.length
!= 2) {
355 logger
.fine(String
.format("Skipping unexpected git config line, incorrect segments: %s",
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",
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");