diff --git a/src/ref/ArcanistRepositoryRef.php b/src/ref/ArcanistRepositoryRef.php index ffbe52fb..b0122ab4 100644 --- a/src/ref/ArcanistRepositoryRef.php +++ b/src/ref/ArcanistRepositoryRef.php @@ -1,111 +1,146 @@ phid = $phid; return $this; } public function getPHID() { return $this->phid; } public function setBrowseURI($browse_uri) { $this->browseURI = $browse_uri; return $this; } public static function newFromConduit(array $map) { $ref = new self(); $ref->parameters = $map; $ref->phid = $map['phid']; return $ref; } public function getURIs() { $uris = idxv($this->parameters, array('attachments', 'uris', 'uris')); if (!$uris) { return array(); } $results = array(); foreach ($uris as $uri) { $effective_uri = idxv($uri, array('fields', 'uri', 'effective')); if ($effective_uri !== null) { $results[] = $effective_uri; } } return $results; } public function getDisplayName() { return idxv($this->parameters, array('fields', 'name')); } public function newBrowseURI(array $params) { PhutilTypeSpec::checkMap( $params, array( 'path' => 'optional string|null', 'branch' => 'optional string|null', 'lines' => 'optional string|null', )); foreach ($params as $key => $value) { if (!strlen($value)) { unset($params[$key]); } } $defaults = array( 'path' => '/', 'branch' => $this->getDefaultBranch(), 'lines' => null, ); $params = $params + $defaults; $uri_base = $this->browseURI; $uri_base = rtrim($uri_base, '/'); $uri_branch = phutil_escape_uri_path_component($params['branch']); $uri_path = ltrim($params['path'], '/'); $uri_path = phutil_escape_uri($uri_path); $uri_lines = null; if ($params['lines']) { $uri_lines = '$'.phutil_escape_uri($params['lines']); } // TODO: This construction, which includes a branch, is probably wrong for // Subversion. return "{$uri_base}/browse/{$uri_branch}/{$uri_path}{$uri_lines}"; } public function getDefaultBranch() { $branch = idxv($this->parameters, array('fields', 'defaultBranch')); if ($branch === null) { return 'master'; } return $branch; } + public function isPermanentRef(ArcanistMarkerRef $ref) { + $rules = idxv( + $this->parameters, + array('fields', 'refRules', 'permanentRefRules')); + + if ($rules === null) { + return false; + } + + // If the rules exist but there are no specified rules, treat every ref + // as permanent. + if (!$rules) { + return true; + } + + // TODO: It would be nice to unify evaluation of permanent ref rules + // across Arcanist and Phabricator. + + $ref_name = $ref->getName(); + foreach ($rules as $rule) { + $matches = null; + if (preg_match('(^regexp\\((.*)\\)\z)', $rule, $matches)) { + if (preg_match($matches[1], $ref_name)) { + return true; + } + } else { + if ($rule === $ref_name) { + return true; + } + } + } + + return false; + } + } diff --git a/src/repository/api/ArcanistGitAPI.php b/src/repository/api/ArcanistGitAPI.php index 5b5c56cc..fb2e883a 100644 --- a/src/repository/api/ArcanistGitAPI.php +++ b/src/repository/api/ArcanistGitAPI.php @@ -1,1776 +1,1819 @@ setCWD($this->getPath()); } public function newPassthru($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 newv('PhutilExecPassthru', $args) ->setCWD($this->getPath()); } public function getSourceControlSystemName() { return 'git'; } public function getGitVersion() { static $version = null; if ($version === null) { list($stdout) = $this->execxLocal('--version'); $version = rtrim(str_replace('git version ', '', $stdout)); } return $version; } 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(); $config_options = array(); // See T13432. Disable the rare "diff.suppressBlankEmpty" configuration // option, which discards the " " (space) change type prefix on unchanged // blank lines. At time of writing the parser does not handle these // properly, but generating a more-standard diff is generally desirable // even if a future parser handles this case more gracefully. $config_options[] = '-c'; $config_options[] = 'diff.suppressBlankEmpty=false'; if ($head !== null) { list($stdout) = $this->execxLocal( "%LR diff {$options} %s %s --", $config_options, $base, $head); } else { list($stdout) = $this->execxLocal( "%LR diff {$options} %s --", $config_options, $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; } if (!strlen($branch)) { 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 === null) { 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(); } } return $this->getGitRemoteFetchURI($remote); } 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( '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'); } private function buildUncommittedStatusViaStatus() { $status = $this->buildLocalFuture( array( 'status --porcelain=2 -z', )); list($stdout) = $status->resolvex(); $result = new PhutilArrayWithDefaultValue(); $parts = explode("\0", $stdout); while (count($parts) > 1) { $entry = array_shift($parts); $entry_parts = explode(' ', $entry, 2); if ($entry_parts[0] == '1') { $entry_parts = explode(' ', $entry, 9); $path = $entry_parts[8]; } else if ($entry_parts[0] == '2') { $entry_parts = explode(' ', $entry, 10); $path = $entry_parts[9]; } else if ($entry_parts[0] == 'u') { $entry_parts = explode(' ', $entry, 11); $path = $entry_parts[10]; } else if ($entry_parts[0] == '?') { $entry_parts = explode(' ', $entry, 2); $result[$entry_parts[1]] = self::FLAG_UNTRACKED; continue; } $result[$path] |= self::FLAG_UNCOMMITTED; $index_state = substr($entry_parts[1], 0, 1); $working_state = substr($entry_parts[1], 1, 1); if ($index_state == 'A') { $result[$path] |= self::FLAG_ADDED; } else if ($index_state == 'M') { $result[$path] |= self::FLAG_MODIFIED; } else if ($index_state == 'D') { $result[$path] |= self::FLAG_DELETED; } if ($working_state != '.') { $result[$path] |= self::FLAG_UNSTAGED; if ($index_state == '.') { if ($working_state == 'A') { $result[$path] |= self::FLAG_ADDED; } else if ($working_state == 'M') { $result[$path] |= self::FLAG_MODIFIED; } else if ($working_state == 'D') { $result[$path] |= self::FLAG_DELETED; } } } $submodule_tracked = substr($entry_parts[2], 2, 1); $submodule_untracked = substr($entry_parts[2], 3, 1); if ($submodule_tracked == 'M' || $submodule_untracked == 'U') { $result[$path] |= self::FLAG_EXTERNALS; } if ($entry_parts[0] == '2') { $result[array_shift($parts)] = $result[$path] | self::FLAG_DELETED; $result[$path] |= self::FLAG_ADDED; } } return $result->toArray(); } protected function buildUncommittedStatus() { if (version_compare($this->getGitVersion(), '2.11.0', '>=')) { return $this->buildUncommittedStatusViaStatus(); } $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 HEAD --', $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. if (!strlen($path)) { // No filename, so there's no content (Probably new/deleted file). return null; } 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. */ private 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 !== null) { $result[] = array( 'current' => ($branch === $current), 'name' => $branch, 'ref' => $ref, 'hash' => $hash, 'tree' => $tree, 'epoch' => (int)$epoch, 'desc' => $desc, 'text' => $text, ); } } return $result; } public function getBaseCommitRef() { $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 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 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; } public function isPerforceRemote($remote_name) { // See T13434. In Perforce workflows, "git p4 clone" creates "p4" refs // under "refs/remotes/", but does not define a real remote named "p4". // We treat this remote as though it were a real remote during "arc land", // but it does not respond to commands like "git remote show p4", so we // need to handle it specially. if ($remote_name !== 'p4') { return false; } $remote_dir = $this->getMetadataPath().'/refs/remotes/p4'; if (!Filesystem::pathExists($remote_dir)) { return false; } return true; } public function isPushableRemote($remote_name) { $uri = $this->getGitRemotePushURI($remote_name); return ($uri !== null); } public function isFetchableRemote($remote_name) { $uri = $this->getGitRemoteFetchURI($remote_name); return ($uri !== null); } private function getGitRemoteFetchURI($remote_name) { return $this->getGitRemoteURI($remote_name, $for_push = false); } private function getGitRemotePushURI($remote_name) { return $this->getGitRemoteURI($remote_name, $for_push = true); } private function getGitRemoteURI($remote_name, $for_push) { $remote_uri = $this->loadGitRemoteURI($remote_name, $for_push); if ($remote_uri !== null) { $remote_uri = rtrim($remote_uri); if (!strlen($remote_uri)) { $remote_uri = null; } } return $remote_uri; } private function loadGitRemoteURI($remote_name, $for_push) { // Try to identify the best URI for a given remote. This is complicated // because remotes may have different "push" and "fetch" URIs, may // rewrite URIs with "insteadOf" configuration, and different versions // of Git support different URI resolution commands. // Remotes may also have more than one URI of a given type, but we ignore // those cases here. // Start with "git remote get-url [--push]". This is the simplest and // most accurate command, but was introduced most recently in Git's // history. $argv = array(); if ($for_push) { $argv[] = '--push'; } list($err, $stdout) = $this->execManualLocal( 'remote get-url %Ls -- %s', $argv, $remote_name); if (!$err) { return $stdout; } // See T13481. If "git remote get-url [--push]" failed, it might be because // the remote does not exist, but it might also be because the version of // Git is too old to support "git remote get-url", which was introduced // in Git 2.7 (circa late 2015). $git_version = $this->getGitVersion(); if (version_compare($git_version, '2.7', '>=')) { // This version of Git should support "git remote get-url --push", but // the command failed, so conclude this is not a valid remote and thus // there is no remote URI. return null; } // If we arrive here, we're in a version of Git which is too old to // support "git remote get-url [--push]". We're going to fall back to // older and less accurate mechanisms for figuring out the remote URI. // The first mechanism we try is "git ls-remote --get-url". This exists // in Git 1.7.5 or newer. It only gives us the fetch URI, so this result // will be incorrect if a remote has different fetch and push URIs. // However, this is very rare, and this result is almost always correct. // Note that some old versions of Git do not parse "--" in this command // properly. We omit it since it doesn't seem like there's anything // dangerous an attacker can do even if they can choose a remote name to // intentionally cause an argument misparse. // This will cause the command to behave incorrectly for remotes with // names which are also valid flags, like "--quiet". list($err, $stdout) = $this->execManualLocal( 'ls-remote --get-url %s', $remote_name); if (!$err) { // The "git ls-remote --get-url" command just echoes the remote name // (like "origin") if no remote URI is found. Treat this like a failure. $output_is_input = (rtrim($stdout) === $remote_name); if (!$output_is_input) { return $stdout; } } if (version_compare($git_version, '1.7.5', '>=')) { // This version of Git should support "git ls-remote --get-url", but // the command failed (or echoed the input), so conclude the remote // really does not exist. return null; } // Fall back to the very old "git config -- remote.origin.url" command. // This does not give us push URLs and does not resolve "insteadOf" // aliases, but still works in the simplest (and most common) cases. list($err, $stdout) = $this->execManualLocal( 'config -- %s', sprintf('remote.%s.url', $remote_name)); if (!$err) { return $stdout; } return null; } protected function newCurrentCommitSymbol() { return 'HEAD'; } public function isGitLFSWorkingCopy() { // We're going to run: // // $ git ls-files -z -- ':(attr:filter=lfs)' // // ...and exit as soon as it generates any field terminated with a "\0". // // If this command generates any such output, that means this working copy // contains at least one LFS file, so it's an LFS working copy. If it // exits with no error and no output, this is not an LFS working copy. // // If it exits with an error, we're in trouble. $future = $this->buildLocalFuture( array( 'ls-files -z -- %s', ':(attr:filter=lfs)', )); $lfs_list = id(new LinesOfALargeExecFuture($future)) ->setDelimiter("\0"); try { foreach ($lfs_list as $lfs_file) { // We have our answer, so we can throw the subprocess away. $future->resolveKill(); return true; } return false; } catch (CommandException $ex) { // This is probably an older version of Git. Continue below. } // In older versions of Git, the first command will fail with an error // ("Invalid pathspec magic..."). See PHI1718. // // Some other tests we could use include: // // (1) Look for ".gitattributes" at the repository root. This approach is // a rough approximation because ".gitattributes" may be global or in a // subdirectory. See D21190. // // (2) Use "git check-attr" and pipe a bunch of files into it, roughly // like this: // // $ git ls-files -z -- | git check-attr --stdin -z filter -- // // However, the best version of this check I could come up with is fairly // slow in even moderately large repositories (~200ms in a repository with // 10K paths). See D21190. // // (3) Use "git lfs ls-files". This is even worse than piping "ls-files" // to "check-attr" in PHP (~600ms in a repository with 10K paths). // // (4) Give up and just assume the repository isn't LFS. This is the // current behavior. return false; } protected function newLandEngine() { return new ArcanistGitLandEngine(); } protected function newWorkEngine() { return new ArcanistGitWorkEngine(); } public function newLocalState() { return id(new ArcanistGitLocalState()) ->setRepositoryAPI($this); } public function readRawCommit($hash) { list($stdout) = $this->execxLocal( 'cat-file commit -- %s', $hash); return ArcanistGitRawCommit::newFromRawBlob($stdout); } public function writeRawCommit(ArcanistGitRawCommit $commit) { $blob = $commit->getRawBlob(); $future = $this->execFutureLocal('hash-object -t commit --stdin -w'); $future->write($blob); list($stdout) = $future->resolvex(); return trim($stdout); } protected function newSupportedMarkerTypes() { return array( ArcanistMarkerRef::TYPE_BRANCH, ); } protected function newMarkerRefQueryTemplate() { return new ArcanistGitRepositoryMarkerQuery(); } protected function newRemoteRefQueryTemplate() { return new ArcanistGitRepositoryRemoteQuery(); } protected function newNormalizedURI($uri) { return new ArcanistRepositoryURINormalizer( ArcanistRepositoryURINormalizer::TYPE_GIT, $uri); } + protected function newPublishedCommitHashes() { + $remotes = $this->newRemoteRefQuery() + ->execute(); + if (!$remotes) { + return array(); + } + + $markers = $this->newMarkerRefQuery() + ->withIsRemoteCache(true) + ->execute(); + + if (!$markers) { + return array(); + } + + $runtime = $this->getRuntime(); + $workflow = $runtime->getCurrentWorkflow(); + + $workflow->loadHardpoints( + $remotes, + ArcanistRemoteRef::HARDPOINT_REPOSITORYREFS); + + $remotes = mpull($remotes, null, 'getRemoteName'); + + $hashes = array(); + + foreach ($markers as $marker) { + $remote_name = $marker->getRemoteName(); + $remote = idx($remotes, $remote_name); + if (!$remote) { + continue; + } + + if (!$remote->isPermanentRef($marker)) { + continue; + } + + $hashes[] = $marker->getCommitHash(); + } + + return $hashes; + } + } diff --git a/src/repository/api/ArcanistRepositoryAPI.php b/src/repository/api/ArcanistRepositoryAPI.php index 79052eaa..2a5e3555 100644 --- a/src/repository/api/ArcanistRepositoryAPI.php +++ b/src/repository/api/ArcanistRepositoryAPI.php @@ -1,810 +1,818 @@ diffLinesOfContext; } public function setDiffLinesOfContext($lines) { $this->diffLinesOfContext = $lines; return $this; } public function getWorkingCopyIdentity() { return $this->configurationManager->getWorkingCopyIdentity(); } public function getConfigurationManager() { return $this->configurationManager; } public static function newAPIFromConfigurationManager( ArcanistConfigurationManager $configuration_manager) { $working_copy = $configuration_manager->getWorkingCopyIdentity(); if (!$working_copy) { throw new Exception( pht( 'Trying to create a %s without a working copy!', __CLASS__)); } $root = $working_copy->getProjectRoot(); switch ($working_copy->getVCSType()) { case 'svn': $api = new ArcanistSubversionAPI($root); break; case 'hg': $api = new ArcanistMercurialAPI($root); break; case 'git': $api = new ArcanistGitAPI($root); break; default: throw new Exception( pht( 'The current working directory is not part of a working copy for '. 'a supported version control system (Git, Subversion or '. 'Mercurial).')); } $api->configurationManager = $configuration_manager; return $api; } public function __construct($path) { $this->path = $path; } public function getPath($to_file = null) { if ($to_file !== null) { return $this->path.DIRECTORY_SEPARATOR. ltrim($to_file, DIRECTORY_SEPARATOR); } else { return $this->path.DIRECTORY_SEPARATOR; } } /* -( Path Status )-------------------------------------------------------- */ abstract protected function buildUncommittedStatus(); abstract protected function buildCommitRangeStatus(); /** * Get a list of uncommitted paths in the working copy that have been changed * or are affected by other status effects, like conflicts or untracked * files. * * Convenience methods @{method:getUntrackedChanges}, * @{method:getUnstagedChanges}, @{method:getUncommittedChanges}, * @{method:getMergeConflicts}, and @{method:getIncompleteChanges} allow * simpler selection of paths in a specific state. * * This method returns a map of paths to bitmasks with status, using * `FLAG_` constants. For example: * * array( * 'some/uncommitted/file.txt' => ArcanistRepositoryAPI::FLAG_UNSTAGED, * ); * * A file may be in several states. Not all states are possible with all * version control systems. * * @return map Map of paths, see above. * @task status */ final public function getUncommittedStatus() { if ($this->uncommittedStatusCache === null) { $status = $this->buildUncommittedStatus(); ksort($status); $this->uncommittedStatusCache = $status; } return $this->uncommittedStatusCache; } /** * @task status */ final public function getUntrackedChanges() { return $this->getUncommittedPathsWithMask(self::FLAG_UNTRACKED); } /** * @task status */ final public function getUnstagedChanges() { return $this->getUncommittedPathsWithMask(self::FLAG_UNSTAGED); } /** * @task status */ final public function getUncommittedChanges() { return $this->getUncommittedPathsWithMask(self::FLAG_UNCOMMITTED); } /** * @task status */ final public function getMergeConflicts() { return $this->getUncommittedPathsWithMask(self::FLAG_CONFLICT); } /** * @task status */ final public function getIncompleteChanges() { return $this->getUncommittedPathsWithMask(self::FLAG_INCOMPLETE); } /** * @task status */ final public function getMissingChanges() { return $this->getUncommittedPathsWithMask(self::FLAG_MISSING); } /** * @task status */ final public function getDirtyExternalChanges() { return $this->getUncommittedPathsWithMask(self::FLAG_EXTERNALS); } /** * @task status */ private function getUncommittedPathsWithMask($mask) { $match = array(); foreach ($this->getUncommittedStatus() as $path => $flags) { if ($flags & $mask) { $match[] = $path; } } return $match; } /** * Get a list of paths affected by the commits in the current commit range. * * See @{method:getUncommittedStatus} for a description of the return value. * * @return map Map from paths to status. * @task status */ final public function getCommitRangeStatus() { if ($this->commitRangeStatusCache === null) { $status = $this->buildCommitRangeStatus(); ksort($status); $this->commitRangeStatusCache = $status; } return $this->commitRangeStatusCache; } /** * Get a list of paths affected by commits in the current commit range, or * uncommitted changes in the working copy. See @{method:getUncommittedStatus} * or @{method:getCommitRangeStatus} to retrieve smaller parts of the status. * * See @{method:getUncommittedStatus} for a description of the return value. * * @return map Map from paths to status. * @task status */ final public function getWorkingCopyStatus() { $range_status = $this->getCommitRangeStatus(); $uncommitted_status = $this->getUncommittedStatus(); $result = new PhutilArrayWithDefaultValue($range_status); foreach ($uncommitted_status as $path => $mask) { $result[$path] |= $mask; } $result = $result->toArray(); ksort($result); return $result; } /** * Drops caches after changes to the working copy. By default, some queries * against the working copy are cached. They * * @return this * @task status */ final public function reloadWorkingCopy() { $this->uncommittedStatusCache = null; $this->commitRangeStatusCache = null; $this->didReloadWorkingCopy(); $this->reloadCommitRange(); return $this; } /** * Hook for implementations to dirty working copy caches after the working * copy has been updated. * * @return void * @task status */ protected function didReloadWorkingCopy() { return; } /** * Fetches the original file data for each path provided. * * @return map Map from path to file data. */ public function getBulkOriginalFileData($paths) { $filedata = array(); foreach ($paths as $path) { $filedata[$path] = $this->getOriginalFileData($path); } return $filedata; } /** * Fetches the current file data for each path provided. * * @return map Map from path to file data. */ public function getBulkCurrentFileData($paths) { $filedata = array(); foreach ($paths as $path) { $filedata[$path] = $this->getCurrentFileData($path); } return $filedata; } /** * @return Traversable */ abstract public function getAllFiles(); abstract public function getBlame($path); abstract public function getRawDiffText($path); abstract public function getOriginalFileData($path); abstract public function getCurrentFileData($path); abstract public function getLocalCommitInformation(); abstract public function getSourceControlBaseRevision(); abstract public function getCanonicalRevisionName($string); abstract public function getBranchName(); abstract public function getSourceControlPath(); abstract public function isHistoryDefaultImmutable(); abstract public function supportsAmend(); abstract public function getWorkingCopyRevision(); abstract public function updateWorkingCopy(); abstract public function getMetadataPath(); abstract public function loadWorkingCopyDifferentialRevisions( ConduitClient $conduit, array $query); abstract public function getRemoteURI(); public function getChangedFiles($since_commit) { throw new ArcanistCapabilityNotSupportedException($this); } public function getAuthor() { throw new ArcanistCapabilityNotSupportedException($this); } public function addToCommit(array $paths) { throw new ArcanistCapabilityNotSupportedException($this); } abstract public function supportsLocalCommits(); public function doCommit($message) { throw new ArcanistCapabilityNotSupportedException($this); } public function amendCommit($message = null) { throw new ArcanistCapabilityNotSupportedException($this); } public function getBaseCommitRef() { throw new ArcanistCapabilityNotSupportedException($this); } public function hasLocalCommit($commit) { throw new ArcanistCapabilityNotSupportedException($this); } public function getCommitMessage($commit) { throw new ArcanistCapabilityNotSupportedException($this); } public function getCommitSummary($commit) { throw new ArcanistCapabilityNotSupportedException($this); } public function getAllLocalChanges() { throw new ArcanistCapabilityNotSupportedException($this); } public function getFinalizedRevisionMessage() { throw new ArcanistCapabilityNotSupportedException($this); } public function execxLocal($pattern /* , ... */) { $args = func_get_args(); return $this->buildLocalFuture($args)->resolvex(); } public function execManualLocal($pattern /* , ... */) { $args = func_get_args(); return $this->buildLocalFuture($args)->resolve(); } public function execFutureLocal($pattern /* , ... */) { $args = func_get_args(); return $this->buildLocalFuture($args); } abstract protected function buildLocalFuture(array $argv); public function canStashChanges() { return false; } public function stashChanges() { throw new ArcanistCapabilityNotSupportedException($this); } public function unstashChanges() { throw new ArcanistCapabilityNotSupportedException($this); } /* -( Scratch Files )------------------------------------------------------ */ /** * Try to read a scratch file, if it exists and is readable. * * @param string Scratch file name. * @return mixed String for file contents, or false for failure. * @task scratch */ public function readScratchFile($path) { $full_path = $this->getScratchFilePath($path); if (!$full_path) { return false; } if (!Filesystem::pathExists($full_path)) { return false; } try { $result = Filesystem::readFile($full_path); } catch (FilesystemException $ex) { return false; } return $result; } /** * Try to write a scratch file, if there's somewhere to put it and we can * write there. * * @param string Scratch file name to write. * @param string Data to write. * @return bool True on success, false on failure. * @task scratch */ public function writeScratchFile($path, $data) { $dir = $this->getScratchFilePath(''); if (!$dir) { return false; } if (!Filesystem::pathExists($dir)) { try { Filesystem::createDirectory($dir); } catch (Exception $ex) { return false; } } try { Filesystem::writeFile($this->getScratchFilePath($path), $data); } catch (FilesystemException $ex) { return false; } return true; } /** * Try to remove a scratch file. * * @param string Scratch file name to remove. * @return bool True if the file was removed successfully. * @task scratch */ public function removeScratchFile($path) { $full_path = $this->getScratchFilePath($path); if (!$full_path) { return false; } try { Filesystem::remove($full_path); } catch (FilesystemException $ex) { return false; } return true; } /** * Get a human-readable description of the scratch file location. * * @param string Scratch file name. * @return mixed String, or false on failure. * @task scratch */ public function getReadableScratchFilePath($path) { $full_path = $this->getScratchFilePath($path); if ($full_path) { return Filesystem::readablePath( $full_path, $this->getPath()); } else { return false; } } /** * Get the path to a scratch file, if possible. * * @param string Scratch file name. * @return mixed File path, or false on failure. * @task scratch */ public function getScratchFilePath($path) { $new_scratch_path = Filesystem::resolvePath( 'arc', $this->getMetadataPath()); static $checked = false; if (!$checked) { $checked = true; $old_scratch_path = $this->getPath('.arc'); // we only want to do the migration once // unfortunately, people have checked in .arc directories which // means that the old one may get recreated after we delete it if (Filesystem::pathExists($old_scratch_path) && !Filesystem::pathExists($new_scratch_path)) { Filesystem::createDirectory($new_scratch_path); $existing_files = Filesystem::listDirectory($old_scratch_path, true); foreach ($existing_files as $file) { $new_path = Filesystem::resolvePath($file, $new_scratch_path); $old_path = Filesystem::resolvePath($file, $old_scratch_path); Filesystem::writeFile( $new_path, Filesystem::readFile($old_path)); } Filesystem::remove($old_scratch_path); } } return Filesystem::resolvePath($path, $new_scratch_path); } /* -( Base Commits )------------------------------------------------------- */ abstract public function supportsCommitRanges(); final public function setBaseCommit($symbolic_commit) { if (!$this->supportsCommitRanges()) { throw new ArcanistCapabilityNotSupportedException($this); } $this->symbolicBaseCommit = $symbolic_commit; $this->reloadCommitRange(); return $this; } public function setHeadCommit($symbolic_commit) { throw new ArcanistCapabilityNotSupportedException($this); } final public function getBaseCommit() { if (!$this->supportsCommitRanges()) { throw new ArcanistCapabilityNotSupportedException($this); } if ($this->resolvedBaseCommit === null) { $commit = $this->buildBaseCommit($this->symbolicBaseCommit); $this->resolvedBaseCommit = $commit; } return $this->resolvedBaseCommit; } public function getHeadCommit() { throw new ArcanistCapabilityNotSupportedException($this); } final public function reloadCommitRange() { $this->resolvedBaseCommit = null; $this->baseCommitExplanation = null; $this->didReloadCommitRange(); return $this; } protected function didReloadCommitRange() { return; } protected function buildBaseCommit($symbolic_commit) { throw new ArcanistCapabilityNotSupportedException($this); } public function getBaseCommitExplanation() { return $this->baseCommitExplanation; } public function setBaseCommitExplanation($explanation) { $this->baseCommitExplanation = $explanation; return $this; } public function resolveBaseCommitRule($rule, $source) { return null; } public function setBaseCommitArgumentRules($base_commit_argument_rules) { $this->baseCommitArgumentRules = $base_commit_argument_rules; return $this; } public function getBaseCommitArgumentRules() { return $this->baseCommitArgumentRules; } public function resolveBaseCommit() { $base_commit_rules = array( 'runtime' => $this->getBaseCommitArgumentRules(), 'local' => '', 'project' => '', 'user' => '', 'system' => '', ); $all_sources = $this->configurationManager->getConfigFromAllSources('base'); $base_commit_rules = $all_sources + $base_commit_rules; $parser = new ArcanistBaseCommitParser($this); $commit = $parser->resolveBaseCommit($base_commit_rules); return $commit; } public function getRepositoryUUID() { return null; } final public function newFuture($pattern /* , ... */) { $args = func_get_args(); return $this->buildLocalFuture($args) ->setResolveOnError(false); } public function newPassthru($pattern /* , ... */) { throw new PhutilMethodNotImplementedException(); } final public function execPassthru($pattern /* , ... */) { $args = func_get_args(); $future = call_user_func_array( array($this, 'newPassthru'), $args); return $future->resolve(); } final public function setRuntime(ArcanistRuntime $runtime) { $this->runtime = $runtime; return $this; } final public function getRuntime() { return $this->runtime; } final protected function getSymbolEngine() { return $this->getRuntime()->getSymbolEngine(); } final public function getCurrentWorkingCopyStateRef() { if ($this->currentWorkingCopyStateRef === false) { $ref = $this->newCurrentWorkingCopyStateRef(); $this->currentWorkingCopyStateRef = $ref; } return $this->currentWorkingCopyStateRef; } protected function newCurrentWorkingCopyStateRef() { $commit_ref = $this->getCurrentCommitRef(); if (!$commit_ref) { return null; } return id(new ArcanistWorkingCopyStateRef()) ->setCommitRef($commit_ref); } final public function getCurrentCommitRef() { if ($this->currentCommitRef === false) { $this->currentCommitRef = $this->newCurrentCommitRef(); } return $this->currentCommitRef; } protected function newCurrentCommitRef() { $symbols = $this->getSymbolEngine(); $commit_symbol = $this->newCurrentCommitSymbol(); return $symbols->loadCommitForSymbol($commit_symbol); } protected function newCurrentCommitSymbol() { throw new ArcanistCapabilityNotSupportedException($this); } final public function newCommitRef() { return new ArcanistCommitRef(); } final public function newMarkerRef() { return new ArcanistMarkerRef(); } final public function getLandEngine() { $engine = $this->newLandEngine(); if ($engine) { $engine->setRepositoryAPI($this); } return $engine; } protected function newLandEngine() { return null; } final public function getWorkEngine() { $engine = $this->newWorkEngine(); if ($engine) { $engine->setRepositoryAPI($this); } return $engine; } protected function newWorkEngine() { return null; } final public function getSupportedMarkerTypes() { return $this->newSupportedMarkerTypes(); } protected function newSupportedMarkerTypes() { return array(); } final public function newMarkerRefQuery() { return id($this->newMarkerRefQueryTemplate()) ->setRepositoryAPI($this); } protected function newMarkerRefQueryTemplate() { throw new PhutilMethodNotImplementedException(); } final public function newRemoteRefQuery() { return id($this->newRemoteRefQueryTemplate()) ->setRepositoryAPI($this); } protected function newRemoteRefQueryTemplate() { throw new PhutilMethodNotImplementedException(); } final public function getDisplayHash($hash) { return substr($hash, 0, 12); } final public function getNormalizedURI($uri) { $normalized_uri = $this->newNormalizedURI($uri); return $normalized_uri->getNormalizedURI(); } protected function newNormalizedURI($uri) { return $uri; } + final public function getPublishedCommitHashes() { + return $this->newPublishedCommitHashes(); + } + + protected function newPublishedCommitHashes() { + return array(); + } + } diff --git a/src/repository/marker/ArcanistGitRepositoryMarkerQuery.php b/src/repository/marker/ArcanistGitRepositoryMarkerQuery.php index a1d747c6..2c634b66 100644 --- a/src/repository/marker/ArcanistGitRepositoryMarkerQuery.php +++ b/src/repository/marker/ArcanistGitRepositoryMarkerQuery.php @@ -1,180 +1,195 @@ getRepositoryAPI(); $future = $this->newCurrentBranchNameFuture()->start(); $field_list = array( '%(refname)', '%(objectname)', '%(committerdate:raw)', '%(tree)', '%(*objectname)', '%(subject)', '%(subject)%0a%0a%(body)', '%02', ); $expect_count = count($field_list); $branch_prefix = 'refs/heads/'; $branch_length = strlen($branch_prefix); - // NOTE: Since we only return branches today, we restrict this operation - // to branches. + $remote_prefix = 'refs/remotes/'; + $remote_length = strlen($remote_prefix); list($stdout) = $api->newFuture( - 'for-each-ref --format %s -- refs/heads/', + 'for-each-ref --format %s -- refs/', implode('%01', $field_list))->resolve(); $markers = array(); $lines = explode("\2", $stdout); foreach ($lines as $line) { $line = trim($line); if (!strlen($line)) { continue; } $fields = explode("\1", $line, $expect_count); $actual_count = count($fields); if ($actual_count !== $expect_count) { throw new Exception( pht( 'Unexpected field count when parsing line "%s", got %s but '. 'expected %s.', $line, new PhutilNumber($actual_count), new PhutilNumber($expect_count))); } list($ref, $hash, $epoch, $tree, $dst_hash, $summary, $text) = $fields; + $remote_name = null; + if (!strncmp($ref, $branch_prefix, $branch_length)) { $type = ArcanistMarkerRef::TYPE_BRANCH; $name = substr($ref, $branch_length); + } else if (!strncmp($ref, $remote_prefix, $remote_length)) { + // This isn't entirely correct: the ref may be a tag, etc. + $type = ArcanistMarkerRef::TYPE_BRANCH; + + $label = substr($ref, $remote_length); + $parts = explode('/', $label, 2); + + $remote_name = $parts[0]; + $name = $parts[1]; } else { // For now, discard other refs. continue; } $marker = id(new ArcanistMarkerRef()) ->setName($name) ->setMarkerType($type) ->setEpoch((int)$epoch) ->setMarkerHash($hash) ->setTreeHash($tree) ->setSummary($summary) ->setMessage($text); + if ($remote_name !== null) { + $marker->setRemoteName($remote_name); + } + if (strlen($dst_hash)) { $commit_hash = $dst_hash; } else { $commit_hash = $hash; } $marker->setCommitHash($commit_hash); $commit_ref = $api->newCommitRef() ->setCommitHash($commit_hash) ->attachMessage($text); $marker->attachCommitRef($commit_ref); $markers[] = $marker; } $current = $this->resolveCurrentBranchNameFuture($future); if ($current !== null) { foreach ($markers as $marker) { if ($marker->getName() === $current) { $marker->setIsActive(true); } } } return $markers; } private function newCurrentBranchNameFuture() { $api = $this->getRepositoryAPI(); return $api->newFuture('symbolic-ref --quiet HEAD --') ->setResolveOnError(true); } private function resolveCurrentBranchNameFuture($future) { list($err, $stdout) = $future->resolve(); if ($err) { return null; } $matches = null; if (!preg_match('(^refs/heads/(.*)\z)', trim($stdout), $matches)) { return null; } return $matches[1]; } protected function newRemoteRefMarkers(ArcanistRemoteRef $remote) { $api = $this->getRepositoryAPI(); // NOTE: Since we only care about branches today, we only list branches. $future = $api->newFuture( 'ls-remote --refs %s %s', $remote->getRemoteName(), 'refs/heads/*'); list($stdout) = $future->resolve(); $branch_prefix = 'refs/heads/'; $branch_length = strlen($branch_prefix); $pattern = '(^(?P\S+)\t(?P\S+)\z)'; $markers = array(); $lines = phutil_split_lines($stdout, false); foreach ($lines as $line) { $matches = null; $ok = preg_match($pattern, $line, $matches); if (!$ok) { throw new Exception( pht( 'Failed to match "ls-remote" pattern against line "%s".', $line)); } $hash = $matches['hash']; $ref = $matches['ref']; if (!strncmp($ref, $branch_prefix, $branch_length)) { $type = ArcanistMarkerRef::TYPE_BRANCH; $name = substr($ref, $branch_length); } else { // For now, discard other refs. continue; } $marker = id(new ArcanistMarkerRef()) ->setName($name) ->setMarkerType($type) ->setMarkerHash($hash) ->setCommitHash($hash); $commit_ref = $api->newCommitRef() ->setCommitHash($hash); $marker->attachCommitRef($commit_ref); $markers[] = $marker; } return $markers; } } diff --git a/src/repository/marker/ArcanistMarkerRef.php b/src/repository/marker/ArcanistMarkerRef.php index a945d572..c580ab56 100644 --- a/src/repository/marker/ArcanistMarkerRef.php +++ b/src/repository/marker/ArcanistMarkerRef.php @@ -1,176 +1,186 @@ getMarkerType()) { case self::TYPE_BRANCH: return pht('Branch "%s"', $this->getName()); case self::TYPE_BOOKMARK: return pht('Bookmark "%s"', $this->getName()); default: return pht('Marker "%s"', $this->getName()); } } protected function newHardpoints() { return array( $this->newHardpoint(self::HARDPOINT_COMMITREF), $this->newHardpoint(self::HARDPOINT_WORKINGCOPYSTATEREF), $this->newHardpoint(self::HARDPOINT_REMOTEREF), ); } public function setName($name) { $this->name = $name; return $this; } public function getName() { return $this->name; } public function setMarkerType($marker_type) { $this->markerType = $marker_type; return $this; } public function getMarkerType() { return $this->markerType; } public function setEpoch($epoch) { $this->epoch = $epoch; return $this; } public function getEpoch() { return $this->epoch; } public function setMarkerHash($marker_hash) { $this->markerHash = $marker_hash; return $this; } public function getMarkerHash() { return $this->markerHash; } public function setDisplayHash($display_hash) { $this->displayHash = $display_hash; return $this; } public function getDisplayHash() { return $this->displayHash; } public function setCommitHash($commit_hash) { $this->commitHash = $commit_hash; return $this; } public function getCommitHash() { return $this->commitHash; } public function setTreeHash($tree_hash) { $this->treeHash = $tree_hash; return $this; } public function getTreeHash() { return $this->treeHash; } public function setSummary($summary) { $this->summary = $summary; return $this; } public function getSummary() { return $this->summary; } public function setMessage($message) { $this->message = $message; return $this; } public function getMessage() { return $this->message; } public function setIsActive($is_active) { $this->isActive = $is_active; return $this; } public function getIsActive() { return $this->isActive; } + public function setRemoteName($remote_name) { + $this->remoteName = $remote_name; + return $this; + } + + public function getRemoteName() { + return $this->remoteName; + } + public function isBookmark() { return ($this->getMarkerType() === self::TYPE_BOOKMARK); } public function isBranch() { return ($this->getMarkerType() === self::TYPE_BRANCH); } public function attachCommitRef(ArcanistCommitRef $ref) { return $this->attachHardpoint(self::HARDPOINT_COMMITREF, $ref); } public function getCommitRef() { return $this->getHardpoint(self::HARDPOINT_COMMITREF); } public function attachWorkingCopyStateRef(ArcanistWorkingCopyStateRef $ref) { return $this->attachHardpoint(self::HARDPOINT_WORKINGCOPYSTATEREF, $ref); } public function getWorkingCopyStateRef() { return $this->getHardpoint(self::HARDPOINT_WORKINGCOPYSTATEREF); } public function attachRemoteRef(ArcanistRemoteRef $ref = null) { return $this->attachHardpoint(self::HARDPOINT_REMOTEREF, $ref); } public function getRemoteRef() { return $this->getHardpoint(self::HARDPOINT_REMOTEREF); } protected function buildRefView(ArcanistRefView $view) { $title = pht( '%s %s', $this->getDisplayHash(), $this->getSummary()); $view ->setObjectName($this->getRefDisplayName()) ->setTitle($title); } } diff --git a/src/repository/marker/ArcanistRepositoryMarkerQuery.php b/src/repository/marker/ArcanistRepositoryMarkerQuery.php index b39c232b..02e12d64 100644 --- a/src/repository/marker/ArcanistRepositoryMarkerQuery.php +++ b/src/repository/marker/ArcanistRepositoryMarkerQuery.php @@ -1,120 +1,136 @@ markerTypes = array_fuse($types); return $this; } final public function withNames(array $names) { $this->names = array_fuse($names); return $this; } final public function withRemotes(array $remotes) { assert_instances_of($remotes, 'ArcanistRemoteRef'); $this->remotes = $remotes; return $this; } + final public function withIsRemoteCache($is_cache) { + $this->isRemoteCache = $is_cache; + return $this; + } + final public function withIsActive($active) { $this->isActive = $active; return $this; } final public function execute() { $remotes = $this->remotes; if ($remotes !== null) { $marker_lists = array(); foreach ($remotes as $remote) { $marker_list = $this->newRemoteRefMarkers($remote); foreach ($marker_list as $marker) { $marker->attachRemoteRef($remote); } $marker_lists[] = $marker_list; } $markers = array_mergev($marker_lists); } else { $markers = $this->newLocalRefMarkers(); foreach ($markers as $marker) { $marker->attachRemoteRef(null); } } $api = $this->getRepositoryAPI(); foreach ($markers as $marker) { $state_ref = id(new ArcanistWorkingCopyStateRef()) ->setCommitRef($marker->getCommitRef()); $marker->attachWorkingCopyStateRef($state_ref); $hash = $marker->getCommitHash(); $hash = $api->getDisplayHash($hash); $marker->setDisplayHash($hash); } $types = $this->markerTypes; if ($types !== null) { foreach ($markers as $key => $marker) { if (!isset($types[$marker->getMarkerType()])) { unset($markers[$key]); } } } $names = $this->names; if ($names !== null) { foreach ($markers as $key => $marker) { if (!isset($names[$marker->getName()])) { unset($markers[$key]); } } } if ($this->isActive !== null) { foreach ($markers as $key => $marker) { if ($marker->getIsActive() !== $this->isActive) { unset($markers[$key]); } } } + if ($this->isRemoteCache !== null) { + $want_cache = $this->isRemoteCache; + foreach ($markers as $key => $marker) { + $is_cache = ($marker->getRemoteName() !== null); + if ($is_cache !== $want_cache) { + unset($markers[$key]); + } + } + } + return $this->sortMarkers($markers); } private function sortMarkers(array $markers) { // Sort the list in natural order. If we apply a stable sort later, // markers will sort in "feature1", "feature2", etc., order if they // don't otherwise have a unique position. // This can improve behavior if two branches were updated at the same // time, as is common when cascading rebases after changes land. $map = mpull($markers, 'getName'); natcasesort($map); $markers = array_select_keys($markers, array_keys($map)); return $markers; } final protected function shouldQueryMarkerType($marker_type) { if ($this->markerTypes === null) { return true; } return isset($this->markerTypes[$marker_type]); } abstract protected function newLocalRefMarkers(); abstract protected function newRemoteRefMarkers(ArcanistRemoteRef $remote); } diff --git a/src/repository/remote/ArcanistRemoteRef.php b/src/repository/remote/ArcanistRemoteRef.php index aea02147..7a34e1bd 100644 --- a/src/repository/remote/ArcanistRemoteRef.php +++ b/src/repository/remote/ArcanistRemoteRef.php @@ -1,92 +1,101 @@ getRemoteName()); } public function setRepositoryAPI(ArcanistRepositoryAPI $repository_api) { $this->repositoryAPI = $repository_api; return $this; } public function getRepositoryAPI() { return $this->repositoryAPI; } public function setRemoteName($remote_name) { $this->remoteName = $remote_name; return $this; } public function getRemoteName() { return $this->remoteName; } public function setFetchURI($fetch_uri) { $this->fetchURI = $fetch_uri; return $this; } public function getFetchURI() { return $this->fetchURI; } public function setPushURI($push_uri) { $this->pushURI = $push_uri; return $this; } public function getPushURI() { return $this->pushURI; } protected function buildRefView(ArcanistRefView $view) { $view->setObjectName($this->getRemoteName()); } protected function newHardpoints() { $object_list = new ArcanistObjectListHardpoint(); return array( $this->newTemplateHardpoint(self::HARDPOINT_REPOSITORYREFS, $object_list), ); } private function getRepositoryRefs() { return $this->getHardpoint(self::HARDPOINT_REPOSITORYREFS); } public function getPushRepositoryRef() { return $this->getRepositoryRefByURI($this->getPushURI()); } public function getFetchRepositoryRef() { return $this->getRepositoryRefByURI($this->getFetchURI()); } private function getRepositoryRefByURI($uri) { $api = $this->getRepositoryAPI(); $uri = $api->getNormalizedURI($uri); foreach ($this->getRepositoryRefs() as $repository_ref) { foreach ($repository_ref->getURIs() as $repository_uri) { $repository_uri = $api->getNormalizedURI($repository_uri); if ($repository_uri === $uri) { return $repository_ref; } } } return null; } + public function isPermanentRef(ArcanistMarkerRef $ref) { + $repository_ref = $this->getPushRepositoryRef(); + if (!$repository_ref) { + return false; + } + + return $repository_ref->isPermanentRef($ref); + } + } diff --git a/src/workflow/ArcanistLookWorkflow.php b/src/workflow/ArcanistLookWorkflow.php index d4f25022..e54fffe9 100644 --- a/src/workflow/ArcanistLookWorkflow.php +++ b/src/workflow/ArcanistLookWorkflow.php @@ -1,200 +1,246 @@ newWorkflowInformation() ->setSynopsis( pht('You stand in the middle of a small clearing.')) ->addExample('**look**') ->addExample('**look** [options] -- __thing__') ->setHelp($help); } public function getWorkflowArguments() { return array( $this->newWorkflowArgument('argv') ->setWildcard(true), ); } public function runWorkflow() { echo tsprintf( "%!\n\n", pht( 'Arcventure')); $argv = $this->getArgument('argv'); if ($argv) { if ($argv === array('remotes')) { return $this->lookRemotes(); } + if ($argv === array('published')) { + return $this->lookPublished(); + } + echo tsprintf( "%s\n", pht( 'You do not see "%s" anywhere.', implode(' ', $argv))); return 1; } echo tsprintf( "%W\n\n", pht( 'You stand in the middle of a small clearing in the woods.')); $now = time(); $hour = (int)date('h', $now); if ($hour >= 5 && $hour <= 7) { $time = pht( 'It is early morning. Glimses of sunlight peek through the trees '. 'and you hear the faint sound of birds overhead.'); } else if ($hour >= 8 && $hour <= 10) { $time = pht( 'It is morning. The sun is high in the sky to the east and you hear '. 'birds all around you. A gentle breeze rustles the leaves overhead.'); } else if ($hour >= 11 && $hour <= 13) { $time = pht( 'It is midday. The sun is high overhead and the air is still. It is '. 'very warm. You hear the cry of a hawk high overhead and far in the '. 'distance.'); } else if ($hour >= 14 && $hour <= 16) { $time = pht( 'It is afternoon. The air has changed and it feels as though it '. 'may rain. You hear a squirrel chittering high overhead.'); } else if ($hour >= 17 && $hour <= 19) { $time = pht( 'It is nearly dusk. The wind has picked up and the trees around you '. 'sway and rustle.'); } else if ($hour >= 21 && $hour <= 23) { $time = pht( 'It is late in the evening. The air is cool and still, and filled '. 'with the sound of crickets.'); } else { $phase = new PhutilLunarPhase($now); if ($phase->isNew()) { $time = pht( 'Night has fallen, and the thin sliver of moon overhead offers '. 'no comfort. It is almost pitch black. The night is bitter '. 'cold. It will be difficult to look around in these conditions.'); } else if ($phase->isFull()) { $time = pht( 'Night has fallen, but your surroundings are illuminated by the '. 'silvery glow of a full moon overhead. The night is cool and '. 'the air is crisp. The trees are calm.'); } else if ($phase->isWaxing()) { $time = pht( 'Night has fallen. The moon overhead is waxing, and provides '. 'just enough light that you can make out your surroundings. It '. 'is quite cold.'); } else if ($phase->isWaning()) { $time = pht( 'Night has fallen. The moon overhead is waning. You can barely '. 'make out your surroundings. It is very cold.'); } } echo tsprintf( "%W\n\n", $time); echo tsprintf( "%W\n\n", pht( 'Several small trails and footpaths cross here, twisting away from '. 'you among the trees.')); echo tsprintf( pht("Just ahead to the north, you can see **remotes**.\n")); return 0; } private function lookRemotes() { echo tsprintf( "%W\n\n", pht( 'You follow a wide, straight path to the north and arrive in a '. 'grove of fruit trees after a few minutes of walking. The grass '. 'underfoot is thick and small insects flit through the air.')); echo tsprintf( "%W\n\n", pht( 'At the far edge of the grove, you see remotes:')); $api = $this->getRepositoryAPI(); $remotes = $api->newRemoteRefQuery() ->execute(); $this->loadHardpoints( $remotes, ArcanistRemoteRef::HARDPOINT_REPOSITORYREFS); foreach ($remotes as $remote) { $view = $remote->newRefView(); $push_uri = $remote->getPushURI(); if ($push_uri === null) { $push_uri = '-'; } $view->appendLine( pht( 'Push URI: %s', $push_uri)); $push_repository = $remote->getPushRepositoryRef(); if ($push_repository) { $push_display = $push_repository->getDisplayName(); } else { $push_display = '-'; } $view->appendLine( pht( 'Push Repository: %s', $push_display)); $fetch_uri = $remote->getFetchURI(); if ($fetch_uri === null) { $fetch_uri = '-'; } $view->appendLine( pht( 'Fetch URI: %s', $fetch_uri)); $fetch_repository = $remote->getFetchRepositoryRef(); if ($fetch_repository) { $fetch_display = $fetch_repository->getDisplayName(); } else { $fetch_display = '-'; } $view->appendLine( pht( 'Fetch Repository: %s', $fetch_display)); echo tsprintf('%s', $view); } + + echo tsprintf("\n"); + echo tsprintf( + pht( + "Across the grove, a stream flows north toward ". + "**published** commits.\n")); + } + + private function lookPublished() { + echo tsprintf( + "%W\n\n", + pht( + 'You walk along the narrow bank of the stream as it winds lazily '. + 'downhill and turns east, gradually widening into a river.')); + + $api = $this->getRepositoryAPI(); + + $published = $api->getPublishedCommitHashes(); + + if ($published) { + echo tsprintf( + "%W\n\n", + pht( + 'Floating on the water, you see published commits:')); + + foreach ($published as $hash) { + echo tsprintf( + "%s\n", + $hash); + } + + echo tsprintf( + "\n%W\n", + pht( + 'They river bubbles peacefully.')); + } else { + echo tsprintf( + "%W\n", + pht( + 'The river bubbles quietly, but you do not see any published '. + 'commits anywhere.')); + } } }