diff --git a/src/repository/api/ArcanistSubversionAPI.php b/src/repository/api/ArcanistSubversionAPI.php index 00366688..9b529c08 100644 --- a/src/repository/api/ArcanistSubversionAPI.php +++ b/src/repository/api/ArcanistSubversionAPI.php @@ -1,695 +1,701 @@ getPath()) as $parent) { $possible_svn_dir = Filesystem::resolvePath('.svn', $parent); if (Filesystem::pathExists($possible_svn_dir)) { $svn_dir = $possible_svn_dir; break; } } } return $svn_dir; } protected function buildLocalFuture(array $argv) { $argv[0] = 'svn '.$argv[0]; $future = newv('ExecFuture', $argv); $future->setCWD($this->getPath()); return $future; } protected function buildCommitRangeStatus() { // In SVN, the commit range is always "uncommitted changes", so these // statuses are equivalent. return $this->getUncommittedStatus(); } protected function buildUncommittedStatus() { return $this->getSVNStatus(); } public function getSVNBaseRevisions() { if ($this->svnBaseRevisions === null) { $this->getSVNStatus(); } return $this->svnBaseRevisions; } public function limitStatusToPaths(array $paths) { $this->statusPaths = $paths; return $this; } public function getSVNStatus($with_externals = false) { if ($this->svnStatus === null) { if ($this->statusPaths) { list($status) = $this->execxLocal( '--xml status %Ls', $this->statusPaths); } else { list($status) = $this->execxLocal('--xml status'); } $xml = new SimpleXMLElement($status); $externals = array(); $files = array(); foreach ($xml->target as $target) { $this->svnBaseRevisions = array(); foreach ($target->entry as $entry) { $path = (string)$entry['path']; // On Windows, we get paths with backslash directory separators here. // Normalize them to the format everything else expects and generates. if (phutil_is_windows()) { $path = str_replace(DIRECTORY_SEPARATOR, '/', $path); } $mask = 0; $props = (string)($entry->{'wc-status'}[0]['props']); $item = (string)($entry->{'wc-status'}[0]['item']); $base = (string)($entry->{'wc-status'}[0]['revision']); $this->svnBaseRevisions[$path] = $base; switch ($props) { case 'none': case 'normal': break; case 'modified': $mask |= self::FLAG_MODIFIED; break; default: throw new Exception("Unrecognized property status '{$props}'."); } $mask |= $this->parseSVNStatus($item); if ($item == 'external') { $externals[] = $path; } // This is new in or around Subversion 1.6. $tree_conflicts = ($entry->{'wc-status'}[0]['tree-conflicted']); if ((string)$tree_conflicts) { $mask |= self::FLAG_CONFLICT; } $files[$path] = $mask; } } foreach ($files as $path => $mask) { foreach ($externals as $external) { if (!strncmp($path.'/', $external.'/', strlen($external) + 1)) { $files[$path] |= self::FLAG_EXTERNALS; } } } $this->svnStatus = $files; } $status = $this->svnStatus; if (!$with_externals) { foreach ($status as $path => $mask) { if ($mask & ArcanistRepositoryAPI::FLAG_EXTERNALS) { unset($status[$path]); } } } return $status; } private function parseSVNStatus($item) { switch ($item) { case 'none': // We can get 'none' for property changes on a directory. case 'normal': return 0; case 'external': return self::FLAG_EXTERNALS; case 'unversioned': return self::FLAG_UNTRACKED; case 'obstructed': return self::FLAG_OBSTRUCTED; case 'missing': return self::FLAG_MISSING; case 'added': return self::FLAG_ADDED; case 'replaced': // This is the result of "svn rm"-ing a file, putting another one // in place of it, and then "svn add"-ing the new file. Just treat // this as equivalent to "modified". return self::FLAG_MODIFIED; case 'modified': return self::FLAG_MODIFIED; case 'deleted': return self::FLAG_DELETED; case 'conflicted': return self::FLAG_CONFLICT; case 'incomplete': return self::FLAG_INCOMPLETE; default: throw new Exception("Unrecognized item status '{$item}'."); } } public function addToCommit(array $paths) { $add = array_filter($paths, 'Filesystem::pathExists'); if ($add) { $this->execxLocal( 'add -- %Ls', $add); } if ($add != $paths) { $this->execxLocal( 'delete -- %Ls', array_diff($paths, $add)); } $this->svnStatus = null; } public function getSVNProperty($path, $property) { list($stdout) = execx( 'svn propget %s %s@', $property, $this->getPath($path)); return trim($stdout); } public function getSourceControlPath() { return idx($this->getSVNInfo('/'), 'URL'); } public function getSourceControlBaseRevision() { $info = $this->getSVNInfo('/'); return $info['URL'].'@'.$this->getSVNBaseRevisionNumber(); } public function getCanonicalRevisionName($string) { - throw new ArcanistCapabilityNotSupportedException($this); + // TODO: This could be more accurate, but is only used by `arc browse` + // for now. + + if (is_numeric($string)) { + return $string; + } + return null; } public function getSVNBaseRevisionNumber() { if ($this->svnBaseRevisionNumber) { return $this->svnBaseRevisionNumber; } $info = $this->getSVNInfo('/'); return $info['Revision']; } public function overrideSVNBaseRevisionNumber($effective_base_revision) { $this->svnBaseRevisionNumber = $effective_base_revision; return $this; } public function getBranchName() { $info = $this->getSVNInfo('/'); $repo_root = idx($info, 'Repository Root'); $repo_root_length = strlen($repo_root); $url = idx($info, 'URL'); if (substr($url, 0, $repo_root_length) == $repo_root) { return substr($url, $repo_root_length); } return 'svn'; } public function getRemoteURI() { return idx($this->getSVNInfo('/'), 'Repository Root'); } public function buildInfoFuture($path) { if ($path == '/') { // When the root of a working copy is referenced by a symlink and you // execute 'svn info' on that symlink, svn fails. This is a longstanding // bug in svn: // // See http://subversion.tigris.org/issues/show_bug.cgi?id=2305 // // To reproduce, do: // // $ ln -s working_copy working_link // $ svn info working_copy # ok // $ svn info working_link # fails // // Work around this by cd-ing into the directory before executing // 'svn info'. return $this->buildLocalFuture(array('info .')); } else { // Note: here and elsewhere we need to append "@" to the path because if // a file has a literal "@" in it, everything after that will be // interpreted as a revision. By appending "@" with no argument, SVN // parses it properly. return $this->buildLocalFuture(array('info %s@', $this->getPath($path))); } } public function buildDiffFuture($path) { $root = phutil_get_library_root('arcanist'); // The "--depth empty" flag prevents us from picking up changes in // children when we run 'diff' against a directory. Specifically, when a // user has added or modified some directory "example/", we want to return // ONLY changes to that directory when given it as a path. If we run // without "--depth empty", svn will give us changes to the directory // itself (such as property changes) and also give us changes to any // files within the directory (basically, implicit recursion). We don't // want that, so prevent recursive diffing. This flag does not work if the // directory is newly added (see T5555) so we need to filter the results // out later as well. if (phutil_is_windows()) { // TODO: Provide a binary_safe_diff script for Windows. // TODO: Provide a diff command which can take lines of context somehow. return $this->buildLocalFuture( array( 'diff --depth empty %s', $path, )); } else { $diff_bin = $root.'/../scripts/repository/binary_safe_diff.sh'; $diff_cmd = Filesystem::resolvePath($diff_bin); return $this->buildLocalFuture( array( 'diff --depth empty --diff-cmd %s -x -U%d %s', $diff_cmd, $this->getDiffLinesOfContext(), $path, )); } } public function primeSVNInfoResult($path, $result) { $this->svnInfoRaw[$path] = $result; return $this; } public function primeSVNDiffResult($path, $result) { $this->svnDiffRaw[$path] = $result; return $this; } public function getSVNInfo($path) { if (empty($this->svnInfo[$path])) { if (empty($this->svnInfoRaw[$path])) { $this->svnInfoRaw[$path] = $this->buildInfoFuture($path)->resolve(); } list($err, $stdout) = $this->svnInfoRaw[$path]; if ($err) { throw new Exception( "Error #{$err} executing svn info against '{$path}'."); } // TODO: Hack for Windows. $stdout = str_replace("\r\n", "\n", $stdout); $patterns = array( '/^(URL): (\S+)$/m', '/^(Revision): (\d+)$/m', '/^(Last Changed Author): (\S+)$/m', '/^(Last Changed Rev): (\d+)$/m', '/^(Last Changed Date): (.+) \(.+\)$/m', '/^(Copied From URL): (\S+)$/m', '/^(Copied From Rev): (\d+)$/m', '/^(Repository Root): (\S+)$/m', '/^(Repository UUID): (\S+)$/m', '/^(Node Kind): (\S+)$/m', ); $result = array(); foreach ($patterns as $pattern) { $matches = null; if (preg_match($pattern, $stdout, $matches)) { $result[$matches[1]] = $matches[2]; } } if (isset($result['Last Changed Date'])) { $result['Last Changed Date'] = strtotime($result['Last Changed Date']); } if (empty($result)) { throw new Exception('Unable to parse SVN info.'); } $this->svnInfo[$path] = $result; } return $this->svnInfo[$path]; } public function getRawDiffText($path) { $status = $this->getSVNStatus(); if (!isset($status[$path])) { return null; } $status = $status[$path]; // Build meaningful diff text for "svn copy" operations. if ($status & ArcanistRepositoryAPI::FLAG_ADDED) { $info = $this->getSVNInfo($path); if (!empty($info['Copied From URL'])) { return $this->buildSyntheticAdditionDiff( $path, $info['Copied From URL'], $info['Copied From Rev']); } } // If we run "diff" on a binary file which doesn't have the "svn:mime-type" // of "application/octet-stream", `diff' will explode in a rain of // unhelpful hellfire as it tries to build a textual diff of the two // files. We just fix this inline since it's pretty unambiguous. // TODO: Move this to configuration? $matches = null; if (preg_match('/\.(gif|png|jpe?g|swf|pdf|ico)$/i', $path, $matches)) { // Check if the file is deleted first; SVN will complain if we try to // get properties of a deleted file. if ($status & ArcanistRepositoryAPI::FLAG_DELETED) { return <<getSVNProperty($path, 'svn:mime-type'); if ($mime != 'application/octet-stream') { execx( 'svn propset svn:mime-type application/octet-stream %s', self::escapeFileNameForSVN($this->getPath($path))); } } if (empty($this->svnDiffRaw[$path])) { $this->svnDiffRaw[$path] = $this->buildDiffFuture($path)->resolve(); } list($err, $stdout, $stderr) = $this->svnDiffRaw[$path]; // Note: GNU Diff returns 2 when SVN hands it binary files to diff and they // differ. This is not an error; it is documented behavior. But SVN isn't // happy about it. SVN will exit with code 1 and return the string below. if ($err != 0 && $stderr !== "svn: 'diff' returned 2\n") { throw new Exception( "svn diff returned unexpected error code: $err\n". "stdout: $stdout\n". "stderr: $stderr"); } if ($err == 0 && empty($stdout)) { // If there are no changes, 'diff' exits with no output, but that means // we can not distinguish between empty and unmodified files. Build a // synthetic "diff" without any changes in it. return $this->buildSyntheticUnchangedDiff($path); } return $stdout; } protected function buildSyntheticAdditionDiff($path, $source, $rev) { $type = $this->getSVNProperty($path, 'svn:mime-type'); if ($type == 'application/octet-stream') { return <<getPath($path))) { return null; } $data = Filesystem::readFile($this->getPath($path)); list($orig) = execx('svn cat %s@%s', $source, $rev); $src = new TempFile(); $dst = new TempFile(); Filesystem::writeFile($src, $orig); Filesystem::writeFile($dst, $data); list($err, $diff) = exec_manual( 'diff -L a/%s -L b/%s -U%d %s %s', str_replace($this->getSourceControlPath().'/', '', $source), $path, $this->getDiffLinesOfContext(), $src, $dst); if ($err == 1) { // 1 means there are differences. return <<buildSyntheticUnchangedDiff($path); } } protected function buildSyntheticUnchangedDiff($path) { $full_path = $this->getPath($path); if (is_dir($full_path)) { return null; } if (!file_exists($full_path)) { return null; } $data = Filesystem::readFile($full_path); $lines = explode("\n", $data); $len = count($lines); foreach ($lines as $key => $line) { $lines[$key] = ' '.$line; } $lines = implode("\n", $lines); return <<buildLocalFuture(array('list -R')); return new PhutilCallbackFilterIterator( new LinesOfALargeExecFuture($future), array($this, 'filterFiles')); } public function getChangedFiles($since_commit) { $url = ''; $match = null; if (preg_match('/(.*)@(.*)/', $since_commit, $match)) { list(, $url, $since_commit) = $match; } // TODO: Handle paths with newlines. list($stdout) = $this->execxLocal( '--xml diff --revision %s:HEAD --summarize %s', $since_commit, $url); $xml = new SimpleXMLElement($stdout); $return = array(); foreach ($xml->paths[0]->path as $path) { $return[(string)$path] = $this->parseSVNStatus($path['item']); } return $return; } public function filterFiles($path) { // NOTE: SVN uses '/' also on Windows. if ($path == '' || substr($path, -1) == '/') { return null; } return $path; } public function getBlame($path) { $blame = array(); list($stdout) = $this->execxLocal('blame %s', $path); $stdout = trim($stdout); if (!strlen($stdout)) { // Empty file. return $blame; } foreach (explode("\n", $stdout) as $line) { $m = array(); if (!preg_match('/^\s*(\d+)\s+(\S+)/', $line, $m)) { throw new Exception("Bad blame? `{$line}'"); } $revision = $m[1]; $author = $m[2]; $blame[] = array($author, $revision); } return $blame; } public function getOriginalFileData($path) { // SVN issues warnings for nonexistent paths, directories, etc., but still // returns no error code. However, for new paths in the working copy it // fails. Assume that failure means the original file does not exist. list($err, $stdout) = $this->execManualLocal('cat %s@', $path); if ($err) { return null; } return $stdout; } public function getCurrentFileData($path) { $full_path = $this->getPath($path); if (Filesystem::pathExists($full_path)) { return Filesystem::readFile($full_path); } return null; } public function getRepositoryUUID() { $info = $this->getSVNInfo('/'); return $info['Repository UUID']; } public function getLocalCommitInformation() { return null; } public function isHistoryDefaultImmutable() { return true; } public function supportsAmend() { return false; } public function supportsCommitRanges() { return false; } public function supportsLocalCommits() { return false; } public function hasLocalCommit($commit) { return false; } public function getWorkingCopyRevision() { return $this->getSourceControlBaseRevision(); } public function supportsLocalBranchMerge() { return false; } public function getFinalizedRevisionMessage() { // In other VCSes we give push instructions here, but it never makes sense // in SVN. return 'Done.'; } public function loadWorkingCopyDifferentialRevisions( ConduitClient $conduit, array $query) { // We don't have much to go on in SVN, look for revisions that came from // this directory and belong to the same project. $project = $this->getWorkingCopyIdentity()->getProjectID(); if (!$project) { return array(); } $results = $conduit->callMethodSynchronous( 'differential.query', $query + array( 'arcanistProjects' => array($project), )); foreach ($results as $key => $result) { if ($result['sourcePath'] != $this->getPath()) { unset($results[$key]); } } foreach ($results as $key => $result) { $results[$key]['why'] = 'Matching arcanist project name and working copy directory path.'; } return $results; } public function updateWorkingCopy() { $this->execxLocal('up'); } public static function escapeFileNamesForSVN(array $files) { foreach ($files as $k => $file) { $files[$k] = self::escapeFileNameForSVN($file); } return $files; } public static function escapeFileNameForSVN($file) { // SVN interprets "x@1" as meaning "file x at revision 1", which is not // intended for files named "sprite@2x.png" or similar. For files with an // "@" in their names, escape them by adding "@" at the end, which SVN // interprets as "at the working copy revision". There is a special case // where ".@" means "fail with an error" instead of ". at the working copy // revision", so avoid escaping "." into ".@". if (strpos($file, '@') !== false) { $file = $file.'@'; } return $file; } } diff --git a/src/workflow/ArcanistBrowseWorkflow.php b/src/workflow/ArcanistBrowseWorkflow.php index 6d7e65fb..3de2cb52 100644 --- a/src/workflow/ArcanistBrowseWorkflow.php +++ b/src/workflow/ArcanistBrowseWorkflow.php @@ -1,168 +1,202 @@ array( 'param' => 'branch_name', 'help' => pht( 'Default branch name to view on server. Defaults to "master".'), ), 'force' => array( 'help' => pht( 'Open arguments as paths, even if they do not exist in the '. 'working copy.'), ), '*' => 'paths', ); } public function desiresWorkingCopy() { return true; } public function requiresConduit() { return true; } public function requiresAuthentication() { return true; } public function desiresRepositoryAPI() { return true; } public function run() { $console = PhutilConsole::getConsole(); $is_force = $this->getArgument('force'); $things = $this->getArgument('paths'); if (!$things) { throw new ArcanistUsageException( pht( 'Specify one or more paths or objects to browse. Use the command '. '"arc browse ." if you want to browse this directory.')); } $things = array_fuse($things); $objects = $this->getConduit()->callMethodSynchronous( 'phid.lookup', array( 'names' => array_keys($things), )); $uris = array(); foreach ($objects as $name => $object) { $uris[] = $object['uri']; $console->writeOut( pht( 'Opening **%s** as an object.', $name)."\n"); unset($things[$name]); } if ($this->hasRepositoryAPI()) { $repository_api = $this->getRepositoryAPI(); $project_root = $this->getWorkingCopy()->getProjectRoot(); + // First, try to resolve arguments as symbolic commits. + + $commits = array(); + foreach ($things as $key => $thing) { + $commit = $repository_api->getCanonicalRevisionName($thing); + if ($commit) { + $commits[$commit] = $key; + } + } + + if ($commits) { + $commit_info = $this->getConduit()->callMethodSynchronous( + 'diffusion.querycommits', + array( + 'repositoryPHID' => $this->getRepositoryPHID(), + 'names' => array_keys($commits), + )); + + foreach ($commit_info['identifierMap'] as $ckey => $cphid) { + $thing = $commits[$ckey]; + unset($things[$thing]); + + $uris[] = $commit_info['data'][$cphid]['uri']; + + $console->writeOut( + pht( + 'Opening **%s** as a commit.', + $thing)."\n"); + } + } + + // If we fail, try to resolve them as paths. + foreach ($things as $key => $path) { $path = preg_replace('/:([0-9]+)$/', '$\1', $path); $full_path = Filesystem::resolvePath($path); if (!$is_force && !Filesystem::pathExists($full_path)) { continue; } $console->writeOut( pht( 'Opening **%s** as a repository path.', $key)."\n"); unset($things[$key]); if ($full_path == $project_root) { $path = ''; } else { $path = Filesystem::readablePath($full_path, $project_root); } $base_uri = $this->getBaseURI(); $uris[] = $base_uri.$path; } } else { if ($things) { $console->writeOut( pht( "The current working directory is not a repository working ". - "copy, so remaining arguments can not be resolved as paths. ". - "To browse paths in Diffusion, run 'arc browse' from inside ". - "a working copy.")."\n"); + "copy, so remaining arguments can not be resolved as paths or ". + "commits. To browse paths or symbolic commits in Diffusion, run ". + "'arc browse' from inside a working copy.")."\n"); } } foreach ($things as $thing) { $console->writeOut( pht( - 'Unable to find an object named **%s**, and no such path exists '. - 'in the working copy. Use __--force__ to treat this as a path '. - 'anyway.', + 'Unable to find an object named **%s**, no such commit exists in '. + 'the remote, and no such path exists in the working copy. Use '. + '__--force__ to treat this as a path anyway.', $thing)."\n"); } if ($uris) { $this->openURIsInBrowser($uris); } return 0; } private function getBaseURI() { $repo_uri = $this->getRepositoryURI(); if ($repo_uri === null) { throw new ArcanistUsageException( pht( 'arc is unable to determine which repository in Diffusion '. 'this working copy belongs to. Use "arc which" to understand how '. 'arc looks for a repository.')); } $branch = $this->getArgument('branch', 'master'); return $repo_uri.'browse/'.$branch.'/'; } }