/* * Copyright (C) 2016 Google Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.google.copybara.git; import static com.google.common.base.Preconditions.checkNotNull; import static com.google.copybara.exception.ValidationException.checkCondition; import static com.google.copybara.util.CommandRunner.DEFAULT_TIMEOUT; import static com.google.copybara.util.CommandRunner.NO_INPUT; import com.google.common.annotations.VisibleForTesting; import com.google.common.base.Joiner; import com.google.common.base.MoreObjects; import com.google.common.base.Preconditions; import com.google.common.base.Splitter; import com.google.common.base.StandardSystemProperty; import com.google.common.base.Strings; import com.google.common.base.Verify; import com.google.common.collect.ImmutableCollection; import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableListMultimap; import com.google.common.collect.ImmutableMap; import com.google.common.collect.ImmutableRangeSet; import com.google.common.collect.ImmutableSet; import com.google.common.collect.Iterables; import com.google.common.collect.Lists; import com.google.common.collect.Range; import com.google.common.flogger.FluentLogger; import com.google.copybara.authoring.Author; import com.google.copybara.authoring.AuthorParser; import com.google.copybara.authoring.InvalidAuthorException; import com.google.copybara.exception.AccessValidationException; import com.google.copybara.exception.CannotResolveRevisionException; import com.google.copybara.exception.EmptyChangeException; import com.google.copybara.exception.RepoException; import com.google.copybara.exception.ValidationException; import com.google.copybara.git.GitCredential.UserPassword; import com.google.copybara.util.BadExitStatusWithOutputException; import com.google.copybara.util.CommandOutput; import com.google.copybara.util.CommandOutputWithStatus; import com.google.copybara.util.CommandRunner; import com.google.copybara.util.FileUtil; import com.google.copybara.util.Glob; import com.google.copybara.util.RepositoryUtil; import com.google.copybara.shell.Command; import com.google.copybara.shell.CommandException; import com.google.re2j.Matcher; import com.google.re2j.Pattern; import java.io.IOException; import java.nio.charset.StandardCharsets; import java.nio.file.FileSystems; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.PathMatcher; import java.nio.file.Paths; import java.time.Duration; import java.time.Instant; import java.time.ZoneOffset; import java.time.ZonedDateTime; import java.time.format.DateTimeFormatter; import java.time.format.DateTimeParseException; import java.util.ArrayList; import java.util.Arrays; import java.util.Collection; import java.util.LinkedHashSet; import java.util.List; import java.util.Map; import java.util.Objects; import java.util.Optional; import java.util.Set; import java.util.UUID; import java.util.function.Function; import java.util.stream.Collectors; import javax.annotation.CheckReturnValue; import javax.annotation.Nullable; /** * A class for manipulating Git repositories */ public class GitRepository { private static final FluentLogger logger = FluentLogger.forEnclosingClass(); public static final Duration DEFAULT_FETCH_TIMEOUT = Duration.ofMinutes(15); // TODO(malcon): Make this generic (Using URIish.java) private static final Pattern FULL_URI = Pattern.compile( "([a-z][a-z0-9+-]+@[a-zA-Z0-9_.-]+(:.+)?|^[a-z][a-z0-9+-]+://.*)$"); private static final Pattern LS_TREE_ELEMENT = Pattern.compile( "([0-9]{6}) (commit|tag|tree|blob) ([a-f0-9]{40})\t(.*)"); private static final Pattern LS_REMOTE_OUTPUT_LINE = Pattern.compile("([a-f0-9]{40})\t(.+)"); private static final Pattern SHA1_PATTERN = Pattern.compile("[a-f0-9]{6,40}"); private static final Pattern FAILED_REBASE = Pattern.compile("(Failed to merge in the changes|Could not apply.*)"); private static final ImmutableList<Pattern> REF_NOT_FOUND_ERRORS = ImmutableList.of( Pattern.compile("pathspec '(.+)' did not match any file"), Pattern.compile( "ambiguous argument '(.+)': unknown revision or path not in the working tree")); private static final Pattern FETCH_CANNOT_RESOLVE_ERRORS = Pattern.compile( "" // When fetching a ref like 'refs/foo' fails. + "(fatal: [Cc]ouldn't find remote ref" // When fetching a SHA-1 ref + "|no such remote ref" // New output for fetching (git version 2.17) + "|fatal: no matching remote head" // Local fetch for a SHA-1 fails + "|upload-pack: not our ref" // Gerrit when fetching + "|ERR want .+ not valid)"); private static final Pattern NO_GIT_REPOSITORY = Pattern.compile("does not appear to be a git repository"); private static final Pattern PROTECTED_BRANCH = Pattern.compile( "" // Protected brach errors in GitHub + "([Pp]rotected branch hook declined)"); /** * Label to be used for marking the original revision id (Git SHA-1) for migrated commits. */ public static final String GIT_ORIGIN_REV_ID = "GitOrigin-RevId"; // Git exits with 128 in several circumstances. For example failed rebase. private static final ImmutableRangeSet<Integer> NON_CRASH_ERROR_EXIT_CODES = ImmutableRangeSet.<Integer>builder().add( Range.closed(1, 10)).add(Range.singleton(128)).build(); /** * We cannot control the repo storage location, but we can limit the number of characters of the * repo folder name. */ private static final int DEFAULT_MAX_LOG_LINES = 4_000; public static final String GIT_DESCRIBE_REQUESTED_VERSION = "GIT_DESCRIBE_REQUESTED_VERSION"; public static final String GIT_DESCRIBE_CHANGE_VERSION = "GIT_DESCRIBE_CHANGE_VERSION"; /** * The location of the {@code .git} directory. The is also the value of the {@code --git-dir} * flag. */ private final Path gitDir; @Nullable private final Path workTree; private final boolean verbose; private final GitEnvironment gitEnv; private final Duration fetchTimeout; protected final boolean noVerify; private static final Map<Character, StatusCode> CHAR_TO_STATUS_CODE = Arrays.stream(StatusCode.values()) .collect(Collectors.toMap(StatusCode::getCode, Function.identity())); protected GitRepository( Path gitDir, @Nullable Path workTree, boolean verbose, GitEnvironment gitEnv, Duration fetchTimeout, boolean noVerify) { this.gitDir = checkNotNull(gitDir); this.workTree = workTree; this.verbose = verbose; this.gitEnv = checkNotNull(gitEnv); this.fetchTimeout = checkNotNull(fetchTimeout); this.noVerify = noVerify; } /** Creates a new repository in the given directory. The new repo is not bare. */ public static GitRepository newRepo(boolean verbose, Path path, GitEnvironment gitEnv, Duration fetchTimeout, boolean noVerify) { return new GitRepository(path.resolve(".git"), path, verbose, gitEnv, fetchTimeout, noVerify); } /** * Creates a new repository in the given directory with a default fetch timeout. The new repo is * not bare. */ public static GitRepository newRepo(boolean verbose, Path path, GitEnvironment gitEnv) { return newRepo(verbose, path, gitEnv, DEFAULT_FETCH_TIMEOUT, /*noVerify=*/false); } /** Create a new bare repository */ public static GitRepository newBareRepo(Path gitDir, GitEnvironment gitEnv, boolean verbose, Duration fetchTimeout, boolean noVerify) { return new GitRepository(gitDir, /*workTree=*/ null, verbose, gitEnv, fetchTimeout, noVerify); } /** * Get the version of git that will be used for running migrations. Returns empty if git cannot be * found. */ private static Optional<String> version(GitEnvironment gitEnv) { try { String version = executeGit( Paths.get(StandardSystemProperty.USER_DIR.value()), ImmutableList.of("version"), gitEnv, /*verbose=*/ false) .getStdout(); return Optional.of(version); } catch (CommandException e) { return Optional.empty(); } } /** * Validate that a refspec is valid. * * @throws InvalidRefspecException if the refspec is not valid */ static void validateRefSpec(GitEnvironment gitEnv, Path cwd, String refspec) throws InvalidRefspecException { try { executeGit( cwd, ImmutableList.of("check-ref-format", "--allow-onelevel", "--refspec-pattern", refspec), gitEnv, /*verbose=*/ false); } catch (CommandException e) { Optional<String> version = version(gitEnv); throw new InvalidRefspecException( version .map(s -> "Invalid refspec: " + refspec) .orElseGet( () -> String.format("Cannot find git binary at '%s'", gitEnv.resolveGitBinary()))); } } /** * Fetch a reference from a git url. * * <p>Note that this method doesn't support fetching refspecs that contain local ref path * locations. IOW * "refs/foo" is allowed but not "refs/foo:remote/origin/foo". Wildcards are also not allowed. */ public GitRevision fetchSingleRef(String url, String ref, boolean partialFetch) throws RepoException, ValidationException { return fetchSingleRefWithTags(url, ref, /*fetchTags=*/false, partialFetch); } public GitRevision fetchSingleRefWithTags(String url, String ref, boolean fetchTags, boolean partialFetch) throws RepoException, ValidationException { if (ref.contains(":") || ref.contains("*")) { throw new CannotResolveRevisionException("Fetching refspecs that" + " contain local ref path locations or wildcards is not supported. Invalid ref: " + ref); } // This is not strictly necessary for some Git repos that allow fetching from any sha1 ref, like // servers configured with 'git config uploadpack.allowReachableSHA1InWant true'. Unfortunately, // Github doesn't support it. So what we do is fetch the default refspec (see the comment // below) and hope the sha1 is reachable from heads. // If we fail to find the SHA-1 with that fetch we fetch the SHA-1 directly and hope the server // allows to download it. if (isSha1Reference(ref)) { // Tags are fetched by the default refspec try { fetch(url, /*prune=*/ false, /*force=*/ true, ImmutableList.of(), partialFetch); } catch (CannotResolveRevisionException e) { // Some servers are configured without HEAD. That is fine, we'll try fetching the SHA // instead. logger.atWarning().withCause(e).log( "Cannot fetch remote HEAD. Ignoring and fetching SHA-1 directly"); } try { return resolveReferenceWithContext(ref, /*contextRef=*/ref, url); } catch (RepoException | CannotResolveRevisionException ignore) { // Ignore, the fetch below will attempt using the SHA-1. } } if (fetchTags) { fetch(url, /*prune=*/false, /*force=*/true, ImmutableList.of(ref + ":refs/copybara_fetch/" + ref, "refs/tags/*:refs/tags/*"), partialFetch); return resolveReferenceWithContext("refs/copybara_fetch/" + ref, /*contextRef=*/ref, url); } else { fetch( url, /*prune=*/ false, /*force=*/ true, ImmutableList.of(ref + ":refs/copybara_fetch/" + ref), partialFetch); return resolveReferenceWithContext("refs/copybara_fetch/" + ref, /*contextRef=*/ref, url); } } public GitRevision addDescribeVersion(GitRevision rev) throws RepoException { return rev.withLabels(ImmutableListMultimap.of(GIT_DESCRIBE_REQUESTED_VERSION, describe(rev))); } public String describe(GitRevision rev) throws RepoException { try { return simpleCommand("describe", "--", rev.getSha1()).getStdout().trim(); } catch (RepoException e) { logger.atWarning() .withCause(e).log("Cannot get describe version for commit " + rev.getSha1()); return simpleCommand("describe", "--always", "--", rev.getSha1()).getStdout().trim(); } } public String showDiff(String referenceFrom, String referenceTo) throws RepoException { Preconditions.checkNotNull(referenceFrom, "Parameter referenceFrom should not be null"); Preconditions.checkNotNull(referenceTo, "Parameter referenceTo should not be null"); return simpleCommand("diff", referenceFrom, referenceTo).getStdout(); } /** * Fetch zero or more refspecs in the local repository * * @param url remote git repository url * @param prune if remotely non-present refs should be deleted locally * @param force force updates even for non fast-forward updates * @param refspecs a set refspecs in the form of 'foo' for branches, 'refs/some/ref' or * 'refs/foo/bar:refs/bar/foo'. * @return the set of fetched references and what action was done ( rejected, new reference, * updated, etc.) */ public FetchResult fetch(String url, boolean prune, boolean force, Iterable<String> refspecs, boolean partialFetch) throws RepoException, ValidationException { List<String> args = Lists.newArrayList("fetch", validateUrl(url)); if (partialFetch) { args.add("--filter=blob:none"); } args.add("--verbose"); // This shows progress in the log if not attached to a terminal args.add("--progress"); if (prune) { args.add("-p"); } if (force) { args.add("-f"); } List<String> requestedRefs = new ArrayList<>(); for (String ref : refspecs) { // Validates refspec: Refspec refSpec = createRefSpec(ref); requestedRefs.add(refSpec.getOrigin()); args.add(ref); } ImmutableMap<String, GitRevision> before = showRef(); CommandOutputWithStatus output = gitAllowNonZeroExit(NO_INPUT, args, fetchTimeout); if (output.getTerminationStatus().success()) { ImmutableMap<String, GitRevision> after = showRef(); return new FetchResult(before, after); } checkFetchError(output.getStderr(), url, requestedRefs); throw throwUnknownGitError(output, args); } public void checkFetchError(String stdErr, String url, List<String> requestedRefs) throws ValidationException, RepoException { if (stdErr.isEmpty() || FETCH_CANNOT_RESOLVE_ERRORS.matcher(stdErr).find()) { throw new CannotResolveRevisionException("Cannot find reference(s): " + requestedRefs); } if (NO_GIT_REPOSITORY.matcher(stdErr).find()) { throw new CannotResolveRevisionException( String.format("Invalid Git repository: %s. Error: %s", url, stdErr)); } if (stdErr.contains( "Server does not allow request for unadvertised object")) { throw new CannotResolveRevisionException( String.format("%s: %s", url, stdErr.trim())); } if (stdErr.contains("Permission denied") || stdErr.contains("Could not read from remote repository") || stdErr.contains("Repository not found")) { throw new AccessValidationException(stdErr); } } /** * Create a refspec from a string */ public Refspec createRefSpec(String ref) throws ValidationException { // Validate refspec return Refspec.create(gitEnv, gitDir, ref); } @CheckReturnValue public LogCmd log(String referenceExpr) { return LogCmd.create(this, referenceExpr); } @CheckReturnValue public PushCmd push() { return new PushCmd(this, /*url=*/null, ImmutableList.of(), /*prune=*/false); } @CheckReturnValue public TagCmd tag(String tagName) { return new TagCmd(this, tagName, /*tagMessage=*/null, false); } /** * Runs a git ls-remote from the current directory for a repository url. Assumes the path to the * git binary is already set. You don't have to be in a git repository to run this command. Does * not work with remote names. * * @param url - see <repository> in git help ls-remote * @param refs - see <refs> in git help ls-remote * @param gitEnv - determines where the Git binaries are * @param maxLogLines - Limit log lines to the number specified. -1 for unlimited * @return - a map of refs to sha1 from the git ls-remote output. * @throws RepoException if the operation fails */ public static Map<String, String> lsRemote( String url, Collection<String> refs, GitEnvironment gitEnv, int maxLogLines) throws RepoException { return lsRemote(FileSystems.getDefault().getPath("."), url, refs, gitEnv, maxLogLines); } private static Map<String, String> lsRemote(Path cwd, String url, Collection<String> refs, GitEnvironment gitEnv, int maxLogLines) throws RepoException { ImmutableMap.Builder<String, String> result = ImmutableMap.builder(); List<String> args; try { args = Lists.newArrayList("ls-remote", validateUrl(url)); } catch (ValidationException e) { throw new RepoException("Invalid url: " + url, e); } args.addAll(refs); CommandOutputWithStatus output; try { output = executeGit(cwd, args, gitEnv, false, maxLogLines); } catch (BadExitStatusWithOutputException e) { throw new RepoException( String.format("Error running ls-remote for '%s' and refs '%s': Exit code %s, Output:\n%s", url, refs, e.getOutput().getTerminationStatus().getExitCode(), e.getOutput().getStderr()), e); } catch (CommandException e) { throw new RepoException( String.format("Error running ls-remote for '%s' and refs '%s'", url, refs), e); } if (output.getTerminationStatus().success()) { for (String line : Splitter.on('\n').split(output.getStdout())) { if (line.isEmpty()) { continue; } Matcher matcher = LS_REMOTE_OUTPUT_LINE.matcher(line); if (!matcher.matches()) { throw new RepoException("Unexpected format for ls-remote output: " + line); } result.put(matcher.group(2), matcher.group(1)); } } return result.build(); } /** * Same as {@link #lsRemote(String, Collection, GitEnvironment, int)} but using this repository * environment and {@link #DEFAULT_MAX_LOG_LINES} as max number of log lines. * * @param refs - see <refs> in git help ls-remote * @return - a map of refs to sha1 from the git ls-remote output. * @throws RepoException if the operation fails */ public Map<String, String> lsRemote(String url, Collection<String> refs) throws RepoException { return lsRemote(getCwd(), url, refs, gitEnv, DEFAULT_MAX_LOG_LINES); } /** * Same as {@link #lsRemote(String, Collection, GitEnvironment, int)} but using this repository * environment and explicit max number of log lines. * * @param refs - see <refs> in git help ls-remote * @param maxLogLines - Limit log lines to the number specified. -1 for unlimited * @return - a map of refs to sha1 from the git ls-remote output. * @throws RepoException if the operation fails */ public Map<String, String> lsRemote(String url, Collection<String> refs, int maxLogLines) throws RepoException { return lsRemote(url, refs, gitEnv, maxLogLines); } @CheckReturnValue static String validateUrl(String url) throws RepoException, ValidationException { RepositoryUtil.validateNotHttp(url); if (FULL_URI.matcher(url).matches()) { return url; } // Support local folders if (Files.isDirectory(Paths.get(url))) { return url; } throw new RepoException(String.format("URL '%s' is not valid", url)); } /** * Execute show-ref git command in the local repository and returns a map from reference name to * GitReference(SHA-1). */ private ImmutableMap<String, GitRevision> showRef(Iterable<String> refs) throws RepoException { ImmutableMap.Builder<String, GitRevision> result = ImmutableMap.builder(); CommandOutput commandOutput = gitAllowNonZeroExit(NO_INPUT, ImmutableList.<String>builder().add("show-ref").addAll(refs).build(), DEFAULT_TIMEOUT); if (!commandOutput.getStderr().isEmpty()) { throw new RepoException(String.format( "Error executing show-ref on %s git repo:\n%s", getGitDir(), commandOutput.getStderr())); } for (String line : Splitter.on('\n').split(commandOutput.getStdout())) { if (line.isEmpty()) { continue; } List<String> strings = Splitter.on(' ').splitToList(line); Preconditions.checkState(strings.size() == 2 && SHA1_PATTERN.matcher(strings.get(0)).matches(), "Cannot parse line: '%s'", line); // Ref -> SHA1 result.put(strings.get(1), new GitRevision(this, strings.get(0))); } return result.build(); } /** * Execute show-ref git command in the local repository and returns a map from reference name to * GitReference(SHA-1). */ ImmutableMap<String, GitRevision> showRef() throws RepoException { return showRef(ImmutableList.of()); } String mergeBase(String commit1, String commit2) throws RepoException { return simpleCommand("merge-base", commit1, commit2).getStdout().trim(); } boolean isAncestor(String ancestor, String commit) throws RepoException { CommandOutputWithStatus result = gitAllowNonZeroExit( NO_INPUT, ImmutableList.of("merge-base", "--is-ancestor", "--", ancestor, commit), DEFAULT_TIMEOUT); if (result.getTerminationStatus().success()) { return true; } if (result.getTerminationStatus().getExitCode() == 1) { return false; } throw new RepoException("Error executing git merge-base --is-ancestor:\n" + result.getStderr()); } /** * Returns an instance equivalent to this one but with a different work tree. This does not * initialize or alter the given work tree. */ public GitRepository withWorkTree(Path newWorkTree) { return new GitRepository( this.gitDir, newWorkTree, this.verbose, this.gitEnv, fetchTimeout, this.noVerify); } /** * The Git work tree - in a typical Git repo, this is the directory containing the {@code .git} * directory. Returns {@code null} for bare repos. */ @Nullable public Path getWorkTree() { return workTree; } public Path getGitDir() { return gitDir; } /** * Can be overwritten to add custom behavior. */ protected String runPush(PushCmd pushCmd) throws RepoException, ValidationException { List<String> cmd = Lists.newArrayList("push"); // This shows progress in the log if not attached to a terminal cmd.add("--progress"); if (pushCmd.prune) { cmd.add("--prune"); } if (noVerify) { cmd.add("--no-verify"); } if (pushCmd.url != null) { cmd.add(validateUrl(pushCmd.url)); for (Refspec refspec : pushCmd.refspecs) { cmd.add(refspec.toString()); } } return simpleCommand(cmd.toArray(new String[0])).getStderr(); } /** * An add command bound to the repo that can be configured and then executed with {@link #run()}. */ public class AddCmd { private final boolean force; private final boolean all; private final Iterable<String> files; private AddCmd(boolean force, boolean all, Iterable<String> files) { this.force = force; this.all = all; this.files = checkNotNull(files); } /** Force the add */ @CheckReturnValue public AddCmd force() { return new AddCmd(/*force=*/true, all, files); } /** Add all the unstagged files to the index */ @CheckReturnValue public AddCmd all() { Preconditions.checkState(Iterables.isEmpty(files), "'all' and passing files is incompatible"); return new AddCmd(force, /*all=*/true, files); } /** Configure the files to add to the index */ @CheckReturnValue public AddCmd files(Iterable<String> files) { Preconditions.checkState(!all, "'all' and passing files is incompatible"); return new AddCmd(force, /*all=*/false, files); } /** Configure the files to add to the index */ @CheckReturnValue public AddCmd files(String... files) { return files(ImmutableList.copyOf(files)); } /** Run the git command */ public void run() throws RepoException { List<String> params = Lists.newArrayList("add"); if (force) { params.add("-f"); } if (all) { params.add("--all"); } params.add("--"); Iterables.addAll(params, files); git(getCwd(), addGitDirAndWorkTreeParams(params)); } } /** * Create a git add command that can be configured before execution. */ @CheckReturnValue public AddCmd add() { return new AddCmd(/*force*/false, /*all*/false, /*files*/ImmutableSet.of()); } /** * Get a field from a configuration {@code configFile} relative to {@link #getWorkTree()}. * * <p>If {@code configFile} is null it uses configuration (local or global). * TODO(malcon): Refactor this to work similar to LogCmd. */ @Nullable private String getConfigField(String field, @Nullable String configFile) throws RepoException { ImmutableList.Builder<String> params = ImmutableList.builder(); params.add("config"); if (configFile != null) { params.add("-f", configFile); } params.add("--get"); params.add(field); CommandOutputWithStatus out = gitAllowNonZeroExit(NO_INPUT, params.build(), DEFAULT_TIMEOUT); if (out.getTerminationStatus().success()) { return out.getStdout().trim(); } else if (out.getTerminationStatus().getExitCode() == 1 && out.getStderr().isEmpty()) { return null; } throw new RepoException("Error executing git config:\n" + out.getStderr()); } private ImmutableSet<String> getSubmoduleNames() throws RepoException { // No submodules if (!Files.exists(getCwd().resolve(".gitmodules"))) { return ImmutableSet.of(); } ImmutableList.Builder<String> params = ImmutableList.builder(); params.add("config", "-f", ".gitmodules", "-l", "--name-only"); CommandOutputWithStatus out = gitAllowNonZeroExit(NO_INPUT, params.build(), DEFAULT_TIMEOUT); if (out.getTerminationStatus().success()) { Set<String> modules = new LinkedHashSet<>(); for (String line : Splitter.on('\n').omitEmptyStrings().trimResults().split( out.getStdout().trim())) { if (!line.startsWith("submodule.")) { continue; } modules.add(Splitter.on('.').splitToList(line).get(1)); } return ImmutableSet.copyOf(modules); } else if (out.getTerminationStatus().getExitCode() == 1 && out.getStderr().isEmpty()) { return ImmutableSet.of(); } throw new RepoException("Error executing git config:\n" + out.getStderr()); } /** * Resolves a git reference to the SHA-1 reference */ public String parseRef(String ref) throws RepoException, CannotResolveRevisionException { // Runs rev-list on the reference and remove the extra newline from the output. CommandOutputWithStatus result = gitAllowNonZeroExit( NO_INPUT, ImmutableList.of("rev-list", "-1", ref, "--"), DEFAULT_TIMEOUT); if (!result.getTerminationStatus().success()) { throw new CannotResolveRevisionException("Cannot find reference '" + ref + "'"); } String sha1 = result.getStdout().trim(); Verify.verify(SHA1_PATTERN.matcher(sha1).matches(), "Should be resolved to a SHA-1: %s", sha1); return sha1; } boolean refExists(String ref) throws RepoException { try { parseRef(ref); return true; } catch (CannotResolveRevisionException e) { return false; } } public void rebase(String newBaseline) throws RepoException, RebaseConflictException { CommandOutputWithStatus output = gitAllowNonZeroExit( NO_INPUT, ImmutableList.of("rebase", checkNotNull(newBaseline)), DEFAULT_TIMEOUT); if (output.getTerminationStatus().success()) { return; } if (FAILED_REBASE.matcher(output.getStderr()).find()) { throw new RebaseConflictException( String.format( "" + "Conflict detected while rebasing %s to %s. Please sync or update the change " + "in the origin and retry. Git output was:\n%s", workTree, newBaseline, output.getStdout())); } throw new RepoException(output.getStderr()); } /** * Checks out the given ref in the repo, quietly and throwing away local changes. If checkoutPath * is empty, it will checkout all files. If not, it will only checkout checkoutPaths */ public CommandOutput forceCheckout(String ref, ImmutableSet<String> checkoutPaths) throws RepoException { ImmutableList.Builder<String> argv = ImmutableList.builder(); argv.add("checkout", "-q", "-f", checkNotNull(ref)); argv.addAll(checkoutPaths.stream().filter(e -> !e.isEmpty()).collect(Collectors.toList())); return simpleCommand(argv.build()); } /** * Set the sparse checkout */ public CommandOutput setSparseCheckout(ImmutableSet<String> checkoutPaths) throws RepoException { ImmutableList.Builder<String> argv = ImmutableList.builder(); argv.add("sparse-checkout", "set"); argv.addAll(checkoutPaths.stream().filter(e -> !e.isEmpty()).collect(Collectors.toList())); return simpleCommand(argv.build()); } /** * Checks out the given ref in the repo, quietly and throwing away local changes. */ public CommandOutput forceCheckout(String ref) throws RepoException { return simpleCommand("checkout", "-q", "-f", checkNotNull(ref)); } // DateTimeFormatter.ISO_OFFSET_DATE_TIME might include subseconds, but Git's ISO8601 format does // not deal with subseconds (see https://git-scm.com/docs/git-commit#git-commit-ISO8601). // We still want to stick to the default ISO format in Git, but don't add the subseconds. private static final DateTimeFormatter ISO_OFFSET_DATE_TIME_NO_SUBSECONDS = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ssZ"); // The effective bytes that can be used for command-line arguments is ~128k. Setting an arbitrary // max for the description of 64k private static final int ARBITRARY_MAX_ARG_SIZE = 64_000; public void commit(String author, ZonedDateTime timestamp, String message) throws RepoException, ValidationException { commit(checkNotNull(author), /*amend=*/false, checkNotNull(timestamp), checkNotNull(message)); } // TODO(malcon): Create a CommitCmd object builder public void commit(@Nullable String author, boolean amend, @Nullable ZonedDateTime timestamp, String message) throws RepoException, ValidationException { if (isEmptyStaging() && !amend) { throw new EmptyChangeException( String.format( "Migration of the revision resulted in an empty change from baseline '%s'.\n" + "Is the change already migrated?", parseRef("HEAD"))); } ImmutableList.Builder<String> params = ImmutableList.<String>builder().add("commit"); if (author != null) { params.add("--author", author); } if (timestamp != null) { params.add("--date", timestamp.format(ISO_OFFSET_DATE_TIME_NO_SUBSECONDS)); } if (amend) { params.add("--amend"); } if (noVerify) { params.add("--no-verify"); } Path descriptionFile = null; try { if (message.getBytes(StandardCharsets.UTF_8).length > ARBITRARY_MAX_ARG_SIZE) { descriptionFile = getCwd().resolve(UUID.randomUUID().toString() + ".desc"); Files.write(descriptionFile, message.getBytes(StandardCharsets.UTF_8)); params.add("-F", descriptionFile.toAbsolutePath().toString()); } else { params.add("-m", message); } git(getCwd(), addGitDirAndWorkTreeParams(params.build())); } catch (IOException e) { throw new RepoException( "Could not commit change: Failed to write file " + descriptionFile, e); } finally { try { if (descriptionFile != null) { Files.deleteIfExists(descriptionFile); } } catch (IOException e) { logger.atWarning().log("Could not delete description file: %s", descriptionFile); } } } /** * Check if staging is empty. That means that a commit would fail with EmptyCommitException. */ private boolean isEmptyStaging() throws RepoException { CommandOutput status = simpleCommand("diff", "--staged", "--stat"); return status.getStdout().trim().isEmpty(); } public List<StatusFile> status() throws RepoException { CommandOutput output = git(getCwd(), addGitDirAndWorkTreeParams(ImmutableList.of("status", "--porcelain"))); ImmutableList.Builder<StatusFile> builder = ImmutableList.builder(); for (String line : Splitter.on('\n').split(output.getStdout())) { if (line.isEmpty()) { continue; } // Format 'XY file (-> file)?' List<String> split = Splitter.on(" -> ").limit(2).splitToList(line.substring(3)); String fileName; String newFileName; if (split.size() == 1) { fileName = split.get(0); newFileName = null; } else { fileName = split.get(0); newFileName = split.get(1); } builder.add( new StatusFile(fileName, newFileName, toStatusCode(line.charAt(0)), toStatusCode(line.charAt(1)))); } return builder.build(); } private StatusCode toStatusCode(char c) { return checkNotNull(CHAR_TO_STATUS_CODE.get(c), "Cannot find status code for '%s'", c); } /** * Find submodules information for the current repository. * * @param currentRemoteUrl remote url associated with the repository. It will be used to * resolve relative URLs (for example: url = ../foo). */ Iterable<Submodule> listSubmodules(String currentRemoteUrl) throws RepoException { ImmutableList.Builder<Submodule> result = ImmutableList.builder(); for (String submoduleName : getSubmoduleNames()) { String path = getSubmoduleField(submoduleName, "path"); if (path == null) { throw new RepoException("Path is required for submodule " + submoduleName); } String url = getSubmoduleField(submoduleName, "url"); if (url == null) { throw new RepoException("Url is required for submodule " + submoduleName); } String branch = getSubmoduleField(submoduleName, "branch"); if (branch != null && branch.equals(".")) { branch = "HEAD"; } FileUtil.checkNormalizedRelative(path); // If the url is relative, construct a url using the parent module remote url. if (url.startsWith("../")) { url = siblingUrl(currentRemoteUrl, submoduleName, url.substring(3)); } else if (url.startsWith("./")) { url = siblingUrl(currentRemoteUrl, submoduleName, url.substring(2)); } try { result.add(new Submodule(validateUrl(url), submoduleName, branch, path)); } catch (ValidationException e) { throw new RepoException("Invalid url: " + url, e); } } return result.build(); } // TODO(malcon): Create a builder like LogCmd, etc. ImmutableList<TreeElement> lsTree(GitRevision reference, @Nullable String treeish, boolean recursive, boolean fullName) throws RepoException { ImmutableList.Builder<TreeElement> result = ImmutableList.builder(); List<String> args = Lists.newArrayList("ls-tree", reference.getSha1()); if (recursive) { args.add("-r"); } if (fullName) { args.add("--full-name"); } if (treeish != null) { args.add("--"); args.add(treeish); } String stdout = simpleCommand(args).getStdout(); for (String line : Splitter.on('\n').split(stdout)) { if (line.isEmpty()) { continue; } Matcher matcher = LS_TREE_ELEMENT.matcher(line); if (!matcher.matches()) { throw new RepoException("Unexpected format for ls-tree output: " + line); } // We ignore the mode for now GitObjectType objectType = GitObjectType.valueOf(matcher.group(2).toUpperCase()); String sha1 = matcher.group(3); String path = matcher.group(4) // Per ls-tree documentation. Replace those escaped characters. .replace("\\\\", "\\").replace("\\t", "\t").replace("\\n", "\n"); result.add(new TreeElement(objectType, sha1, path)); } return result.build(); } private String siblingUrl(String currentRemoteUrl, String submoduleName, String relativeUrl) throws RepoException { int idx = currentRemoteUrl.lastIndexOf('/'); if (idx == -1) { throw new RepoException(String.format( "Cannot find the parent url for '%s'. But git submodule '%s' is" + " configured with url '%s'", currentRemoteUrl, submoduleName, relativeUrl)); } return currentRemoteUrl.substring(0, idx) + "/" + relativeUrl; } private String getSubmoduleField(String submoduleName, String field) throws RepoException { return getConfigField("submodule." + submoduleName + "." + field, ".gitmodules"); } private Path getCwd() { return workTree != null ? workTree : gitDir; } private List<String> addGitDirAndWorkTreeParams(Iterable<String> argv) { Preconditions.checkState(Files.isDirectory(gitDir), "git repository dir '%s' doesn't exist or is not a directory", gitDir); List<String> allArgv = Lists.newArrayList("--git-dir=" + gitDir); if (workTree != null) { allArgv.add("--work-tree=" + workTree); } Iterables.addAll(allArgv, argv); return allArgv; } public GitRepository init() throws RepoException { try { Files.createDirectories(gitDir); if (workTree != null) { Files.createDirectories(workTree); } } catch (IOException e) { throw new RepoException("Cannot create directories: " + e.getMessage(), e); } if (workTree != null && workTree.resolve(".git").equals(gitDir)) { git(workTree, ImmutableList.of("init", ".")); } else { git(gitDir, ImmutableList.of("init", "--bare")); } return this; } public GitRepository withCredentialHelper(String credentialHelper) throws RepoException { git(gitDir, ImmutableList.of("config", "--local", "credential.helper", checkNotNull(credentialHelper))); return this; } public GitRepository withPartialClone() { try { this.simpleCommand("config", "extensions.partialClone", "true"); } catch (Exception e) { logger.atInfo().withCause(e).log("Partial Clone %s", e); } return new GitRepository(gitDir, workTree, verbose, gitEnv, fetchTimeout, noVerify); } public UserPassword credentialFill(String url) throws RepoException, ValidationException { return new GitCredential(gitEnv.resolveGitBinary(), Duration.ofMinutes(1), gitEnv) .fill(gitDir, url); } /** * Runs a {@code git} command with the {@code --git-dir} and (if non-bare) {@code --work-tree} * args set, and returns the {@link CommandOutput} if the command execution was successful. * * <p>Git commands usually write to stdout, but occasionally they write to stderr. It's * responsibility of the client to consume the output from the correct source. * * <p>WARNING: Please consider creating a higher level function instead of calling this method. * At some point we will deprecate. * * @param argv the arguments to pass to {@code git}, starting with the sub-command name */ public CommandOutput simpleCommand(String... argv) throws RepoException { return simpleCommand(Arrays.asList(argv)); } public CommandOutput simpleCommand(List<String> argv) throws RepoException { return git(getCwd(), addGitDirAndWorkTreeParams(argv)); } CommandOutput simpleCommandNoRedirectOutput(String... argv) throws RepoException { Iterable<String> params = addGitDirAndWorkTreeParams(Arrays.asList(argv)); try { // Use maxLoglines 0 and verbose=false to avoid redirection return executeGit(getCwd(), params, gitEnv, /*verbose*/ false, /*maxLoglines*/ 0); } catch (BadExitStatusWithOutputException e) { CommandOutputWithStatus output = e.getOutput(); for (Pattern error : REF_NOT_FOUND_ERRORS) { Matcher matcher = error.matcher(output.getStderr()); if (matcher.find()) { throw new RepoException( "Cannot find reference '" + matcher.group(1) + "'"); } } throw throwUnknownGitError(output, params); } catch (CommandException e) { throw new RepoException("Error executing 'git': " + e.getMessage(), e); } } void forceClean() throws RepoException { Preconditions.checkNotNull(workTree, "Clean only acts on the worktree. A worktree is needed"); // Force clean and also untracked directories. simpleCommand("clean", "-f", "-d"); } /** * Execute git apply. * * @param index if true we pass --index to the git command * @throws RebaseConflictException if it cannot apply the change. */ public void apply(byte[] stdin, boolean index) throws RepoException, RebaseConflictException { CommandOutputWithStatus output = gitAllowNonZeroExit(stdin, index ? ImmutableList.of("apply", "--index") : ImmutableList.of("apply"), DEFAULT_TIMEOUT); if (output.getTerminationStatus().success()) { return; } if (output.getTerminationStatus().getExitCode() == 1) { throw new RebaseConflictException("Couldn't apply patch:\n" + output.getStderr()); } throw new RepoException("Couldn't apply patch:\n" + output.getStderr()); } /** * Invokes {@code git} in the directory given by {@code cwd} against this repository and returns * the {@link CommandOutput} if the command execution was successful. * * <p>Git commands usually write to stdout, but occasionally they write to stderr. It's * responsibility of the client to consume the output from the correct source. * * @param cwd the directory in which to execute the command * @param params the argv to pass to Git, excluding the initial {@code git} */ public CommandOutput git(Path cwd, String... params) throws RepoException { return git(cwd, Arrays.asList(params)); } /** * Invokes {@code git} in the directory given by {@code cwd} against this repository and returns * the {@link CommandOutput} if the command execution was successful. * * <p>Git commands usually write to stdout, but occasionally they write to stderr. It's * responsibility of the client to consume the output from the correct source. * * <p>See also {@link #git(Path, String[])}. * * @param cwd the directory in which to execute the command * @param params params the argv to pass to Git, excluding the initial {@code git} */ private CommandOutput git(Path cwd, Iterable<String> params) throws RepoException { try { return executeGit(cwd, params, gitEnv, verbose); } catch (BadExitStatusWithOutputException e) { CommandOutputWithStatus output = e.getOutput(); for (Pattern error : REF_NOT_FOUND_ERRORS) { Matcher matcher = error.matcher(output.getStderr()); if (matcher.find()) { throw new RepoException( "Cannot find reference '" + matcher.group(1) + "'"); } } throw throwUnknownGitError(output, params); } catch (CommandException e) { throw new RepoException("Error executing 'git': " + e.getMessage(), e); } } private RepoException throwUnknownGitError( CommandOutputWithStatus output, Iterable<String> params) throws RepoException { throw new RepoException( String.format( "Error executing 'git %s'(exit code %d). Stderr: %s\n", Joiner.on(' ').join(params), output.getTerminationStatus().getExitCode(), output.getStderr())); } /** * Execute git allowing non-zero exit codes. This will only allow program non-zero exit codes * (0-10. The upper bound is arbitrary). And will still fail for exit codes like 127 (Command not * found). */ private CommandOutputWithStatus gitAllowNonZeroExit(byte[] stdin, Iterable<String> params, Duration defaultTimeout) throws RepoException { try { List<String> allParams = new ArrayList<>(); allParams.add(gitEnv.resolveGitBinary()); allParams.addAll(addGitDirAndWorkTreeParams(params)); Command cmd = new Command( Iterables.toArray(allParams, String.class), gitEnv.getEnvironment(), getCwd().toFile()); return new CommandRunner(cmd, defaultTimeout) .withVerbose(verbose) .withInput(stdin) .execute(); } catch (BadExitStatusWithOutputException e) { CommandOutputWithStatus output = e.getOutput(); int exitCode = e.getOutput().getTerminationStatus().getExitCode(); if (NON_CRASH_ERROR_EXIT_CODES.contains(exitCode)) { return output; } throw throwUnknownGitError(output, params); } catch (CommandException e) { throw new RepoException("Error executing 'git': " + e.getMessage(), e); } } private static CommandOutputWithStatus executeGit( Path cwd, Iterable<String> params, GitEnvironment gitEnv, boolean verbose) throws CommandException { return executeGit(cwd, params, gitEnv, verbose, DEFAULT_MAX_LOG_LINES); } private static CommandOutputWithStatus executeGit( Path cwd, Iterable<String> params, GitEnvironment gitEnv, boolean verbose, int maxLogLines) throws CommandException { List<String> allParams = new ArrayList<>(Iterables.size(params) + 1); allParams.add(gitEnv.resolveGitBinary()); Iterables.addAll(allParams, params); Command cmd = new Command( Iterables.toArray(allParams, String.class), gitEnv.getEnvironment(), cwd.toFile()); CommandRunner runner = new CommandRunner(cmd).withVerbose(verbose); return maxLogLines >= 0 ? runner.withMaxStdOutLogLines(maxLogLines).execute() : runner.execute(); } @Override public String toString() { return MoreObjects.toStringHelper(this) .add("gitDir", gitDir) .add("workTree", workTree) .add("verbose", verbose) .toString(); } /** * Resolve a reference * * @throws CannotResolveRevisionException if it cannot resolve the reference */ GitRevision resolveReferenceWithContext(String reference, @Nullable String contextRef, String url) throws RepoException, CannotResolveRevisionException { // Nothing needs to be resolved, since it is a complete SHA-1. But we // check that the reference exists. if (GitRevision.COMPLETE_SHA1_PATTERN.matcher(reference).matches()) { if (checkSha1Exists(reference)) { return new GitRevision(this, reference); } throw new CannotResolveRevisionException( "Cannot find '" + reference + "' object in the repository"); } return new GitRevision(this, parseRef(reference), /*reviewReference=*/null, contextRef, ImmutableListMultimap.of(), url); } /** * Resolve a reference * * @throws CannotResolveRevisionException if it cannot resolve the reference */ public GitRevision resolveReference(String reference) throws RepoException, CannotResolveRevisionException { // Nothing needs to be resolved, since it is a complete SHA-1. But we // check that the reference exists. if (GitRevision.COMPLETE_SHA1_PATTERN.matcher(reference).matches()) { if (checkSha1Exists(reference)) { return new GitRevision(this, reference); } throw new CannotResolveRevisionException( "Cannot find '" + reference + "' object in the repository"); } return new GitRevision(this, parseRef(reference)); } /** * Checks if a SHA-1 object exist in the repository */ private boolean checkSha1Exists(String reference) throws RepoException { ImmutableList<String> params = ImmutableList.of("cat-file", "-e", reference); CommandOutputWithStatus output = gitAllowNonZeroExit(NO_INPUT, params, DEFAULT_TIMEOUT); if (output.getTerminationStatus().success()) { return true; } if (output.getStderr().isEmpty()) { return false; } throw throwUnknownGitError(output, params); } /** * Resolves a git reference to the SHA-1 reference */ public String readFile(String revision, String path) throws RepoException { CommandOutputWithStatus result = gitAllowNonZeroExit(NO_INPUT, ImmutableList.of("--no-pager", "show", String.format("%s:%s", revision, path)), DEFAULT_TIMEOUT); if (!result.getTerminationStatus().success()) { throw new RepoException(String.format("Cannot read file '%s' in '%s'", path, revision)); } return result.getStdout(); } public void checkout(Glob glob, Path destRoot, GitRevision rev) throws RepoException { ImmutableList<TreeElement> treeElements = lsTree(rev, null, true, true); PathMatcher pathMatcher = glob.relativeTo(destRoot); for (TreeElement file : treeElements) { Path path = destRoot.resolve(file.getPath()); if (pathMatcher.matches(path)) { try { Files.write( path, readFile(rev.getSha1(), file.getPath()).getBytes(StandardCharsets.UTF_8)); } catch (IOException e) { throw new RepoException(String .format("Cannot write '%s' from reference '%s' into '%s'", file.getPath(), rev, path), e); } } } } GitRevision commitTree(String message, String tree, List<GitRevision> parents) throws RepoException { ImmutableList.Builder<String> args = ImmutableList.<String>builder().add("commit-tree", tree); for (GitRevision parent : parents) { args.add("-p", parent.getSha1()); } args.add("-m", message); return new GitRevision(this, git(getCwd(), addGitDirAndWorkTreeParams(args.build())).getStdout().trim()); } /** * Creates a reference from a complete SHA-1 string without any validation that it exists. */ private GitRevision createReferenceFromCompleteSha1(String ref) { return new GitRevision(this, ref); } private boolean isSha1Reference(String ref) { return SHA1_PATTERN.matcher(ref).matches(); } /** * Information of a submodule of {@code this} repository. */ public static class Submodule { private final String url; private final String name; @Nullable private final String branch; private final String path; private Submodule(String url, String name, String branch, String path) { this.url = url; this.name = name; this.branch = branch; this.path = path; } /** * Resolved submodule URL. Urls like './foo' have been already resolved to its corresponding * absolute one. */ public String getUrl() { return url; } /** Name of the submodule. */ public String getName() { return name; } /** * Branch associated with the submodule. Supported values: null or '.' (HEAD is used) or * a regular reference. */ @Nullable public String getBranch() { return branch; } /** Relative path for the checkout of the submodule */ public String getPath() { return path; } @Override public String toString() { return MoreObjects.toStringHelper(this) .add("url", url) .add("name", name) .add("branch", branch) .add("path", path) .toString(); } } static class TreeElement { private final GitObjectType type; private final String ref; private final String path; private TreeElement(GitObjectType type, String ref, String path) { this.type = checkNotNull(type); this.ref = checkNotNull(ref); this.path = checkNotNull(path); } GitObjectType getType() { return type; } public String getRef() { return ref; } public String getPath() { return path; } @Override public String toString() { return MoreObjects.toStringHelper(this) .add("type", type) .add("ref", ref) .add("file", path) .toString(); } } enum GitObjectType { BLOB, COMMIT, TAG, TREE } static final class StatusFile { private final String file; @Nullable private final String newFileName; private final StatusCode indexStatus; private final StatusCode workdirStatus; @VisibleForTesting StatusFile(String file, @Nullable String newFileName, StatusCode indexStatus, StatusCode workdirStatus) { this.file = checkNotNull(file); this.newFileName = newFileName; this.indexStatus = checkNotNull(indexStatus); this.workdirStatus = checkNotNull(workdirStatus); } public String getFile() { return file; } @Nullable String getNewFileName() { return newFileName; } StatusCode getIndexStatus() { return indexStatus; } StatusCode getWorkdirStatus() { return workdirStatus; } @Override public boolean equals(Object o) { if (this == o) { return true; } if (o == null || getClass() != o.getClass()) { return false; } StatusFile that = (StatusFile) o; return Objects.equals(file, that.file) && Objects.equals(newFileName, that.newFileName) && indexStatus == that.indexStatus && workdirStatus == that.workdirStatus; } @Override public int hashCode() { return Objects.hash(file, newFileName, indexStatus, workdirStatus); } @Override public String toString() { return Character.toString(indexStatus.getCode()) + getWorkdirStatus().getCode() + " " + file + (newFileName != null ? " -> " + newFileName : ""); } } enum StatusCode { UNMODIFIED(' '), MODIFIED('M'), ADDED('A'), DELETED('D'), RENAMED('R'), COPIED('C'), UPDATED_BUT_UNMERGED('U'), UNTRACKED('?'), IGNORED('!'), CHANGE_TYPE('T'); private final char code; public char getCode() { return code; } StatusCode(char code) { this.code = code; } } /** * An object capable of performing a 'git push' operation to a remote repository. */ public static class PushCmd { private final GitRepository repo; @Nullable private final String url; private final ImmutableList<Refspec> refspecs; private final boolean prune; @Nullable public String getUrl() { return url; } public ImmutableList<Refspec> getRefspecs() { return refspecs; } public boolean isPrune() { return prune; } @CheckReturnValue public PushCmd(GitRepository repo, @Nullable String url, ImmutableList<Refspec> refspecs, boolean prune) { this.repo = checkNotNull(repo); this.url = url; this.refspecs = checkNotNull(refspecs); Preconditions.checkArgument(refspecs.isEmpty() || url != null, "refspec can only be" + " used when a url is passed"); this.prune = prune; } @CheckReturnValue public PushCmd withRefspecs(String url, Iterable<Refspec> refspecs) { return new PushCmd(repo, checkNotNull(url), ImmutableList.copyOf(refspecs), prune); } @CheckReturnValue public PushCmd prune(boolean prune) { return new PushCmd(repo, url, this.refspecs, prune); } /** * Runs the push command and returns the response from the server. */ public String run() throws RepoException, ValidationException { String output = repo.runPush(this); checkCondition( !PROTECTED_BRANCH.matcher(output).find(), "Cannot push to %s refspecs %s. Please request an admin of the repo to verify the " + "branch protection rules at %s/settings/branches if you think it's a legit branch.", url, refspecs, url); return output; } } /** * An object capable of performing a 'git log' operation on a repository and returning a list * of {@link GitLogEntry}. * * <p>By default it returns the body, doesn't include the changed files and does --first-parent. */ public static class LogCmd { private static final String COMMIT_FIELD = "commit"; private static final String PARENTS_FIELD = "parents"; private static final String TREE_FIELD = "tree"; private static final String AUTHOR_FIELD = "author"; private static final String AUTHOR_DATE_FIELD = "author_date"; private static final String COMMITTER_FIELD = "committer"; private static final String COMMITTER_DATE = "committer_date"; private static final String BEGIN_BODY = "begin_body"; private static final String END_BODY = "end_body"; private static final String COMMIT_SEPARATOR = "\u0001copybara\u0001"; private static final Pattern UNINDENT = Pattern.compile("\n "); private static final String GROUP = "--\n"; private final int limit; private final ImmutableCollection<String> paths; private final String refExpr; private final boolean includeStat; private final boolean includeBody; private final boolean includeMergeDiff; private final boolean firstParent; private final int skip; private final GitRepository repo; @Nullable private final String grepString; @CheckReturnValue LogCmd(GitRepository repo, String refExpr, int limit, ImmutableCollection<String> paths, boolean firstParent, boolean includeStat, boolean includeBody, @Nullable String grepString, boolean includeMergeDiff, int skip) { this.limit = limit; this.paths = paths; this.refExpr = refExpr; this.firstParent = firstParent; this.includeStat = includeStat; this.includeMergeDiff = includeMergeDiff; this.includeBody = includeBody; this.repo = repo; this.grepString = grepString; this.skip = skip; } static LogCmd create(GitRepository repository, String refExpr) { return new LogCmd( checkNotNull(repository), checkNotNull(refExpr), 0, ImmutableList.of(), /*firstParent*/ true, /* includeStat= */ false, /*includeBody=*/ true, /*grepString=*/ null, /*includeMergeDiff=*/ false, /*skip=*/0); } /** * Limit the query to {@code limit} results. Should be > 0. */ @CheckReturnValue public LogCmd withLimit(int limit) { Preconditions.checkArgument(limit > 0); return new LogCmd(repo, refExpr, limit, paths, firstParent, includeStat, includeBody, grepString, includeMergeDiff, skip); } /** * Skip the first {@code skip} commits. Should be >= 0. */ @CheckReturnValue LogCmd withSkip(int skip) { Preconditions.checkArgument(skip >= 0); return new LogCmd(repo, refExpr, limit, paths, firstParent, includeStat, includeBody, grepString, includeMergeDiff, skip); } /** * Only query for changes in {@code paths} paths. */ @CheckReturnValue LogCmd withPaths(ImmutableCollection<String> paths) { Preconditions.checkArgument(paths.stream().noneMatch(s -> s.trim().equals(""))); return new LogCmd(repo, refExpr, limit, paths, firstParent, includeStat, includeBody, grepString, includeMergeDiff, skip); } /** * Set if --first-parent should be used in 'git log'. */ @CheckReturnValue LogCmd firstParent(boolean firstParent) { return new LogCmd(repo, refExpr, limit, paths, firstParent, includeStat, includeBody, grepString, includeMergeDiff, skip); } /** * If files affected by the commit should be included in the response. */ @CheckReturnValue LogCmd includeFiles(boolean includeStat) { return new LogCmd(repo, refExpr, limit, paths, firstParent, includeStat, includeBody, grepString, includeMergeDiff, skip); } /** * If file diff should be shown for merges. Equivalent to 'git log -m' command. */ @CheckReturnValue LogCmd includeMergeDiff(boolean includeMergeDiff) { return new LogCmd(repo, refExpr, limit, paths, firstParent, includeStat, includeBody, grepString, includeMergeDiff, skip); } /** * If the body (commit message) should be included in the response. */ @CheckReturnValue LogCmd includeBody(boolean includeBody) { return new LogCmd(repo, refExpr, limit, paths, firstParent, includeStat, includeBody, grepString, includeMergeDiff, skip); } /** * Look only for messages thatMatches grep expression. */ @CheckReturnValue public LogCmd grep(@Nullable String grepString) { return new LogCmd(repo, refExpr, limit, paths, firstParent, includeStat, includeBody, grepString, includeMergeDiff, skip); } /** * Run 'git log' and returns zero or more {@link GitLogEntry}. */ public ImmutableList<GitLogEntry> run() throws RepoException { List<String> cmd = Lists.newArrayList("log", "--no-color", createFormat(includeBody)); if (limit > 0) { cmd.add("-" + limit); } if (includeStat) { cmd.add("--name-only"); // Don't show changes as renames, otherwise --name-only shows only the new name. cmd.add("--no-renames"); } if (firstParent) { cmd.add("--first-parent"); } if (includeMergeDiff) { cmd.add("-m"); } // Without this flag, non-ascii characters in file names are returned wrapped // in quotes and the unicode chars escaped. cmd.add("-z"); if (skip > 0) { cmd.add("--skip"); cmd.add(Integer.toString(skip)); } if (!Strings.isNullOrEmpty(grepString)) { cmd.add("--grep"); cmd.add(grepString); } cmd.add(refExpr); if (!paths.isEmpty()) { cmd.add("--"); cmd.addAll(paths); } CommandOutput output = repo.simpleCommand(cmd.toArray(new String[0])); return parseLog(output.getStdout(), includeBody); } private ImmutableList<GitLogEntry> parseLog(String log, boolean includeBody) throws RepoException { // No changes. We cannot know until we run git log since fromRef can be null (HEAD) if (log.isEmpty()) { return ImmutableList.of(); } ImmutableList.Builder<GitLogEntry> commits = ImmutableList.builder(); for (String msg : Splitter.on("\0" + COMMIT_SEPARATOR). split(log.substring(COMMIT_SEPARATOR.length()))) { List<String> groups = Splitter.on("\n" + GROUP).splitToList(msg); Map<String, String> fields = Splitter.on("\n") .withKeyValueSeparator(Splitter.on("=").limit(2)) .split(groups.get(0)); String body = null; if (includeBody) { body = UNINDENT.matcher(groups.get(1)).replaceAll("\n"); body = body.substring(BEGIN_BODY.length() + 1, body.length() - END_BODY.length() - 1); // Copybara assumes \n as a separator in many places. body = body.replace("\r\n", "\n"); } ImmutableSet<String> files = null; if (includeStat) { String fileString = groups.get(2); if (fileString.startsWith("\0\n")) { fileString = fileString.substring(2); } files = ImmutableSet.copyOf(Splitter.on("\0").omitEmptyStrings().split(fileString)); } ImmutableList.Builder<GitRevision> parents = ImmutableList.builder(); for (String parent : Splitter.on(" ").omitEmptyStrings() .split(getField(fields, PARENTS_FIELD))) { parents.add(repo.createReferenceFromCompleteSha1(parent)); } String tree = getField(fields, TREE_FIELD); String commit = getField(fields, COMMIT_FIELD); try { commits.add(new GitLogEntry( repo.createReferenceFromCompleteSha1(commit), parents.build(), tree, AuthorParser.parse(getField(fields, AUTHOR_FIELD)), AuthorParser.parse(getField(fields, COMMITTER_FIELD)), tryParseDate(fields, AUTHOR_DATE_FIELD, commit), tryParseDate(fields, COMMITTER_DATE, commit), body, files)); } catch (InvalidAuthorException e) { throw new RepoException("Error in commit '" + commit + "'. Invalid author.", e); } } return commits.build(); } private ZonedDateTime tryParseDate(Map<String, String> fields, String dateField, String commit) { String value = getField(fields, dateField); try { return ZonedDateTime.parse(value); } catch (DateTimeParseException e) { logger.atSevere().log("Cannot parse date '%s' for commit %s. Using epoch time instead", value, commit); return ZonedDateTime.ofInstant(Instant.EPOCH, ZoneOffset.UTC); } } private String getField(Map<String, String> fields, String field) { return checkNotNull(fields.get(field), "%s not present", field); } /** * We use a custom format that allows us easy parsing and be tolerant to random text in the * body (That is the reason why we indent the body). * * <p>We also use \u0001 as commit separator to prevent a file being confused as the separator. */ private String createFormat(boolean includeBody) { return ("--format=" + COMMIT_SEPARATOR + COMMIT_FIELD + "=%H\n" + PARENTS_FIELD + "=%P\n" + TREE_FIELD + "=%T\n" + AUTHOR_FIELD + "=%an <%ae>\n" + AUTHOR_DATE_FIELD + "=%aI\n" + COMMITTER_FIELD + "=%cn <%ce>\n" + COMMITTER_DATE + "=%cI\n" + GROUP // Body is padded by 4 spaces. + (includeBody ? BEGIN_BODY + "\n" + "%w(0,4,4)%B%w(0,0,0)\n" + END_BODY + "\n" : "\n") + GROUP) .replace("\n", "%n").replace("\u0001", "%x01"); } } /** * An object that represent a commit as returned by 'git log'. */ public static class GitLogEntry { private final GitRevision commit; private final ImmutableList<GitRevision> parents; private final String tree; private final Author author; private final Author committer; private final ZonedDateTime authorDate; private final ZonedDateTime commitDate; @Nullable private final String body; @Nullable private final ImmutableSet<String> files; GitLogEntry(GitRevision commit, ImmutableList<GitRevision> parents, String tree, Author author, Author committer, ZonedDateTime authorDate, ZonedDateTime commitDate, @Nullable String body, @Nullable ImmutableSet<String> files) { this.commit = commit; this.parents = parents; this.tree = tree; this.author = author; this.committer = committer; this.authorDate = authorDate; this.commitDate = commitDate; this.body = body; this.files = files; } public GitRevision getCommit() { return commit; } public ImmutableList<GitRevision> getParents() { return parents; } public Author getAuthor() { return author; } public Author getCommitter() { return committer; } public ZonedDateTime getAuthorDate() { return authorDate; } ZonedDateTime getCommitDate() { return commitDate; } public String getTree() { return tree; } @Nullable public String getBody() { return body; } @Nullable public ImmutableSet<String> getFiles() { return files; } @Override public String toString() { return MoreObjects.toStringHelper(this) .add("commit", commit) .add("parents", parents) .add("author", author) .add("committer", committer) .add("authorDate", authorDate) .add("commitDate", commitDate) .add("body", body) .toString(); } } // Used for debugging issues @SuppressWarnings("unused") public String gitCmd() { return "git --git-dir=" + gitDir + (workTree != null ? " --work-tree=" + workTree : ""); } /** * An object capable of performing a 'git tag' operation to a remote repository. */ //TODO(huanhuanchen): support deleting tag public static class TagCmd { private final GitRepository repo; private final String tagName; @Nullable private final String tagMessage; private final boolean force; TagCmd(GitRepository gitRepository, String tagName, String tagMessage, boolean force) { this.repo = Preconditions.checkNotNull(gitRepository); this.tagName = Preconditions.checkNotNull(tagName); this.tagMessage = tagMessage; this.force = force; } static TagCmd create(GitRepository gitRepository, String tagName) { return new TagCmd(gitRepository, tagName, null, false); } public TagCmd withAnnotatedTag(String tagMessage) { return new TagCmd(repo, tagName, tagMessage, force); } public TagCmd force(boolean force) { return new TagCmd(repo, tagName, tagMessage, force); } public void run() throws RepoException, ValidationException { List<String> cmd = Lists.newArrayList("tag"); if (tagMessage != null) { cmd.add("-a"); } cmd.add(tagName); if (tagMessage != null) { cmd.add("-m"); cmd.add(tagMessage); } if (force) { cmd.add("--force"); } repo.simpleCommand(cmd.toArray(new String[0])); } } }