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 (if available).*/
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
);
51 * Exception for all problems calling git or parsing its output.
53 static final class GitException
extends Exception
{
54 GitException(String message
) {
58 GitException(String message
, Throwable cause
) {
59 super(message
, cause
);
64 * Abstraction over calling git for unit tests.
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
;
86 * Implements {@link GitClient} interface by invoking git command as a separate process.
88 private static final class GitCommandClient
implements GitClient
{
90 * Potential git repo directory (doesn't have to be root repo directory).
92 private final File baseDir
;
97 * @param baseDir potential git repo directory (doesn't have to be root repo directory)
99 GitCommandClient(File baseDir
) {
100 this.baseDir
= baseDir
;
104 public String
callGit(String
... args
) throws GitException
{
105 ImmutableList
<String
> command
= ImmutableList
.<String
>builder()
106 .add(Utility
.isOsWindows() ?
"git.exe" : "git")
111 Process process
= new ProcessBuilder(command
)
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
));
136 throw new GitException(String
.format(
137 "git command failed (exit code = %d), command: %s\n%s%s",
138 rc
, command
, stdout
, stderr
));
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
;
166 * @param baseDir potential git repo directory (doesn't have to be root repo directory)
168 RepoInfo(File baseDir
) {
169 this(new GitCommandClient(baseDir
));
175 * @param git git client interface
177 RepoInfo(GitClient git
) {
182 * Constructs SourceContext for the HEAD revision. Repository URL and json fields are populated
183 * only for Google-hosted repositories.
186 SourceContext
getSourceContext() {
187 Multimap
<String
, String
> remoteUrls
;
188 String revision
= null;
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
);
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()) {
260 String
[] parts
= configLine
.split(" +");
261 if (parts
.length
!= 2) {
262 logger
.fine(String
.format("Skipping unexpected git config line, incorrect segments: %s",
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",
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");
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(
319 + "(?<hostname>[^/]*)/"
321 + "(?<part1>[^/?#]+)"
322 + "(/r/(?<part2>[^/?#]+))?"
324 Matcher match
= cloudRepoRe
.matcher(remoteUrl
);
325 if (!match
.matches()) {
326 logger
.fine(String
.format("Skipping remote URL %s", remoteUrl
));
330 String idType
= match
.group("idtype");
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
));
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
));
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
));
356 logger
.fine(String
.format("Unexpected ID type %s, skipping remote URL %s",
361 return String
.format("{\"cloudRepo\": {\"repoId\": %s, \"revisionId\": \"%s\"}}",
362 jsonRepoId
, Utility
.jsonEscape(revision
));