diff --git a/src/repository/api/ArcanistGitAPI.php b/src/repository/api/ArcanistGitAPI.php index a73cbe07..fe7d839f 100644 --- a/src/repository/api/ArcanistGitAPI.php +++ b/src/repository/api/ArcanistGitAPI.php @@ -1,1499 +1,1504 @@ setCWD($this->getPath()); return $future; } public function execPassthru($pattern /* , ... */) { $args = func_get_args(); static $git = null; if ($git === null) { if (phutil_is_windows()) { // NOTE: On Windows, phutil_passthru() uses 'bypass_shell' because // everything goes to hell if we don't. We must provide an absolute // path to Git for this to work properly. $git = Filesystem::resolveBinary('git'); $git = csprintf('%s', $git); } else { $git = 'git'; } } $args[0] = $git.' '.$args[0]; return call_user_func_array('phutil_passthru', $args); } public function getSourceControlSystemName() { return 'git'; } public function getGitVersion() { list($stdout) = $this->execxLocal('--version'); return rtrim(str_replace('git version ', '', $stdout)); } public function getMetadataPath() { static $path = null; if ($path === null) { list($stdout) = $this->execxLocal('rev-parse --git-dir'); $path = rtrim($stdout, "\n"); // the output of git rev-parse --git-dir is an absolute path, unless // the cwd is the root of the repository, in which case it uses the // relative path of .git. If we get this relative path, turn it into // an absolute path. if ($path === '.git') { $path = $this->getPath('.git'); } } return $path; } public function getHasCommits() { return !$this->repositoryHasNoCommits; } /** * Tests if a child commit is descendant of a parent commit. * If child and parent are the same, it returns false. * @param Child commit SHA. * @param Parent commit SHA. * @return bool True if the child is a descendant of the parent. */ private function isDescendant($child, $parent) { list($common_ancestor) = $this->execxLocal( 'merge-base %s %s', $child, $parent); $common_ancestor = trim($common_ancestor); return ($common_ancestor == $parent) && ($common_ancestor != $child); } public function getLocalCommitInformation() { if ($this->repositoryHasNoCommits) { // Zero commits. throw new Exception( pht( "You can't get local commit information for a repository with no ". "commits.")); } else if ($this->getBaseCommit() == self::GIT_MAGIC_ROOT_COMMIT) { // One commit. $against = 'HEAD'; } else { // 2..N commits. We include commits reachable from HEAD which are // not reachable from the base commit; this is consistent with user // expectations even though it is not actually the diff range. // Particularly: // // | // D <----- master branch // | // C Y <- feature branch // | /| // B X // | / // A // | // // If "A, B, C, D" are master, and the user is at Y, when they run // "arc diff B" they want (and get) a diff of B vs Y, but they think about // this as being the commits X and Y. If we log "B..Y", we only show // Y. With "Y --not B", we show X and Y. if ($this->symbolicHeadCommit !== null) { $base_commit = $this->getBaseCommit(); $resolved_base = $this->resolveCommit($base_commit); $head_commit = $this->symbolicHeadCommit; $resolved_head = $this->getHeadCommit(); if (!$this->isDescendant($resolved_head, $resolved_base)) { // NOTE: Since the base commit will have been resolved as the // merge-base of the specified base and the specified HEAD, we can't // easily tell exactly what's wrong with the range. // For example, `arc diff HEAD --head HEAD^^^` is invalid because it // is reversed, but resolving the commit "HEAD" will compute its // merge-base with "HEAD^^^", which is "HEAD^^^", so the range will // appear empty. throw new ArcanistUsageException( pht( 'The specified commit range is empty, backward or invalid: the '. 'base (%s) is not an ancestor of the head (%s). You can not '. 'diff an empty or reversed commit range.', $base_commit, $head_commit)); } } $against = csprintf( '%s --not %s', $this->getHeadCommit(), $this->getBaseCommit()); } // NOTE: Windows escaping of "%" symbols apparently is inherently broken; // when passed through escapeshellarg() they are replaced with spaces. // TODO: Learn how cmd.exe works and find some clever workaround? // NOTE: If we use "%x00", output is truncated in Windows. list($info) = $this->execxLocal( phutil_is_windows() ? 'log %C --format=%C --' : 'log %C --format=%s --', $against, // NOTE: "%B" is somewhat new, use "%s%n%n%b" instead. '%H%x01%T%x01%P%x01%at%x01%an%x01%aE%x01%s%x01%s%n%n%b%x02'); $commits = array(); $info = trim($info, " \n\2"); if (!strlen($info)) { return array(); } $info = explode("\2", $info); foreach ($info as $line) { list($commit, $tree, $parents, $time, $author, $author_email, $title, $message) = explode("\1", trim($line), 8); $message = rtrim($message); $commits[$commit] = array( 'commit' => $commit, 'tree' => $tree, 'parents' => array_filter(explode(' ', $parents)), 'time' => $time, 'author' => $author, 'summary' => $title, 'message' => $message, 'authorEmail' => $author_email, ); } return $commits; } protected function buildBaseCommit($symbolic_commit) { if ($symbolic_commit !== null) { if ($symbolic_commit == self::GIT_MAGIC_ROOT_COMMIT) { $this->setBaseCommitExplanation( pht('you explicitly specified the empty tree.')); return $symbolic_commit; } list($err, $merge_base) = $this->execManualLocal( 'merge-base %s %s', $symbolic_commit, $this->getHeadCommit()); if ($err) { throw new ArcanistUsageException( pht( "Unable to find any git commit named '%s' in this repository.", $symbolic_commit)); } if ($this->symbolicHeadCommit === null) { $this->setBaseCommitExplanation( pht( "it is the merge-base of the explicitly specified base commit ". "'%s' and HEAD.", $symbolic_commit)); } else { $this->setBaseCommitExplanation( pht( "it is the merge-base of the explicitly specified base commit ". "'%s' and the explicitly specified head commit '%s'.", $symbolic_commit, $this->symbolicHeadCommit)); } return trim($merge_base); } // Detect zero-commit or one-commit repositories. There is only one // relative-commit value that makes any sense in these repositories: the // empty tree. list($err) = $this->execManualLocal('rev-parse --verify HEAD^'); if ($err) { list($err) = $this->execManualLocal('rev-parse --verify HEAD'); if ($err) { $this->repositoryHasNoCommits = true; } if ($this->repositoryHasNoCommits) { $this->setBaseCommitExplanation(pht('the repository has no commits.')); } else { $this->setBaseCommitExplanation( pht('the repository has only one commit.')); } return self::GIT_MAGIC_ROOT_COMMIT; } if ($this->getBaseCommitArgumentRules() || $this->getConfigurationManager()->getConfigFromAnySource('base')) { $base = $this->resolveBaseCommit(); if (!$base) { throw new ArcanistUsageException( pht( "None of the rules in your 'base' configuration matched a valid ". "commit. Adjust rules or specify which commit you want to use ". "explicitly.")); } return $base; } $do_write = false; $default_relative = null; $working_copy = $this->getWorkingCopyIdentity(); if ($working_copy) { $default_relative = $working_copy->getProjectConfig( 'git.default-relative-commit'); $this->setBaseCommitExplanation( pht( "it is the merge-base of '%s' and HEAD, as specified in '%s' in ". "'%s'. This setting overrides other settings.", $default_relative, 'git.default-relative-commit', '.arcconfig')); } if (!$default_relative) { list($err, $upstream) = $this->execManualLocal( 'rev-parse --abbrev-ref --symbolic-full-name %s', '@{upstream}'); if (!$err) { $default_relative = trim($upstream); $this->setBaseCommitExplanation( pht( "it is the merge-base of '%s' (the Git upstream ". "of the current branch) HEAD.", $default_relative)); } } if (!$default_relative) { $default_relative = $this->readScratchFile('default-relative-commit'); $default_relative = trim($default_relative); if ($default_relative) { $this->setBaseCommitExplanation( pht( "it is the merge-base of '%s' and HEAD, as specified in '%s'.", $default_relative, '.git/arc/default-relative-commit')); } } if (!$default_relative) { // TODO: Remove the history lesson soon. echo phutil_console_format( "** %s **\n\n", pht('Select a Default Commit Range')); echo phutil_console_wrap( pht( "You're running a command which operates on a range of revisions ". "(usually, from some revision to HEAD) but have not specified the ". "revision that should determine the start of the range.\n\n". "Previously, arc assumed you meant '%s' when you did not specify ". "a start revision, but this behavior does not make much sense in ". "most workflows outside of Facebook's historic %s workflow.\n\n". "arc no longer assumes '%s'. You must specify a relative commit ". "explicitly when you invoke a command (e.g., `%s`, not just `%s`) ". "or select a default for this working copy.\n\nIn most cases, the ". "best default is '%s'. You can also select '%s' to preserve the ". "old behavior, or some other remote or branch. But you almost ". "certainly want to select 'origin/master'.\n\n". "(Technically: the merge-base of the selected revision and HEAD is ". "used to determine the start of the commit range.)", 'HEAD^', 'git-svn', 'HEAD^', 'arc diff HEAD^', 'arc diff', 'origin/master', 'HEAD^')); $prompt = pht('What default do you want to use? [origin/master]'); $default = phutil_console_prompt($prompt); if (!strlen(trim($default))) { $default = 'origin/master'; } $default_relative = $default; $do_write = true; } list($object_type) = $this->execxLocal( 'cat-file -t %s', $default_relative); if (trim($object_type) !== 'commit') { throw new Exception( pht( "Relative commit '%s' is not the name of a commit!", $default_relative)); } if ($do_write) { // Don't perform this write until we've verified that the object is a // valid commit name. $this->writeScratchFile('default-relative-commit', $default_relative); $this->setBaseCommitExplanation( pht( "it is the merge-base of '%s' and HEAD, as you just specified.", $default_relative)); } list($merge_base) = $this->execxLocal( 'merge-base %s HEAD', $default_relative); return trim($merge_base); } public function getHeadCommit() { if ($this->resolvedHeadCommit === null) { $this->resolvedHeadCommit = $this->resolveCommit( coalesce($this->symbolicHeadCommit, 'HEAD')); } return $this->resolvedHeadCommit; } public function setHeadCommit($symbolic_commit) { $this->symbolicHeadCommit = $symbolic_commit; $this->reloadCommitRange(); return $this; } /** * Translates a symbolic commit (like "HEAD^") to a commit identifier. * @param string_symbol commit. * @return string the commit SHA. */ private function resolveCommit($symbolic_commit) { list($err, $commit_hash) = $this->execManualLocal( 'rev-parse %s', $symbolic_commit); if ($err) { throw new ArcanistUsageException( pht( "Unable to find any git commit named '%s' in this repository.", $symbolic_commit)); } return trim($commit_hash); } private function getDiffFullOptions($detect_moves_and_renames = true) { $options = array( self::getDiffBaseOptions(), '--no-color', '--src-prefix=a/', '--dst-prefix=b/', '-U'.$this->getDiffLinesOfContext(), ); if ($detect_moves_and_renames) { $options[] = '-M'; $options[] = '-C'; } return implode(' ', $options); } private function getDiffBaseOptions() { $options = array( // Disable external diff drivers, like graphical differs, since Arcanist // needs to capture the diff text. '--no-ext-diff', // Disable textconv so we treat binary files as binary, even if they have // an alternative textual representation. TODO: Ideally, Differential // would ship up the binaries for 'arc patch' but display the textconv // output in the visual diff. '--no-textconv', // Provide a standard view of submodule changes; the 'log' and 'diff' // values do not parse by the diff parser. '--submodule=short', ); return implode(' ', $options); } /** * @param the base revision * @param head revision. If this is null, the generated diff will include the * working copy */ public function getFullGitDiff($base, $head = null) { $options = $this->getDiffFullOptions(); if ($head !== null) { list($stdout) = $this->execxLocal( "diff {$options} %s %s --", $base, $head); } else { list($stdout) = $this->execxLocal( "diff {$options} %s --", $base); } return $stdout; } /** * @param string Path to generate a diff for. * @param bool If true, detect moves and renames. Otherwise, ignore * moves/renames; this is useful because it prompts git to * generate real diff text. */ public function getRawDiffText($path, $detect_moves_and_renames = true) { $options = $this->getDiffFullOptions($detect_moves_and_renames); list($stdout) = $this->execxLocal( "diff {$options} %s -- %s", $this->getBaseCommit(), $path); return $stdout; } private function getBranchNameFromRef($ref) { $count = 0; $branch = preg_replace('/^refs\/heads\//', '', $ref, 1, $count); if ($count !== 1) { return null; } return $branch; } public function getBranchName() { list($err, $stdout, $stderr) = $this->execManualLocal( 'symbolic-ref --quiet HEAD'); if ($err === 0) { // We expect the branch name to come qualified with a refs/heads/ prefix. // Verify this, and strip it. $ref = rtrim($stdout); $branch = $this->getBranchNameFromRef($ref); if (!$branch) { throw new Exception( pht('Failed to parse %s output!', 'git symbolic-ref')); } return $branch; } else if ($err === 1) { // Exit status 1 with --quiet indicates that HEAD is detached. return null; } else { throw new Exception( pht('Command %s failed: %s', 'git symbolic-ref', $stderr)); } } public function getRemoteURI() { // Determine which remote to examine; default to 'origin' $remote = 'origin'; $branch = $this->getBranchName(); if ($branch) { $path = $this->getPathToUpstream($branch); if ($path->isConnectedToRemote()) { $remote = $path->getRemoteRemoteName(); } } // "git ls-remote --get-url" is the appropriate plumbing to get the remote // URI. "git config remote.origin.url", on the other hand, may not be as // accurate (for example, it does not take into account possible URL // rewriting rules set by the user through "url..insteadOf"). However, // the --get-url flag requires git 1.7.5. $version = $this->getGitVersion(); if (version_compare($version, '1.7.5', '>=')) { list($stdout) = $this->execxLocal('ls-remote --get-url %s', $remote); } else { list($stdout) = $this->execxLocal('config %s', "remote.{$remote}.url"); } $uri = rtrim($stdout); // ls-remote echos the remote name (ie 'origin') if no remote URI is found // TODO: In 2.7.0 (circa 2016) git introduced `git remote get-url` // with saner error handling. if (!$uri || $uri === $remote) { return null; } return $uri; } public function getSourceControlPath() { // TODO: Try to get something useful here. return null; } public function getGitCommitLog() { $relative = $this->getBaseCommit(); if ($this->repositoryHasNoCommits) { // No commits yet. return ''; } else if ($relative == self::GIT_MAGIC_ROOT_COMMIT) { // First commit. list($stdout) = $this->execxLocal( 'log --format=medium HEAD'); } else { // 2..N commits. list($stdout) = $this->execxLocal( 'log --first-parent --format=medium %s..%s', $this->getBaseCommit(), $this->getHeadCommit()); } return $stdout; } public function getGitHistoryLog() { list($stdout) = $this->execxLocal( 'log --format=medium -n%d %s', self::SEARCH_LENGTH_FOR_PARENT_REVISIONS, $this->getBaseCommit()); return $stdout; } public function getSourceControlBaseRevision() { list($stdout) = $this->execxLocal( 'rev-parse %s', $this->getBaseCommit()); return rtrim($stdout, "\n"); } public function getCanonicalRevisionName($string) { $match = null; if (preg_match('/@([0-9]+)$/', $string, $match)) { $stdout = $this->getHashFromFromSVNRevisionNumber($match[1]); } else { list($stdout) = $this->execxLocal( phutil_is_windows() ? 'show -s --format=%C %s --' : 'show -s --format=%s %s --', '%H', $string); } return rtrim($stdout); } private function executeSVNFindRev($input, $vcs) { $match = array(); list($stdout) = $this->execxLocal( 'svn find-rev %s', $input); if (!$stdout) { throw new ArcanistUsageException( pht( 'Cannot find the %s equivalent of %s.', $vcs, $input)); } // When git performs a partial-rebuild during svn // look-up, we need to parse the final line $lines = explode("\n", $stdout); $stdout = $lines[count($lines) - 2]; return rtrim($stdout); } // Convert svn revision number to git hash public function getHashFromFromSVNRevisionNumber($revision_id) { return $this->executeSVNFindRev('r'.$revision_id, 'Git'); } // Convert a git hash to svn revision number public function getSVNRevisionNumberFromHash($hash) { return $this->executeSVNFindRev($hash, 'SVN'); } protected function buildUncommittedStatus() { $diff_options = $this->getDiffBaseOptions(); if ($this->repositoryHasNoCommits) { $diff_base = self::GIT_MAGIC_ROOT_COMMIT; } else { $diff_base = 'HEAD'; } // Find uncommitted changes. $uncommitted_future = $this->buildLocalFuture( array( 'diff %C --raw %s --', $diff_options, $diff_base, )); $untracked_future = $this->buildLocalFuture( array( 'ls-files --others --exclude-standard', )); // Unstaged changes $unstaged_future = $this->buildLocalFuture( array( 'diff-files --name-only', )); $futures = array( $uncommitted_future, $untracked_future, // NOTE: `git diff-files` races with each of these other commands // internally, and resolves with inconsistent results if executed // in parallel. To work around this, DO NOT run it at the same time. // After the other commands exit, we can start the `diff-files` command. ); id(new FutureIterator($futures))->resolveAll(); // We're clear to start the `git diff-files` now. $unstaged_future->start(); $result = new PhutilArrayWithDefaultValue(); list($stdout) = $uncommitted_future->resolvex(); $uncommitted_files = $this->parseGitRawDiff($stdout); foreach ($uncommitted_files as $path => $mask) { $result[$path] |= ($mask | self::FLAG_UNCOMMITTED); } list($stdout) = $untracked_future->resolvex(); $stdout = rtrim($stdout, "\n"); if (strlen($stdout)) { $stdout = explode("\n", $stdout); foreach ($stdout as $path) { $result[$path] |= self::FLAG_UNTRACKED; } } list($stdout, $stderr) = $unstaged_future->resolvex(); $stdout = rtrim($stdout, "\n"); if (strlen($stdout)) { $stdout = explode("\n", $stdout); foreach ($stdout as $path) { $result[$path] |= self::FLAG_UNSTAGED; } } return $result->toArray(); } protected function buildCommitRangeStatus() { list($stdout, $stderr) = $this->execxLocal( 'diff %C --raw %s --', $this->getDiffBaseOptions(), $this->getBaseCommit()); return $this->parseGitRawDiff($stdout); } public function getGitConfig($key, $default = null) { list($err, $stdout) = $this->execManualLocal('config %s', $key); if ($err) { return $default; } return rtrim($stdout); } public function getAuthor() { list($stdout) = $this->execxLocal('var GIT_AUTHOR_IDENT'); return preg_replace('/\s+<.*/', '', rtrim($stdout, "\n")); } public function addToCommit(array $paths) { $this->execxLocal( 'add -A -- %Ls', $paths); $this->reloadWorkingCopy(); return $this; } public function doCommit($message) { $tmp_file = new TempFile(); Filesystem::writeFile($tmp_file, $message); // NOTE: "--allow-empty-message" was introduced some time after 1.7.0.4, // so we do not provide it and thus require a message. $this->execxLocal( 'commit -F %s', $tmp_file); $this->reloadWorkingCopy(); return $this; } public function amendCommit($message = null) { if ($message === null) { $this->execxLocal('commit --amend --allow-empty -C HEAD'); } else { $tmp_file = new TempFile(); Filesystem::writeFile($tmp_file, $message); $this->execxLocal( 'commit --amend --allow-empty -F %s', $tmp_file); } $this->reloadWorkingCopy(); return $this; } private function parseGitRawDiff($status, $full = false) { static $flags = array( 'A' => self::FLAG_ADDED, 'M' => self::FLAG_MODIFIED, 'D' => self::FLAG_DELETED, ); $status = trim($status); $lines = array(); foreach (explode("\n", $status) as $line) { if ($line) { $lines[] = preg_split("/[ \t]/", $line, 6); } } $files = array(); foreach ($lines as $line) { $mask = 0; // "git diff --raw" lines begin with a ":" character. $old_mode = ltrim($line[0], ':'); $new_mode = $line[1]; // The hashes may be padded with "." characters for alignment. Discard // them. $old_hash = rtrim($line[2], '.'); $new_hash = rtrim($line[3], '.'); $flag = $line[4]; $file = $line[5]; $new_value = intval($new_mode, 8); $is_submodule = (($new_value & 0160000) === 0160000); if (($is_submodule) && ($flag == 'M') && ($old_hash === $new_hash) && ($old_mode === $new_mode)) { // See T9455. We see this submodule as "modified", but the old and new // hashes are the same and the old and new modes are the same, so we // don't directly see a modification. // We can end up here if we have a submodule which has uncommitted // changes inside of it (for example, the user has added untracked // files or made uncommitted changes to files in the submodule). In // this case, we set a different flag because we can't meaningfully // give users the same prompt. // Note that if the submodule has real changes from the parent // perspective (the base commit has changed) and also has uncommitted // changes, we'll only see the real changes and miss the uncommitted // changes. At the time of writing, there is no reasonable porcelain // for finding those changes, and the impact of this error seems small. $mask |= self::FLAG_EXTERNALS; } else if (isset($flags[$flag])) { $mask |= $flags[$flag]; } else if ($flag[0] == 'R') { $both = explode("\t", $file); if ($full) { $files[$both[0]] = array( 'mask' => $mask | self::FLAG_DELETED, 'ref' => str_repeat('0', 40), ); } else { $files[$both[0]] = $mask | self::FLAG_DELETED; } $file = $both[1]; $mask |= self::FLAG_ADDED; } else if ($flag[0] == 'C') { $both = explode("\t", $file); $file = $both[1]; $mask |= self::FLAG_ADDED; } if ($full) { $files[$file] = array( 'mask' => $mask, 'ref' => $new_hash, ); } else { $files[$file] = $mask; } } return $files; } public function getAllFiles() { $future = $this->buildLocalFuture(array('ls-files -z')); return id(new LinesOfALargeExecFuture($future)) ->setDelimiter("\0"); } public function getChangedFiles($since_commit) { list($stdout) = $this->execxLocal( 'diff --raw %s', $since_commit); return $this->parseGitRawDiff($stdout); } public function getBlame($path) { list($stdout) = $this->execxLocal( 'blame --porcelain -w -M %s -- %s', $this->getBaseCommit(), $path); // the --porcelain format prints at least one header line per source line, // then the source line prefixed by a tab character $blame_info = preg_split('/^\t.*\n/m', rtrim($stdout)); // commit info is not repeated in these headers, so cache it $revision_data = array(); $blame = array(); foreach ($blame_info as $line_info) { $revision = substr($line_info, 0, 40); $data = idx($revision_data, $revision, array()); if (empty($data)) { $matches = array(); if (!preg_match('/^author (.*)$/m', $line_info, $matches)) { throw new Exception( pht( 'Unexpected output from %s: no author for commit %s', 'git blame', $revision)); } $data['author'] = $matches[1]; $data['from_first_commit'] = preg_match('/^boundary$/m', $line_info); $revision_data[$revision] = $data; } // Ignore lines predating the git repository (on a boundary commit) // rather than blaming them on the oldest diff's unfortunate author if (!$data['from_first_commit']) { $blame[] = array($data['author'], $revision); } } return $blame; } public function getOriginalFileData($path) { return $this->getFileDataAtRevision($path, $this->getBaseCommit()); } public function getCurrentFileData($path) { return $this->getFileDataAtRevision($path, 'HEAD'); } private function parseGitTree($stdout) { $result = array(); $stdout = trim($stdout); if (!strlen($stdout)) { return $result; } $lines = explode("\n", $stdout); foreach ($lines as $line) { $matches = array(); $ok = preg_match( '/^(\d{6}) (blob|tree|commit) ([a-z0-9]{40})[\t](.*)$/', $line, $matches); if (!$ok) { throw new Exception(pht('Failed to parse %s output!', 'git ls-tree')); } $result[$matches[4]] = array( 'mode' => $matches[1], 'type' => $matches[2], 'ref' => $matches[3], ); } return $result; } private function getFileDataAtRevision($path, $revision) { // NOTE: We don't want to just "git show {$revision}:{$path}" since if the // path was a directory at the given revision we'll get a list of its files // and treat it as though it as a file containing a list of other files, // which is silly. list($stdout) = $this->execxLocal( 'ls-tree %s -- %s', $revision, $path); $info = $this->parseGitTree($stdout); if (empty($info[$path])) { // No such path, or the path is a directory and we executed 'ls-tree dir/' // and got a list of its contents back. return null; } if ($info[$path]['type'] != 'blob') { // Path is or was a directory, not a file. return null; } list($stdout) = $this->execxLocal( 'cat-file blob %s', $info[$path]['ref']); return $stdout; } /** * Returns names of all the branches in the current repository. * * @return list> Dictionary of branch information. */ public function getAllBranches() { $field_list = array( '%(refname)', '%(objectname)', '%(committerdate:raw)', '%(tree)', '%(subject)', '%(subject)%0a%0a%(body)', '%02', ); list($stdout) = $this->execxLocal( 'for-each-ref --format=%s -- refs/heads', implode('%01', $field_list)); $current = $this->getBranchName(); $result = array(); $lines = explode("\2", $stdout); foreach ($lines as $line) { $line = trim($line); if (!strlen($line)) { continue; } $fields = explode("\1", $line, 6); list($ref, $hash, $epoch, $tree, $desc, $text) = $fields; $branch = $this->getBranchNameFromRef($ref); if ($branch) { $result[] = array( 'current' => ($branch === $current), 'name' => $branch, 'ref' => $ref, 'hash' => $hash, 'tree' => $tree, 'epoch' => (int)$epoch, 'desc' => $desc, 'text' => $text, ); } } return $result; } public function getAllBranchRefs() { $branches = $this->getAllBranches(); $refs = array(); foreach ($branches as $branch) { $commit_ref = $this->newCommitRef() ->setCommitHash($branch['hash']) ->setTreeHash($branch['tree']) ->setCommitEpoch($branch['epoch']) ->attachMessage($branch['text']); $refs[] = $this->newBranchRef() ->setBranchName($branch['name']) ->setRefName($branch['ref']) ->setIsCurrentBranch($branch['current']) ->attachCommitRef($commit_ref); } return $refs; } public function getBaseCommitRef() { - $base_commit = $this->getSourceControlBaseRevision(); + $base_commit = $this->getBaseCommit(); + + if ($base_commit === self::GIT_MAGIC_ROOT_COMMIT) { + return null; + } + $base_message = $this->getCommitMessage($base_commit); // TODO: We should also pull the tree hash. return $this->newCommitRef() ->setCommitHash($base_commit) ->attachMessage($base_message); } public function getWorkingCopyRevision() { list($stdout) = $this->execxLocal('rev-parse HEAD'); return rtrim($stdout, "\n"); } public function isHistoryDefaultImmutable() { return false; } public function supportsAmend() { return true; } public function supportsCommitRanges() { return true; } public function supportsLocalCommits() { return true; } public function hasLocalCommit($commit) { try { if (!$this->getCanonicalRevisionName($commit)) { return false; } } catch (CommandException $exception) { return false; } return true; } public function getAllLocalChanges() { $diff = $this->getFullGitDiff($this->getBaseCommit()); if (!strlen(trim($diff))) { return array(); } $parser = new ArcanistDiffParser(); return $parser->parseDiff($diff); } public function supportsLocalBranchMerge() { return true; } public function performLocalBranchMerge($branch, $message) { if (!$branch) { throw new ArcanistUsageException( pht('Under git, you must specify the branch you want to merge.')); } $err = phutil_passthru( '(cd %s && git merge --no-ff -m %s %s)', $this->getPath(), $message, $branch); if ($err) { throw new ArcanistUsageException(pht('Merge failed!')); } } public function getFinalizedRevisionMessage() { return pht( "You may now push this commit upstream, as appropriate (e.g. with ". "'%s', or '%s', or by printing and faxing it).", 'git push', 'git svn dcommit'); } public function getCommitMessage($commit) { list($message) = $this->execxLocal( 'log -n1 --format=%C %s --', '%s%n%n%b', $commit); return $message; } public function loadWorkingCopyDifferentialRevisions( ConduitClient $conduit, array $query) { $messages = $this->getGitCommitLog(); if (!strlen($messages)) { return array(); } $parser = new ArcanistDiffParser(); $messages = $parser->parseDiff($messages); // First, try to find revisions by explicit revision IDs in commit messages. $reason_map = array(); $revision_ids = array(); foreach ($messages as $message) { $object = ArcanistDifferentialCommitMessage::newFromRawCorpus( $message->getMetadata('message')); if ($object->getRevisionID()) { $revision_ids[] = $object->getRevisionID(); $reason_map[$object->getRevisionID()] = $message->getCommitHash(); } } if ($revision_ids) { $results = $conduit->callMethodSynchronous( 'differential.query', $query + array( 'ids' => $revision_ids, )); foreach ($results as $key => $result) { $hash = substr($reason_map[$result['id']], 0, 16); $results[$key]['why'] = pht( "Commit message for '%s' has explicit 'Differential Revision'.", $hash); } return $results; } // If we didn't succeed, try to find revisions by hash. $hashes = array(); foreach ($this->getLocalCommitInformation() as $commit) { $hashes[] = array('gtcm', $commit['commit']); $hashes[] = array('gttr', $commit['tree']); } $results = $conduit->callMethodSynchronous( 'differential.query', $query + array( 'commitHashes' => $hashes, )); foreach ($results as $key => $result) { $results[$key]['why'] = pht( 'A git commit or tree hash in the commit range is already attached '. 'to the Differential revision.'); } return $results; } public function updateWorkingCopy() { $this->execxLocal('pull'); $this->reloadWorkingCopy(); } public function getCommitSummary($commit) { if ($commit == self::GIT_MAGIC_ROOT_COMMIT) { return pht('(The Empty Tree)'); } list($summary) = $this->execxLocal( 'log -n 1 --format=%C %s', '%s', $commit); return trim($summary); } public function backoutCommit($commit_hash) { $this->execxLocal('revert %s -n --no-edit', $commit_hash); $this->reloadWorkingCopy(); if (!$this->getUncommittedStatus()) { throw new ArcanistUsageException( pht('%s has already been reverted.', $commit_hash)); } } public function getBackoutMessage($commit_hash) { return pht('This reverts commit %s.', $commit_hash); } public function isGitSubversionRepo() { return Filesystem::pathExists($this->getPath('.git/svn')); } public function resolveBaseCommitRule($rule, $source) { list($type, $name) = explode(':', $rule, 2); switch ($type) { case 'git': $matches = null; if (preg_match('/^merge-base\((.+)\)$/', $name, $matches)) { list($err, $merge_base) = $this->execManualLocal( 'merge-base %s HEAD', $matches[1]); if (!$err) { $this->setBaseCommitExplanation( pht( "it is the merge-base of '%s' and HEAD, as specified by ". "'%s' in your %s 'base' configuration.", $matches[1], $rule, $source)); return trim($merge_base); } } else if (preg_match('/^branch-unique\((.+)\)$/', $name, $matches)) { list($err, $merge_base) = $this->execManualLocal( 'merge-base %s HEAD', $matches[1]); if ($err) { return null; } $merge_base = trim($merge_base); list($commits) = $this->execxLocal( 'log --format=%C %s..HEAD --', '%H', $merge_base); $commits = array_filter(explode("\n", $commits)); if (!$commits) { return null; } $commits[] = $merge_base; $head_branch_count = null; $all_branch_names = ipull($this->getAllBranches(), 'name'); foreach ($commits as $commit) { // Ideally, we would use something like "for-each-ref --contains" // to get a filtered list of branches ready for script consumption. // Instead, try to get predictable output from "branch --contains". $flags = array(); $flags[] = '--no-color'; // NOTE: The "--no-column" flag was introduced in Git 1.7.11, so // don't pass it if we're running an older version. See T9953. $version = $this->getGitVersion(); if (version_compare($version, '1.7.11', '>=')) { $flags[] = '--no-column'; } list($branches) = $this->execxLocal( 'branch %Ls --contains %s', $flags, $commit); $branches = array_filter(explode("\n", $branches)); // Filter the list, removing the "current" marker (*) and ignoring // anything other than known branch names (mainly, any possible // "detached HEAD" or "no branch" line). foreach ($branches as $key => $branch) { $branch = trim($branch, ' *'); if (in_array($branch, $all_branch_names)) { $branches[$key] = $branch; } else { unset($branches[$key]); } } if ($head_branch_count === null) { // If this is the first commit, it's HEAD. Count how many // branches it is on; we want to include commits on the same // number of branches. This covers a case where this branch // has sub-branches and we're running "arc diff" here again // for whatever reason. $head_branch_count = count($branches); } else if (count($branches) > $head_branch_count) { $branches = implode(', ', $branches); $this->setBaseCommitExplanation( pht( "it is the first commit between '%s' (the merge-base of ". "'%s' and HEAD) which is also contained by another branch ". "(%s).", $merge_base, $matches[1], $branches)); return $commit; } } } else { list($err) = $this->execManualLocal( 'cat-file -t %s', $name); if (!$err) { $this->setBaseCommitExplanation( pht( "it is specified by '%s' in your %s 'base' configuration.", $rule, $source)); return $name; } } break; case 'arc': switch ($name) { case 'empty': $this->setBaseCommitExplanation( pht( "you specified '%s' in your %s 'base' configuration.", $rule, $source)); return self::GIT_MAGIC_ROOT_COMMIT; case 'amended': $text = $this->getCommitMessage('HEAD'); $message = ArcanistDifferentialCommitMessage::newFromRawCorpus( $text); if ($message->getRevisionID()) { $this->setBaseCommitExplanation( pht( "HEAD has been amended with 'Differential Revision:', ". "as specified by '%s' in your %s 'base' configuration.", $rule, $source)); return 'HEAD^'; } break; case 'upstream': list($err, $upstream) = $this->execManualLocal( 'rev-parse --abbrev-ref --symbolic-full-name %s', '@{upstream}'); if (!$err) { $upstream = rtrim($upstream); list($upstream_merge_base) = $this->execxLocal( 'merge-base %s HEAD', $upstream); $upstream_merge_base = rtrim($upstream_merge_base); $this->setBaseCommitExplanation( pht( "it is the merge-base of the upstream of the current branch ". "and HEAD, and matched the rule '%s' in your %s ". "'base' configuration.", $rule, $source)); return $upstream_merge_base; } break; case 'this': $this->setBaseCommitExplanation( pht( "you specified '%s' in your %s 'base' configuration.", $rule, $source)); return 'HEAD^'; } default: return null; } return null; } public function canStashChanges() { return true; } public function stashChanges() { $this->execxLocal('stash'); $this->reloadWorkingCopy(); } public function unstashChanges() { $this->execxLocal('stash pop'); } protected function didReloadCommitRange() { // After an amend, the symbolic head may resolve to a different commit. $this->resolvedHeadCommit = null; } /** * Follow the chain of tracking branches upstream until we reach a remote * or cycle locally. * * @param string Ref to start from. * @return ArcanistGitUpstreamPath Path to an upstream. */ public function getPathToUpstream($start) { $cursor = $start; $path = new ArcanistGitUpstreamPath(); while (true) { list($err, $upstream) = $this->execManualLocal( 'rev-parse --symbolic-full-name %s@{upstream}', $cursor); if ($err) { // We ended up somewhere with no tracking branch, so we're done. break; } $upstream = trim($upstream); if (preg_match('(^refs/heads/)', $upstream)) { $upstream = preg_replace('(^refs/heads/)', '', $upstream); $is_cycle = $path->getUpstream($upstream); $path->addUpstream( $cursor, array( 'type' => ArcanistGitUpstreamPath::TYPE_LOCAL, 'name' => $upstream, 'cycle' => $is_cycle, )); if ($is_cycle) { // We ran into a local cycle, so we're done. break; } // We found another local branch, so follow that one upriver. $cursor = $upstream; continue; } if (preg_match('(^refs/remotes/)', $upstream)) { $upstream = preg_replace('(^refs/remotes/)', '', $upstream); list($remote, $branch) = explode('/', $upstream, 2); $path->addUpstream( $cursor, array( 'type' => ArcanistGitUpstreamPath::TYPE_REMOTE, 'name' => $branch, 'remote' => $remote, )); // We found a remote, so we're done. break; } throw new Exception( pht( 'Got unrecognized upstream format ("%s") from Git, expected '. '"refs/heads/..." or "refs/remotes/...".', $upstream)); } return $path; } } diff --git a/src/repository/api/ArcanistMercurialAPI.php b/src/repository/api/ArcanistMercurialAPI.php index 8e9ea7e7..301cc88e 100644 --- a/src/repository/api/ArcanistMercurialAPI.php +++ b/src/repository/api/ArcanistMercurialAPI.php @@ -1,1141 +1,1155 @@ setCWD($this->getPath()); return $future; } public function execPassthru($pattern /* , ... */) { $args = func_get_args(); if (phutil_is_windows()) { $args[0] = 'hg '.$args[0]; } else { $args[0] = 'HGPLAIN=1 hg '.$args[0]; } return call_user_func_array('phutil_passthru', $args); } public function getSourceControlSystemName() { return 'hg'; } public function getMetadataPath() { return $this->getPath('.hg'); } public function getSourceControlBaseRevision() { return $this->getCanonicalRevisionName($this->getBaseCommit()); } public function getCanonicalRevisionName($string) { $match = null; if ($this->isHgSubversionRepo() && preg_match('/@([0-9]+)$/', $string, $match)) { $string = hgsprintf('svnrev(%s)', $match[1]); } list($stdout) = $this->execxLocal( 'log -l 1 --template %s -r %s --', '{node}', $string); return $stdout; } public function getHashFromFromSVNRevisionNumber($revision_id) { $matches = array(); $string = hgsprintf('svnrev(%s)', $revision_id); list($stdout) = $this->execxLocal( 'log -l 1 --template %s -r %s --', '{node}', $string); if (!$stdout) { throw new ArcanistUsageException( pht('Cannot find the HG equivalent of %s given.', $revision_id)); } return $stdout; } public function getSVNRevisionNumberFromHash($hash) { $matches = array(); list($stdout) = $this->execxLocal( 'log -r %s --template {svnrev}', $hash); if (!$stdout) { throw new ArcanistUsageException( pht('Cannot find the SVN equivalent of %s given.', $hash)); } return $stdout; } public function getSourceControlPath() { return '/'; } public function getBranchName() { if (!$this->branch) { list($stdout) = $this->execxLocal('branch'); $this->branch = trim($stdout); } return $this->branch; } protected function didReloadCommitRange() { $this->localCommitInfo = null; } protected function buildBaseCommit($symbolic_commit) { if ($symbolic_commit !== null) { try { $commit = $this->getCanonicalRevisionName( hgsprintf('ancestor(%s,.)', $symbolic_commit)); } catch (Exception $ex) { // Try it as a revset instead of a commit id try { $commit = $this->getCanonicalRevisionName( hgsprintf('ancestor(%R,.)', $symbolic_commit)); } catch (Exception $ex) { throw new ArcanistUsageException( pht( "Commit '%s' is not a valid Mercurial commit identifier.", $symbolic_commit)); } } $this->setBaseCommitExplanation( pht( 'it is the greatest common ancestor of the working directory '. 'and the commit you specified explicitly.')); return $commit; } if ($this->getBaseCommitArgumentRules() || $this->getConfigurationManager()->getConfigFromAnySource('base')) { $base = $this->resolveBaseCommit(); if (!$base) { throw new ArcanistUsageException( pht( "None of the rules in your 'base' configuration matched a valid ". "commit. Adjust rules or specify which commit you want to use ". "explicitly.")); } return $base; } // Mercurial 2.1 and up have phases which indicate if something is // published or not. To find which revs are outgoing, it's much // faster to check the phase instead of actually checking the server. if ($this->supportsPhases()) { list($err, $stdout) = $this->execManualLocal( 'log --branch %s -r %s --style default', $this->getBranchName(), 'draft()'); } else { list($err, $stdout) = $this->execManualLocal( 'outgoing --branch %s --style default', $this->getBranchName()); } if (!$err) { $logs = ArcanistMercurialParser::parseMercurialLog($stdout); } else { // Mercurial (in some versions?) raises an error when there's nothing // outgoing. $logs = array(); } if (!$logs) { $this->setBaseCommitExplanation( pht( 'you have no outgoing commits, so arc assumes you intend to submit '. 'uncommitted changes in the working copy.')); return $this->getWorkingCopyRevision(); } $outgoing_revs = ipull($logs, 'rev'); // This is essentially an implementation of a theoretical `hg merge-base` // command. $against = $this->getWorkingCopyRevision(); while (true) { // NOTE: The "^" and "~" syntaxes were only added in hg 1.9, which is // new as of July 2011, so do this in a compatible way. Also, "hg log" // and "hg outgoing" don't necessarily show parents (even if given an // explicit template consisting of just the parents token) so we need // to separately execute "hg parents". list($stdout) = $this->execxLocal( 'parents --style default --rev %s', $against); $parents_logs = ArcanistMercurialParser::parseMercurialLog($stdout); list($p1, $p2) = array_merge($parents_logs, array(null, null)); if ($p1 && !in_array($p1['rev'], $outgoing_revs)) { $against = $p1['rev']; break; } else if ($p2 && !in_array($p2['rev'], $outgoing_revs)) { $against = $p2['rev']; break; } else if ($p1) { $against = $p1['rev']; } else { // This is the case where you have a new repository and the entire // thing is outgoing; Mercurial literally accepts "--rev null" as // meaning "diff against the empty state". $against = 'null'; break; } } if ($against == 'null') { $this->setBaseCommitExplanation( pht('this is a new repository (all changes are outgoing).')); } else { $this->setBaseCommitExplanation( pht( 'it is the first commit reachable from the working copy state '. 'which is not outgoing.')); } return $against; } public function getLocalCommitInformation() { if ($this->localCommitInfo === null) { $base_commit = $this->getBaseCommit(); list($info) = $this->execxLocal( 'log --template %s --rev %s --branch %s --', "{node}\1{rev}\1{author}\1". "{date|rfc822date}\1{branch}\1{tag}\1{parents}\1{desc}\2", hgsprintf('(%s::. - %s)', $base_commit, $base_commit), $this->getBranchName()); $logs = array_filter(explode("\2", $info)); $last_node = null; $futures = array(); $commits = array(); foreach ($logs as $log) { list($node, $rev, $full_author, $date, $branch, $tag, $parents, $desc) = explode("\1", $log, 9); list($author, $author_email) = $this->parseFullAuthor($full_author); // NOTE: If a commit has only one parent, {parents} returns empty. // If it has two parents, {parents} returns revs and short hashes, not // full hashes. Try to avoid making calls to "hg parents" because it's // relatively expensive. $commit_parents = null; if (!$parents) { if ($last_node) { $commit_parents = array($last_node); } } if (!$commit_parents) { // We didn't get a cheap hit on previous commit, so do the full-cost // "hg parents" call. We can run these in parallel, at least. $futures[$node] = $this->execFutureLocal( 'parents --template %s --rev %s', '{node}\n', $node); } $commits[$node] = array( 'author' => $author, 'time' => strtotime($date), 'branch' => $branch, 'tag' => $tag, 'commit' => $node, 'rev' => $node, // TODO: Remove eventually. 'local' => $rev, 'parents' => $commit_parents, 'summary' => head(explode("\n", $desc)), 'message' => $desc, 'authorEmail' => $author_email, ); $last_node = $node; } $futures = id(new FutureIterator($futures)) ->limit(4); foreach ($futures as $node => $future) { list($parents) = $future->resolvex(); $parents = array_filter(explode("\n", $parents)); $commits[$node]['parents'] = $parents; } // Put commits in newest-first order, to be consistent with Git and the // expected order of "hg log" and "git log" under normal circumstances. // The order of ancestors() is oldest-first. $commits = array_reverse($commits); $this->localCommitInfo = $commits; } return $this->localCommitInfo; } public function getAllFiles() { // TODO: Handle paths with newlines. $future = $this->buildLocalFuture(array('manifest')); return new LinesOfALargeExecFuture($future); } public function getChangedFiles($since_commit) { list($stdout) = $this->execxLocal( 'status --rev %s', $since_commit); return ArcanistMercurialParser::parseMercurialStatus($stdout); } public function getBlame($path) { list($stdout) = $this->execxLocal( 'annotate -u -v -c --rev %s -- %s', $this->getBaseCommit(), $path); $lines = phutil_split_lines($stdout, $retain_line_endings = true); $blame = array(); foreach ($lines as $line) { if (!strlen($line)) { continue; } $matches = null; $ok = preg_match('/^\s*([^:]+?) ([a-f0-9]{12}):/', $line, $matches); if (!$ok) { throw new Exception( pht( 'Unable to parse Mercurial blame line: %s', $line)); } $revision = $matches[2]; $author = trim($matches[1]); $blame[] = array($author, $revision); } return $blame; } protected function buildUncommittedStatus() { list($stdout) = $this->execxLocal('status'); $results = new PhutilArrayWithDefaultValue(); $working_status = ArcanistMercurialParser::parseMercurialStatus($stdout); foreach ($working_status as $path => $mask) { if (!($mask & parent::FLAG_UNTRACKED)) { // Mark tracked files as uncommitted. $mask |= self::FLAG_UNCOMMITTED; } $results[$path] |= $mask; } return $results->toArray(); } protected function buildCommitRangeStatus() { // TODO: Possibly we should use "hg status --rev X --rev ." for this // instead, but we must run "hg diff" later anyway in most cases, so // building and caching it shouldn't hurt us. $diff = $this->getFullMercurialDiff(); if (!$diff) { return array(); } $parser = new ArcanistDiffParser(); $changes = $parser->parseDiff($diff); $status_map = array(); foreach ($changes as $change) { $flags = 0; switch ($change->getType()) { case ArcanistDiffChangeType::TYPE_ADD: case ArcanistDiffChangeType::TYPE_MOVE_HERE: case ArcanistDiffChangeType::TYPE_COPY_HERE: $flags |= self::FLAG_ADDED; break; case ArcanistDiffChangeType::TYPE_CHANGE: case ArcanistDiffChangeType::TYPE_COPY_AWAY: // Check for changes? $flags |= self::FLAG_MODIFIED; break; case ArcanistDiffChangeType::TYPE_DELETE: case ArcanistDiffChangeType::TYPE_MOVE_AWAY: case ArcanistDiffChangeType::TYPE_MULTICOPY: $flags |= self::FLAG_DELETED; break; } $status_map[$change->getCurrentPath()] = $flags; } return $status_map; } protected function didReloadWorkingCopy() { // Diffs are against ".", so we need to drop the cache if we change the // working copy. $this->rawDiffCache = array(); $this->branch = null; } private function getDiffOptions() { $options = array( '--git', '-U'.$this->getDiffLinesOfContext(), ); return implode(' ', $options); } public function getRawDiffText($path) { $options = $this->getDiffOptions(); $range = $this->getBaseCommit(); $raw_diff_cache_key = $options.' '.$range.' '.$path; if (idx($this->rawDiffCache, $raw_diff_cache_key)) { return idx($this->rawDiffCache, $raw_diff_cache_key); } list($stdout) = $this->execxLocal( 'diff %C --rev %s -- %s', $options, $range, $path); $this->rawDiffCache[$raw_diff_cache_key] = $stdout; return $stdout; } public function getFullMercurialDiff() { return $this->getRawDiffText(''); } public function getOriginalFileData($path) { return $this->getFileDataAtRevision($path, $this->getBaseCommit()); } public function getCurrentFileData($path) { return $this->getFileDataAtRevision( $path, $this->getWorkingCopyRevision()); } public function getBulkOriginalFileData($paths) { return $this->getBulkFileDataAtRevision($paths, $this->getBaseCommit()); } public function getBulkCurrentFileData($paths) { return $this->getBulkFileDataAtRevision( $paths, $this->getWorkingCopyRevision()); } private function getBulkFileDataAtRevision($paths, $revision) { // Calling 'hg cat' on each file individually is slow (1 second per file // on a large repo) because mercurial has to decompress and parse the // entire manifest every time. Do it in one large batch instead. // hg cat will write the file data to files in a temp directory $tmpdir = Filesystem::createTemporaryDirectory(); // Mercurial doesn't create the directories for us :( foreach ($paths as $path) { $tmppath = $tmpdir.'/'.$path; Filesystem::createDirectory(dirname($tmppath), 0755, true); } list($err, $stdout) = $this->execManualLocal( 'cat --rev %s --output %s -- %C', $revision, // %p is the formatter for the repo-relative filepath $tmpdir.'/%p', implode(' ', $paths)); $filedata = array(); foreach ($paths as $path) { $tmppath = $tmpdir.'/'.$path; if (Filesystem::pathExists($tmppath)) { $filedata[$path] = Filesystem::readFile($tmppath); } } Filesystem::remove($tmpdir); return $filedata; } private function getFileDataAtRevision($path, $revision) { list($err, $stdout) = $this->execManualLocal( 'cat --rev %s -- %s', $revision, $path); if ($err) { // Assume this is "no file at revision", i.e. a deleted or added file. return null; } else { return $stdout; } } public function getWorkingCopyRevision() { return '.'; } public function isHistoryDefaultImmutable() { return true; } public function supportsAmend() { list($err, $stdout) = $this->execManualLocal('help commit'); if ($err) { return false; } else { return (strpos($stdout, 'amend') !== false); } } public function supportsRebase() { if ($this->supportsRebase === null) { list($err) = $this->execManualLocal('help rebase'); $this->supportsRebase = $err === 0; } return $this->supportsRebase; } public function supportsPhases() { if ($this->supportsPhases === null) { list($err) = $this->execManualLocal('help phase'); $this->supportsPhases = $err === 0; } return $this->supportsPhases; } public function supportsCommitRanges() { return true; } public function supportsLocalCommits() { return true; } public function getAllBranches() { list($branch_info) = $this->execxLocal('bookmarks'); if (trim($branch_info) == 'no bookmarks set') { return array(); } $matches = null; preg_match_all( '/^\s*(\*?)\s*(.+)\s(\S+)$/m', $branch_info, $matches, PREG_SET_ORDER); $return = array(); foreach ($matches as $match) { list(, $current, $name) = $match; $return[] = array( 'current' => (bool)$current, 'name' => rtrim($name), ); } return $return; } public function getAllBranchRefs() { $branches = $this->getAllBranches(); $refs = array(); foreach ($branches as $branch) { $refs[] = $this->newBranchRef() ->setBranchName($branch['name']) ->setIsCurrentBranch($branch['current']); } return $refs; } + public function getBaseCommitRef() { + $base_commit = $this->getBaseCommit(); + + if ($base_commit === 'null') { + return null; + } + + $base_message = $this->getCommitMessage($base_commit); + + return $this->newCommitRef() + ->setCommitHash($base_commit) + ->attachMessage($base_message); + } + public function hasLocalCommit($commit) { try { $this->getCanonicalRevisionName($commit); return true; } catch (Exception $ex) { return false; } } public function getCommitMessage($commit) { list($message) = $this->execxLocal( 'log --template={desc} --rev %s', $commit); return $message; } public function getAllLocalChanges() { $diff = $this->getFullMercurialDiff(); if (!strlen(trim($diff))) { return array(); } $parser = new ArcanistDiffParser(); return $parser->parseDiff($diff); } public function supportsLocalBranchMerge() { return true; } public function performLocalBranchMerge($branch, $message) { if ($branch) { $err = phutil_passthru( '(cd %s && HGPLAIN=1 hg merge --rev %s && hg commit -m %s)', $this->getPath(), $branch, $message); } else { $err = phutil_passthru( '(cd %s && HGPLAIN=1 hg merge && hg commit -m %s)', $this->getPath(), $message); } if ($err) { throw new ArcanistUsageException(pht('Merge failed!')); } } public function getFinalizedRevisionMessage() { return pht( "You may now push this commit upstream, as appropriate (e.g. with ". "'%s' or by printing and faxing it).", 'hg push'); } public function getCommitMessageLog() { $base_commit = $this->getBaseCommit(); list($stdout) = $this->execxLocal( 'log --template %s --rev %s --branch %s --', "{node}\1{desc}\2", hgsprintf('(%s::. - %s)', $base_commit, $base_commit), $this->getBranchName()); $map = array(); $logs = explode("\2", trim($stdout)); foreach (array_filter($logs) as $log) { list($node, $desc) = explode("\1", $log); $map[$node] = $desc; } return array_reverse($map); } public function loadWorkingCopyDifferentialRevisions( ConduitClient $conduit, array $query) { $messages = $this->getCommitMessageLog(); $parser = new ArcanistDiffParser(); // First, try to find revisions by explicit revision IDs in commit messages. $reason_map = array(); $revision_ids = array(); foreach ($messages as $node_id => $message) { $object = ArcanistDifferentialCommitMessage::newFromRawCorpus($message); if ($object->getRevisionID()) { $revision_ids[] = $object->getRevisionID(); $reason_map[$object->getRevisionID()] = $node_id; } } if ($revision_ids) { $results = $conduit->callMethodSynchronous( 'differential.query', $query + array( 'ids' => $revision_ids, )); foreach ($results as $key => $result) { $hash = substr($reason_map[$result['id']], 0, 16); $results[$key]['why'] = pht( "Commit message for '%s' has explicit 'Differential Revision'.", $hash); } return $results; } // Try to find revisions by hash. $hashes = array(); foreach ($this->getLocalCommitInformation() as $commit) { $hashes[] = array('hgcm', $commit['commit']); } if ($hashes) { // NOTE: In the case of "arc diff . --uncommitted" in a Mercurial working // copy with dirty changes, there may be no local commits. $results = $conduit->callMethodSynchronous( 'differential.query', $query + array( 'commitHashes' => $hashes, )); foreach ($results as $key => $hash) { $results[$key]['why'] = pht( 'A mercurial commit hash in the commit range is already attached '. 'to the Differential revision.'); } return $results; } return array(); } public function updateWorkingCopy() { $this->execxLocal('up'); $this->reloadWorkingCopy(); } private function getMercurialConfig($key, $default = null) { list($stdout) = $this->execxLocal('showconfig %s', $key); if ($stdout == '') { return $default; } return rtrim($stdout); } public function getAuthor() { $full_author = $this->getMercurialConfig('ui.username'); list($author, $author_email) = $this->parseFullAuthor($full_author); return $author; } /** * Parse the Mercurial author field. * * Not everyone enters their email address as a part of the username * field. Try to make it work when it's obvious. * * @param string $full_author * @return array */ protected function parseFullAuthor($full_author) { if (strpos($full_author, '@') === false) { $author = $full_author; $author_email = null; } else { $email = new PhutilEmailAddress($full_author); $author = $email->getDisplayName(); $author_email = $email->getAddress(); } return array($author, $author_email); } public function addToCommit(array $paths) { $this->execxLocal( 'addremove -- %Ls', $paths); $this->reloadWorkingCopy(); } public function doCommit($message) { $tmp_file = new TempFile(); Filesystem::writeFile($tmp_file, $message); $this->execxLocal('commit -l %s', $tmp_file); $this->reloadWorkingCopy(); } public function amendCommit($message = null) { if ($message === null) { $message = $this->getCommitMessage('.'); } $tmp_file = new TempFile(); Filesystem::writeFile($tmp_file, $message); try { $this->execxLocal( 'commit --amend -l %s', $tmp_file); } catch (CommandException $ex) { if (preg_match('/nothing changed/', $ex->getStdout())) { // NOTE: Mercurial considers it an error to make a no-op amend. Although // we generally defer to the underlying VCS to dictate behavior, this // one seems a little goofy, and we use amend as part of various // workflows under the assumption that no-op amends are fine. If this // amend failed because it's a no-op, just continue. } else { throw $ex; } } $this->reloadWorkingCopy(); } public function getCommitSummary($commit) { if ($commit == 'null') { return pht('(The Empty Void)'); } list($summary) = $this->execxLocal( 'log --template {desc} --limit 1 --rev %s', $commit); $summary = head(explode("\n", $summary)); return trim($summary); } public function backoutCommit($commit_hash) { $this->execxLocal('backout -r %s', $commit_hash); $this->reloadWorkingCopy(); if (!$this->getUncommittedStatus()) { throw new ArcanistUsageException( pht('%s has already been reverted.', $commit_hash)); } } public function getBackoutMessage($commit_hash) { return pht('Backed out changeset %s,', $commit_hash); } public function resolveBaseCommitRule($rule, $source) { list($type, $name) = explode(':', $rule, 2); // NOTE: This function MUST return node hashes or symbolic commits (like // branch names or the word "tip"), not revsets. This includes ".^" and // similar, which a revset, not a symbolic commit identifier. If you return // a revset it will be escaped later and looked up literally. switch ($type) { case 'hg': $matches = null; if (preg_match('/^gca\((.+)\)$/', $name, $matches)) { list($err, $merge_base) = $this->execManualLocal( 'log --template={node} --rev %s', sprintf('ancestor(., %s)', $matches[1])); if (!$err) { $this->setBaseCommitExplanation( pht( "it is the greatest common ancestor of '%s' and %s, as ". "specified by '%s' in your %s 'base' configuration.", $matches[1], '.', $rule, $source)); return trim($merge_base); } } else { list($err, $commit) = $this->execManualLocal( 'log --template {node} --rev %s', hgsprintf('%s', $name)); if ($err) { list($err, $commit) = $this->execManualLocal( 'log --template {node} --rev %s', $name); } if (!$err) { $this->setBaseCommitExplanation( pht( "it is specified by '%s' in your %s 'base' configuration.", $rule, $source)); return trim($commit); } } break; case 'arc': switch ($name) { case 'empty': $this->setBaseCommitExplanation( pht( "you specified '%s' in your %s 'base' configuration.", $rule, $source)); return 'null'; case 'outgoing': list($err, $outgoing_base) = $this->execManualLocal( 'log --template={node} --rev %s', 'limit(reverse(ancestors(.) - outgoing()), 1)'); if (!$err) { $this->setBaseCommitExplanation( pht( "it is the first ancestor of the working copy that is not ". "outgoing, and it matched the rule %s in your %s ". "'base' configuration.", $rule, $source)); return trim($outgoing_base); } case 'amended': $text = $this->getCommitMessage('.'); $message = ArcanistDifferentialCommitMessage::newFromRawCorpus( $text); if ($message->getRevisionID()) { $this->setBaseCommitExplanation( pht( "'%s' has been amended with 'Differential Revision:', ". "as specified by '%s' in your %s 'base' configuration.", '.'. $rule, $source)); // NOTE: This should be safe because Mercurial doesn't support // amend until 2.2. return $this->getCanonicalRevisionName('.^'); } break; case 'bookmark': $revset = 'limit('. ' sort('. ' (ancestors(.) and bookmark() - .) or'. ' (ancestors(.) - outgoing()), '. ' -rev),'. '1)'; list($err, $bookmark_base) = $this->execManualLocal( 'log --template={node} --rev %s', $revset); if (!$err) { $this->setBaseCommitExplanation( pht( "it is the first ancestor of %s that either has a bookmark, ". "or is already in the remote and it matched the rule %s in ". "your %s 'base' configuration", '.', $rule, $source)); return trim($bookmark_base); } break; case 'this': $this->setBaseCommitExplanation( pht( "you specified '%s' in your %s 'base' configuration.", $rule, $source)); return $this->getCanonicalRevisionName('.^'); default: if (preg_match('/^nodiff\((.+)\)$/', $name, $matches)) { list($results) = $this->execxLocal( 'log --template %s --rev %s', "{node}\1{desc}\2", sprintf('ancestor(.,%s)::.^', $matches[1])); $results = array_reverse(explode("\2", trim($results))); foreach ($results as $result) { if (empty($result)) { continue; } list($node, $desc) = explode("\1", $result, 2); $message = ArcanistDifferentialCommitMessage::newFromRawCorpus( $desc); if ($message->getRevisionID()) { $this->setBaseCommitExplanation( pht( "it is the first ancestor of %s that has a diff and is ". "the gca or a descendant of the gca with '%s', ". "specified by '%s' in your %s 'base' configuration.", '.', $matches[1], $rule, $source)); return $node; } } } break; } break; default: return null; } return null; } public function isHgSubversionRepo() { return file_exists($this->getPath('.hg/svn/rev_map')); } public function getSubversionInfo() { $info = array(); $base_path = null; $revision = null; list($err, $raw_info) = $this->execManualLocal('svn info'); if (!$err) { foreach (explode("\n", trim($raw_info)) as $line) { list($key, $value) = explode(': ', $line, 2); switch ($key) { case 'URL': $info['base_path'] = $value; $base_path = $value; break; case 'Repository UUID': $info['uuid'] = $value; break; case 'Revision': $revision = $value; break; default: break; } } if ($base_path && $revision) { $info['base_revision'] = $base_path.'@'.$revision; } } return $info; } public function getActiveBookmark() { $bookmarks = $this->getBookmarks(); foreach ($bookmarks as $bookmark) { if ($bookmark['is_active']) { return $bookmark['name']; } } return null; } public function isBookmark($name) { $bookmarks = $this->getBookmarks(); foreach ($bookmarks as $bookmark) { if ($bookmark['name'] === $name) { return true; } } return false; } public function isBranch($name) { $branches = $this->getBranches(); foreach ($branches as $branch) { if ($branch['name'] === $name) { return true; } } return false; } public function getBranches() { list($stdout) = $this->execxLocal('--debug branches'); $lines = ArcanistMercurialParser::parseMercurialBranches($stdout); $branches = array(); foreach ($lines as $name => $spec) { $branches[] = array( 'name' => $name, 'revision' => $spec['rev'], ); } return $branches; } public function getBookmarks() { $bookmarks = array(); list($raw_output) = $this->execxLocal('bookmarks'); $raw_output = trim($raw_output); if ($raw_output !== 'no bookmarks set') { foreach (explode("\n", $raw_output) as $line) { // example line: * mybook 2:6b274d49be97 list($name, $revision) = $this->splitBranchOrBookmarkLine($line); $is_active = false; if ('*' === $name[0]) { $is_active = true; $name = substr($name, 2); } $bookmarks[] = array( 'is_active' => $is_active, 'name' => $name, 'revision' => $revision, ); } } return $bookmarks; } private function splitBranchOrBookmarkLine($line) { // branches and bookmarks are printed in the format: // default 0:a5ead76cdf85 (inactive) // * mybook 2:6b274d49be97 // this code divides the name half from the revision half // it does not parse the * and (inactive) bits $colon_index = strrpos($line, ':'); $before_colon = substr($line, 0, $colon_index); $start_rev_index = strrpos($before_colon, ' '); $name = substr($line, 0, $start_rev_index); $rev = substr($line, $start_rev_index); return array(trim($name), trim($rev)); } public function getRemoteURI() { list($stdout) = $this->execxLocal('paths default'); $stdout = trim($stdout); if (strlen($stdout)) { return $stdout; } return null; } }