diff --git a/src/repository/api/ArcanistSubversionAPI.php b/src/repository/api/ArcanistSubversionAPI.php index e9536111..707d2d93 100644 --- a/src/repository/api/ArcanistSubversionAPI.php +++ b/src/repository/api/ArcanistSubversionAPI.php @@ -1,705 +1,703 @@ 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(pht( "Unrecognized property status '%s'.", $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(pht("Unrecognized item status '%s'.", $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) { // 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( pht("Error #%d executing svn info against '%s'.", $err, $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(pht('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( pht( "%s returned unexpected error code: %d\nstdout: %s\nstderr: %s", 'svn diff', $err, $stdout, $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(pht("Bad blame? `%s'", $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), - )); + $query); foreach ($results as $key => $result) { if ($result['sourcePath'] != $this->getPath()) { unset($results[$key]); } } foreach ($results as $key => $result) { $results[$key]['why'] = pht( '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/ArcanistDiffWorkflow.php b/src/workflow/ArcanistDiffWorkflow.php index 34754966..56c2a281 100644 --- a/src/workflow/ArcanistDiffWorkflow.php +++ b/src/workflow/ArcanistDiffWorkflow.php @@ -1,2555 +1,2554 @@ null, 'unit' => null); private $testResults; private $diffID; private $revisionID; private $postponedLinters; private $haveUncommittedChanges = false; private $diffPropertyFutures = array(); private $commitMessageFromRevision; public function getWorkflowName() { return 'diff'; } public function getCommandSynopses() { return phutil_console_format(<<isRawDiffSource(); } public function requiresConduit() { return true; } public function requiresAuthentication() { return true; } public function requiresRepositoryAPI() { if (!$this->isRawDiffSource()) { return true; } if ($this->getArgument('use-commit-message')) { return true; } return false; } public function getDiffID() { return $this->diffID; } public function getArguments() { $arguments = array( 'message' => array( 'short' => 'm', 'param' => 'message', 'help' => 'When updating a revision, use the specified message instead of '. 'prompting.', ), 'message-file' => array( 'short' => 'F', 'param' => 'file', 'paramtype' => 'file', 'help' => 'When creating a revision, read revision information '. 'from this file.', ), 'use-commit-message' => array( 'supports' => array( 'git', // TODO: Support mercurial. ), 'short' => 'C', 'param' => 'commit', 'help' => 'Read revision information from a specific commit.', 'conflicts' => array( 'only' => null, 'preview' => null, 'update' => null, ), ), 'edit' => array( 'supports' => array( 'git', 'hg', ), 'nosupport' => array( 'svn' => 'Edit revisions via the web interface when using SVN.', ), 'help' => 'When updating a revision under git, edit revision information '. 'before updating.', ), 'raw' => array( 'help' => 'Read diff from stdin, not from the working copy. This disables '. 'many Arcanist/Phabricator features which depend on having access '. 'to the working copy.', 'conflicts' => array( 'less-context' => null, 'apply-patches' => '--raw disables lint.', 'never-apply-patches' => '--raw disables lint.', 'advice' => '--raw disables lint.', 'lintall' => '--raw disables lint.', 'create' => '--raw and --create both need stdin. '. 'Use --raw-command.', 'edit' => '--raw and --edit both need stdin. '. 'Use --raw-command.', 'raw-command' => null, ), ), 'raw-command' => array( 'param' => 'command', 'help' => 'Generate diff by executing a specified command, not from the '. 'working copy. This disables many Arcanist/Phabricator features '. 'which depend on having access to the working copy.', 'conflicts' => array( 'less-context' => null, 'apply-patches' => '--raw-command disables lint.', 'never-apply-patches' => '--raw-command disables lint.', 'advice' => '--raw-command disables lint.', 'lintall' => '--raw-command disables lint.', ), ), 'create' => array( 'help' => 'Always create a new revision.', 'conflicts' => array( 'edit' => '--create can not be used with --edit.', 'only' => '--create can not be used with --only.', 'preview' => '--create can not be used with --preview.', 'update' => '--create can not be used with --update.', ), ), 'update' => array( 'param' => 'revision_id', 'help' => 'Always update a specific revision.', ), 'nounit' => array( 'help' => 'Do not run unit tests.', ), 'nolint' => array( 'help' => 'Do not run lint.', 'conflicts' => array( 'lintall' => '--nolint suppresses lint.', 'advice' => '--nolint suppresses lint.', 'apply-patches' => '--nolint suppresses lint.', 'never-apply-patches' => '--nolint suppresses lint.', ), ), 'only' => array( 'help' => 'Only generate a diff, without running lint, unit tests, or other '. 'auxiliary steps. See also --preview.', 'conflicts' => array( 'preview' => null, 'message' => '--only does not affect revisions.', 'edit' => '--only does not affect revisions.', 'lintall' => '--only suppresses lint.', 'advice' => '--only suppresses lint.', 'apply-patches' => '--only suppresses lint.', 'never-apply-patches' => '--only suppresses lint.', ), ), 'preview' => array( 'help' => 'Instead of creating or updating a revision, only create a diff, '. 'which you may later attach to a revision. This still runs lint '. 'unit tests. See also --only.', 'conflicts' => array( 'only' => null, 'edit' => '--preview does affect revisions.', 'message' => '--preview does not update any revision.', ), ), 'plan-changes' => array( 'help' => 'Create or update a revision without requesting a code review.', 'conflicts' => array( 'only' => '--only does not affect revisions.', 'preview' => '--preview does not affect revisions.', ), ), 'encoding' => array( 'param' => 'encoding', 'help' => 'Attempt to convert non UTF-8 hunks into specified encoding.', ), 'allow-untracked' => array( 'help' => 'Skip checks for untracked files in the working copy.', ), 'excuse' => array( 'param' => 'excuse', 'help' => 'Provide a prepared in advance excuse for any lints/tests'. ' shall they fail.', ), 'less-context' => array( 'help' => "Normally, files are diffed with full context: the entire file is ". "sent to Differential so reviewers can 'show more' and see it. If ". "you are making changes to very large files with tens of thousands ". "of lines, this may not work well. With this flag, a diff will ". "be created that has only a few lines of context.", ), 'lintall' => array( 'help' => 'Raise all lint warnings, not just those on lines you changed.', 'passthru' => array( 'lint' => true, ), ), 'advice' => array( 'help' => 'Require excuse for lint advice in addition to lint warnings and '. 'errors.', ), 'only-new' => array( 'param' => 'bool', 'help' => 'Display only lint messages not present in the original code.', 'passthru' => array( 'lint' => true, ), ), 'apply-patches' => array( 'help' => 'Apply patches suggested by lint to the working copy without '. 'prompting.', 'conflicts' => array( 'never-apply-patches' => true, ), 'passthru' => array( 'lint' => true, ), ), 'never-apply-patches' => array( 'help' => 'Never apply patches suggested by lint.', 'conflicts' => array( 'apply-patches' => true, ), 'passthru' => array( 'lint' => true, ), ), 'amend-all' => array( 'help' => 'When linting git repositories, amend HEAD with all patches '. 'suggested by lint without prompting.', 'passthru' => array( 'lint' => true, ), ), 'amend-autofixes' => array( 'help' => 'When linting git repositories, amend HEAD with autofix '. 'patches suggested by lint without prompting.', 'passthru' => array( 'lint' => true, ), ), 'add-all' => array( 'short' => 'a', 'help' => 'Automatically add all unstaged and uncommitted files to the commit.', ), 'json' => array( 'help' => 'Emit machine-readable JSON. EXPERIMENTAL! Probably does not work!', ), 'no-amend' => array( 'help' => 'Never amend commits in the working copy with lint patches.', ), 'uncommitted' => array( 'help' => 'Suppress warning about uncommitted changes.', 'supports' => array( 'hg', ), ), 'verbatim' => array( 'help' => 'When creating a revision, try to use the working copy '. 'commit message verbatim, without prompting to edit it. '. 'When updating a revision, update some fields from the '. 'local commit message.', 'supports' => array( 'hg', 'git', ), 'conflicts' => array( 'use-commit-message' => true, 'update' => true, 'only' => true, 'preview' => true, 'raw' => true, 'raw-command' => true, 'message-file' => true, ), ), 'reviewers' => array( 'param' => 'usernames', 'help' => 'When creating a revision, add reviewers.', 'conflicts' => array( 'only' => true, 'preview' => true, 'update' => true, ), ), 'cc' => array( 'param' => 'usernames', 'help' => 'When creating a revision, add CCs.', 'conflicts' => array( 'only' => true, 'preview' => true, 'update' => true, ), ), 'skip-binaries' => array( 'help' => 'Do not upload binaries (like images).', ), 'ignore-unsound-tests' => array( 'help' => 'Ignore unsound test failures without prompting.', ), 'base' => array( 'param' => 'rules', 'help' => 'Additional rules for determining base revision.', 'nosupport' => array( 'svn' => 'Subversion does not use base commits.', ), 'supports' => array('git', 'hg'), ), 'no-diff' => array( 'help' => 'Only run lint and unit tests. Intended for internal use.', ), 'cache' => array( 'param' => 'bool', 'help' => '0 to disable lint cache, 1 to enable (default).', 'passthru' => array( 'lint' => true, ), ), 'coverage' => array( 'help' => 'Always enable coverage information.', 'conflicts' => array( 'no-coverage' => null, ), 'passthru' => array( 'unit' => true, ), ), 'no-coverage' => array( 'help' => 'Always disable coverage information.', 'passthru' => array( 'unit' => true, ), ), 'browse' => array( 'help' => pht( 'After creating a diff or revision, open it in a web browser.'), ), '*' => 'paths', 'head' => array( 'param' => 'commit', 'help' => pht( 'Specify the end of the commit range. This disables many '. 'Arcanist/Phabricator features which depend on having access to '. 'the working copy.'), 'supports' => array('git'), 'nosupport' => array( 'svn' => pht('Subversion does not support commit ranges.'), 'hg' => pht('Mercurial does not support --head yet.'), ), 'conflicts' => array( 'lintall' => '--head suppresses lint.', 'advice' => '--head suppresses lint.', ), ), ); return $arguments; } public function isRawDiffSource() { return $this->getArgument('raw') || $this->getArgument('raw-command'); } public function run() { $this->console = PhutilConsole::getConsole(); $this->runRepositoryAPISetup(); if ($this->getArgument('no-diff')) { $this->removeScratchFile('diff-result.json'); $data = $this->runLintUnit(); $this->writeScratchJSONFile('diff-result.json', $data); return 0; } $this->runDiffSetupBasics(); $commit_message = $this->buildCommitMessage(); $this->dispatchEvent( ArcanistEventType::TYPE_DIFF_DIDBUILDMESSAGE, array( 'message' => $commit_message, )); if (!$this->shouldOnlyCreateDiff()) { $revision = $this->buildRevisionFromCommitMessage($commit_message); } $server = $this->console->getServer(); $server->setHandler(array($this, 'handleServerMessage')); $data = $this->runLintUnit(); $lint_result = $data['lintResult']; $this->unresolvedLint = $data['unresolvedLint']; $this->postponedLinters = $data['postponedLinters']; $unit_result = $data['unitResult']; $this->testResults = $data['testResults']; if ($this->getArgument('nolint')) { $this->excuses['lint'] = $this->getSkipExcuse( 'Provide explanation for skipping lint or press Enter to abort:', 'lint-excuses'); } if ($this->getArgument('nounit')) { $this->excuses['unit'] = $this->getSkipExcuse( 'Provide explanation for skipping unit tests or press Enter to abort:', 'unit-excuses'); } $changes = $this->generateChanges(); if (!$changes) { throw new ArcanistUsageException( 'There are no changes to generate a diff from!'); } $diff_spec = array( 'changes' => mpull($changes, 'toDictionary'), 'lintStatus' => $this->getLintStatus($lint_result), 'unitStatus' => $this->getUnitStatus($unit_result), ) + $this->buildDiffSpecification(); $conduit = $this->getConduit(); $diff_info = $conduit->callMethodSynchronous( 'differential.creatediff', $diff_spec); $this->diffID = $diff_info['diffid']; $event = $this->dispatchEvent( ArcanistEventType::TYPE_DIFF_WASCREATED, array( 'diffID' => $diff_info['diffid'], 'lintResult' => $lint_result, 'unitResult' => $unit_result, )); $this->updateLintDiffProperty(); $this->updateUnitDiffProperty(); $this->updateLocalDiffProperty(); $this->resolveDiffPropertyUpdates(); $output_json = $this->getArgument('json'); if ($this->shouldOnlyCreateDiff()) { if (!$output_json) { echo phutil_console_format( "Created a new Differential diff:\n". " **Diff URI:** __%s__\n\n", $diff_info['uri']); } else { $human = ob_get_clean(); echo json_encode(array( 'diffURI' => $diff_info['uri'], 'diffID' => $this->getDiffID(), 'human' => $human, ))."\n"; ob_start(); } if ($this->shouldOpenCreatedObjectsInBrowser()) { $this->openURIsInBrowser(array($diff_info['uri'])); } } else { $revision['diffid'] = $this->getDiffID(); if ($commit_message->getRevisionID()) { $result = $conduit->callMethodSynchronous( 'differential.updaterevision', $revision); foreach (array('edit-messages.json', 'update-messages.json') as $file) { $messages = $this->readScratchJSONFile($file); unset($messages[$revision['id']]); $this->writeScratchJSONFile($file, $messages); } echo "Updated an existing Differential revision:\n"; } else { $revision = $this->dispatchWillCreateRevisionEvent($revision); $result = $conduit->callMethodSynchronous( 'differential.createrevision', $revision); $revised_message = $conduit->callMethodSynchronous( 'differential.getcommitmessage', array( 'revision_id' => $result['revisionid'], )); if ($this->shouldAmend()) { $repository_api = $this->getRepositoryAPI(); if ($repository_api->supportsAmend()) { echo "Updating commit message...\n"; $repository_api->amendCommit($revised_message); } else { echo 'Commit message was not amended. Amending commit message is '. 'only supported in git and hg (version 2.2 or newer)'; } } echo "Created a new Differential revision:\n"; } $uri = $result['uri']; echo phutil_console_format( " **Revision URI:** __%s__\n\n", $uri); if ($this->getArgument('plan-changes')) { $conduit->callMethodSynchronous( 'differential.createcomment', array( 'revision_id' => $result['revisionid'], 'action' => 'rethink', )); echo "Planned changes to the revision.\n"; } if ($this->shouldOpenCreatedObjectsInBrowser()) { $this->openURIsInBrowser(array($uri)); } } echo "Included changes:\n"; foreach ($changes as $change) { echo ' '.$change->renderTextSummary()."\n"; } if ($output_json) { ob_get_clean(); } $this->removeScratchFile('create-message'); return 0; } private function runRepositoryAPISetup() { if (!$this->requiresRepositoryAPI()) { return; } $repository_api = $this->getRepositoryAPI(); if ($this->getArgument('less-context')) { $repository_api->setDiffLinesOfContext(3); } $repository_api->setBaseCommitArgumentRules( $this->getArgument('base', '')); if ($repository_api->supportsCommitRanges()) { $this->parseBaseCommitArgument($this->getArgument('paths')); } $head_commit = $this->getArgument('head'); if ($head_commit !== null) { $repository_api->setHeadCommit($head_commit); } } private function runDiffSetupBasics() { $output_json = $this->getArgument('json'); if ($output_json) { // TODO: We should move this to a higher-level and put an indirection // layer between echoing stuff and stdout. ob_start(); } if ($this->requiresWorkingCopy()) { $repository_api = $this->getRepositoryAPI(); if ($this->getArgument('add-all')) { $this->setCommitMode(self::COMMIT_ENABLE); } else if ($this->getArgument('uncommitted')) { $this->setCommitMode(self::COMMIT_DISABLE); } else { $this->setCommitMode(self::COMMIT_ALLOW); } if ($repository_api instanceof ArcanistSubversionAPI) { $repository_api->limitStatusToPaths($this->getArgument('paths')); } if (!$this->getArgument('head')) { $this->requireCleanWorkingCopy(); } } $this->dispatchEvent( ArcanistEventType::TYPE_DIFF_DIDCOLLECTCHANGES, array()); } private function buildRevisionFromCommitMessage( ArcanistDifferentialCommitMessage $message) { $conduit = $this->getConduit(); $revision_id = $message->getRevisionID(); $revision = array( 'fields' => $message->getFields(), ); if ($revision_id) { // With '--verbatim', pass the (possibly modified) local fields. This // allows the user to edit some fields (like "title" and "summary") // locally without '--edit' and have changes automatically synchronized. // Without '--verbatim', we do not update the revision to reflect local // commit message changes. if ($this->getArgument('verbatim')) { $use_fields = $message->getFields(); } else { $use_fields = array(); } $should_edit = $this->getArgument('edit'); $edit_messages = $this->readScratchJSONFile('edit-messages.json'); $remote_corpus = idx($edit_messages, $revision_id); if (!$should_edit || !$remote_corpus || $use_fields) { if ($this->commitMessageFromRevision) { $remote_corpus = $this->commitMessageFromRevision; } else { $remote_corpus = $conduit->callMethodSynchronous( 'differential.getcommitmessage', array( 'revision_id' => $revision_id, 'edit' => 'edit', 'fields' => $use_fields, )); } } if ($should_edit) { $edited = $this->newInteractiveEditor($remote_corpus) ->setName('differential-edit-revision-info') ->editInteractively(); if ($edited != $remote_corpus) { $remote_corpus = $edited; $edit_messages[$revision_id] = $remote_corpus; $this->writeScratchJSONFile('edit-messages.json', $edit_messages); } } if ($this->commitMessageFromRevision == $remote_corpus) { $new_message = $message; } else { $remote_corpus = ArcanistCommentRemover::removeComments( $remote_corpus); $new_message = ArcanistDifferentialCommitMessage::newFromRawCorpus( $remote_corpus); $new_message->pullDataFromConduit($conduit); } $revision['fields'] = $new_message->getFields(); $revision['id'] = $revision_id; $this->revisionID = $revision_id; $revision['message'] = $this->getArgument('message'); if (!strlen($revision['message'])) { $update_messages = $this->readScratchJSONFile('update-messages.json'); $update_messages[$revision_id] = $this->getUpdateMessage( $revision['fields'], idx($update_messages, $revision_id)); $revision['message'] = ArcanistCommentRemover::removeComments( $update_messages[$revision_id]); if (!strlen(trim($revision['message']))) { throw new ArcanistUserAbortException(); } $this->writeScratchJSONFile('update-messages.json', $update_messages); } } return $revision; } protected function shouldOnlyCreateDiff() { if ($this->getArgument('create')) { return false; } if ($this->getArgument('update')) { return false; } if ($this->getArgument('use-commit-message')) { return false; } if ($this->isRawDiffSource()) { return true; } return $this->getArgument('preview') || $this->getArgument('only'); } private function generateAffectedPaths() { if ($this->isRawDiffSource()) { return array(); } $repository_api = $this->getRepositoryAPI(); if ($repository_api instanceof ArcanistSubversionAPI) { $file_list = new FileList($this->getArgument('paths', array())); $paths = $repository_api->getSVNStatus($externals = true); foreach ($paths as $path => $mask) { if (!$file_list->contains($repository_api->getPath($path), true)) { unset($paths[$path]); } } $warn_externals = array(); foreach ($paths as $path => $mask) { $any_mod = ($mask & ArcanistRepositoryAPI::FLAG_ADDED) || ($mask & ArcanistRepositoryAPI::FLAG_MODIFIED) || ($mask & ArcanistRepositoryAPI::FLAG_DELETED); if ($mask & ArcanistRepositoryAPI::FLAG_EXTERNALS) { unset($paths[$path]); if ($any_mod) { $warn_externals[] = $path; } } } if ($warn_externals && !$this->hasWarnedExternals) { echo phutil_console_format( "The working copy includes changes to 'svn:externals' paths. These ". "changes will not be included in the diff because SVN can not ". "commit 'svn:externals' changes alongside normal changes.". "\n\n". "Modified 'svn:externals' files:". "\n\n". phutil_console_wrap(implode("\n", $warn_externals), 8)); $prompt = 'Generate a diff (with just local changes) anyway?'; if (!phutil_console_confirm($prompt)) { throw new ArcanistUserAbortException(); } else { $this->hasWarnedExternals = true; } } } else { $paths = $repository_api->getWorkingCopyStatus(); } foreach ($paths as $path => $mask) { if ($mask & ArcanistRepositoryAPI::FLAG_UNTRACKED) { unset($paths[$path]); } } return $paths; } protected function generateChanges() { $parser = $this->newDiffParser(); $is_raw = $this->isRawDiffSource(); if ($is_raw) { if ($this->getArgument('raw')) { fwrite(STDERR, "Reading diff from stdin...\n"); $raw_diff = file_get_contents('php://stdin'); } else if ($this->getArgument('raw-command')) { list($raw_diff) = execx('%C', $this->getArgument('raw-command')); } else { throw new Exception('Unknown raw diff source.'); } $changes = $parser->parseDiff($raw_diff); foreach ($changes as $key => $change) { // Remove "message" changes, e.g. from "git show". if ($change->getType() == ArcanistDiffChangeType::TYPE_MESSAGE) { unset($changes[$key]); } } return $changes; } $repository_api = $this->getRepositoryAPI(); if ($repository_api instanceof ArcanistSubversionAPI) { $paths = $this->generateAffectedPaths(); $this->primeSubversionWorkingCopyData($paths); // Check to make sure the user is diffing from a consistent base revision. // This is mostly just an abuse sanity check because it's silly to do this // and makes the code more difficult to effectively review, but it also // affects patches and makes them nonportable. $bases = $repository_api->getSVNBaseRevisions(); // Remove all files with baserev "0"; these files are new. foreach ($bases as $path => $baserev) { if ($bases[$path] <= 0) { unset($bases[$path]); } } if ($bases) { $rev = reset($bases); $revlist = array(); foreach ($bases as $path => $baserev) { $revlist[] = " Revision {$baserev}, {$path}"; } $revlist = implode("\n", $revlist); foreach ($bases as $path => $baserev) { if ($baserev !== $rev) { throw new ArcanistUsageException( "Base revisions of changed paths are mismatched. Update all ". "paths to the same base revision before creating a diff: ". "\n\n". $revlist); } } // If you have a change which affects several files, all of which are // at a consistent base revision, treat that revision as the effective // base revision. The use case here is that you made a change to some // file, which updates it to HEAD, but want to be able to change it // again without updating the entire working copy. This is a little // sketchy but it arises in Facebook Ops workflows with config files and // doesn't have any real material tradeoffs (e.g., these patches are // perfectly applyable). $repository_api->overrideSVNBaseRevisionNumber($rev); } $changes = $parser->parseSubversionDiff( $repository_api, $paths); } else if ($repository_api instanceof ArcanistGitAPI) { $diff = $repository_api->getFullGitDiff( $repository_api->getBaseCommit(), $repository_api->getHeadCommit()); if (!strlen($diff)) { throw new ArcanistUsageException( 'No changes found. (Did you specify the wrong commit range?)'); } $changes = $parser->parseDiff($diff); } else if ($repository_api instanceof ArcanistMercurialAPI) { $diff = $repository_api->getFullMercurialDiff(); if (!strlen($diff)) { throw new ArcanistUsageException( 'No changes found. (Did you specify the wrong commit range?)'); } $changes = $parser->parseDiff($diff); } else { throw new Exception('Repository API is not supported.'); } if (count($changes) > 250) { $count = number_format(count($changes)); $link = 'http://www.phabricator.com/docs/phabricator/article/'. 'Differential_User_Guide_Large_Changes.html'; $message = "This diff has a very large number of changes ({$count}). ". "Differential works best for changes which will receive detailed ". "human review, and not as well for large automated changes or ". "bulk checkins. See {$link} for information about reviewing big ". "checkins. Continue anyway?"; if (!phutil_console_confirm($message)) { throw new ArcanistUsageException( 'Aborted generation of gigantic diff.'); } } $limit = 1024 * 1024 * 4; foreach ($changes as $change) { $size = 0; foreach ($change->getHunks() as $hunk) { $size += strlen($hunk->getCorpus()); } if ($size > $limit) { $file_name = $change->getCurrentPath(); $change_size = number_format($size); $byte_warning = "Diff for '{$file_name}' with context is {$change_size} bytes in ". "length. Generally, source changes should not be this large."; if (!$this->getArgument('less-context')) { $byte_warning .= " If this file is a huge text file, try using the ". "'--less-context' flag."; } if ($repository_api instanceof ArcanistSubversionAPI) { throw new ArcanistUsageException( "{$byte_warning} If the file is not a text file, mark it as ". "binary with:". "\n\n". " $ svn propset svn:mime-type application/octet-stream ". "\n"); } else { $confirm = "{$byte_warning} If the file is not a text file, you can ". "mark it 'binary'. Mark this file as 'binary' and continue?"; if (phutil_console_confirm($confirm)) { $change->convertToBinaryChange($repository_api); } else { throw new ArcanistUsageException( 'Aborted generation of gigantic diff.'); } } } } $try_encoding = nonempty($this->getArgument('encoding'), null); $utf8_problems = array(); foreach ($changes as $change) { foreach ($change->getHunks() as $hunk) { $corpus = $hunk->getCorpus(); if (!phutil_is_utf8($corpus)) { // If this corpus is heuristically binary, don't try to convert it. // mb_check_encoding() and mb_convert_encoding() are both very very // liberal about what they're willing to process. $is_binary = ArcanistDiffUtils::isHeuristicBinaryFile($corpus); if (!$is_binary) { if (!$try_encoding) { try { $try_encoding = $this->getRepositoryEncoding(); } catch (ConduitClientException $e) { if ($e->getErrorCode() == 'ERR-BAD-ARCANIST-PROJECT') { echo phutil_console_wrap( "Lookup of encoding in arcanist project failed\n". $e->getMessage()); } else { throw $e; } } } if ($try_encoding) { $corpus = phutil_utf8_convert($corpus, 'UTF-8', $try_encoding); $name = $change->getCurrentPath(); if (phutil_is_utf8($corpus)) { $this->writeStatusMessage( "Converted a '{$name}' hunk from '{$try_encoding}' ". "to UTF-8.\n"); $hunk->setCorpus($corpus); continue; } } } $utf8_problems[] = $change; break; } } } // If there are non-binary files which aren't valid UTF-8, warn the user // and treat them as binary changes. See D327 for discussion of why Arcanist // has this behavior. if ($utf8_problems) { $utf8_warning = pht( 'This diff includes %s file(s) which are not valid UTF-8 (they '. 'contain invalid byte sequences). You can either stop this workflow '. 'and fix these files, or continue. If you continue, these files '. 'will be marked as binary.', new PhutilNumber(count($utf8_problems))). "\n\n". "You can learn more about how Phabricator handles character encodings ". "(and how to configure encoding settings and detect and correct ". "encoding problems) by reading 'User Guide: UTF-8 and Character ". "Encoding' in the Phabricator documentation.\n\n". " ".pht('%d AFFECTED FILE(S)', count($utf8_problems))."\n"; $confirm = pht( 'Do you want to mark these %s file(s) as binary and continue?', new PhutilNumber(count($utf8_problems))); echo phutil_console_format("**Invalid Content Encoding (Non-UTF8)**\n"); echo phutil_console_wrap($utf8_warning); $file_list = mpull($utf8_problems, 'getCurrentPath'); $file_list = ' '.implode("\n ", $file_list); echo $file_list; if (!phutil_console_confirm($confirm, $default_no = false)) { throw new ArcanistUsageException('Aborted workflow to fix UTF-8.'); } else { foreach ($utf8_problems as $change) { $change->convertToBinaryChange($repository_api); } } } $this->uploadFilesForChanges($changes); return $changes; } private function getGitParentLogInfo() { $info = array( 'parent' => null, 'base_revision' => null, 'base_path' => null, 'uuid' => null, ); $repository_api = $this->getRepositoryAPI(); $parser = $this->newDiffParser(); $history_messages = $repository_api->getGitHistoryLog(); if (!$history_messages) { // This can occur on the initial commit. return $info; } $history_messages = $parser->parseDiff($history_messages); foreach ($history_messages as $key => $change) { try { $message = ArcanistDifferentialCommitMessage::newFromRawCorpus( $change->getMetadata('message')); if ($message->getRevisionID() && $info['parent'] === null) { $info['parent'] = $message->getRevisionID(); } if ($message->getGitSVNBaseRevision() && $info['base_revision'] === null) { $info['base_revision'] = $message->getGitSVNBaseRevision(); $info['base_path'] = $message->getGitSVNBasePath(); } if ($message->getGitSVNUUID()) { $info['uuid'] = $message->getGitSVNUUID(); } if ($info['parent'] && $info['base_revision']) { break; } } catch (ArcanistDifferentialCommitMessageParserException $ex) { // Ignore. } catch (ArcanistUsageException $ex) { // Ignore an invalid Differential Revision field in the parent commit } } return $info; } protected function primeSubversionWorkingCopyData($paths) { $repository_api = $this->getRepositoryAPI(); $futures = array(); $targets = array(); foreach ($paths as $path => $mask) { $futures[] = $repository_api->buildDiffFuture($path); $targets[] = array('command' => 'diff', 'path' => $path); $futures[] = $repository_api->buildInfoFuture($path); $targets[] = array('command' => 'info', 'path' => $path); } $futures = id(new FutureIterator($futures)) ->limit(8); foreach ($futures as $key => $future) { $target = $targets[$key]; if ($target['command'] == 'diff') { $repository_api->primeSVNDiffResult( $target['path'], $future->resolve()); } else { $repository_api->primeSVNInfoResult( $target['path'], $future->resolve()); } } } private function shouldAmend() { if ($this->isRawDiffSource()) { return false; } if ($this->haveUncommittedChanges) { return false; } if ($this->getArgument('no-amend')) { return false; } if ($this->getArgument('head') !== null) { return false; } // Run this last: with --raw or --raw-command, we won't have a repository // API. if ($this->isHistoryImmutable()) { return false; } return true; } /* -( Lint and Unit Tests )------------------------------------------------ */ /** * @task lintunit */ private function runLintUnit() { $lint_result = $this->runLint(); $unit_result = $this->runUnit(); return array( 'lintResult' => $lint_result, 'unresolvedLint' => $this->unresolvedLint, 'postponedLinters' => $this->postponedLinters, 'unitResult' => $unit_result, 'testResults' => $this->testResults, ); } /** * @task lintunit */ private function runLint() { if ($this->getArgument('nolint') || $this->getArgument('only') || $this->isRawDiffSource() || $this->getArgument('head')) { return ArcanistLintWorkflow::RESULT_SKIP; } $repository_api = $this->getRepositoryAPI(); $this->console->writeOut("Linting...\n"); try { $argv = $this->getPassthruArgumentsAsArgv('lint'); if ($repository_api->supportsCommitRanges()) { $argv[] = '--rev'; $argv[] = $repository_api->getBaseCommit(); } $lint_workflow = $this->buildChildWorkflow('lint', $argv); if ($this->shouldAmend()) { // TODO: We should offer to create a checkpoint commit. $lint_workflow->setShouldAmendChanges(true); } $lint_result = $lint_workflow->run(); switch ($lint_result) { case ArcanistLintWorkflow::RESULT_OKAY: if ($this->getArgument('advice') && $lint_workflow->getUnresolvedMessages()) { $this->getErrorExcuse( 'lint', 'Lint issued unresolved advice.', 'lint-excuses'); } else { $this->console->writeOut( "** LINT OKAY ** No lint problems.\n"); } break; case ArcanistLintWorkflow::RESULT_WARNINGS: $this->getErrorExcuse( 'lint', 'Lint issued unresolved warnings.', 'lint-excuses'); break; case ArcanistLintWorkflow::RESULT_ERRORS: $this->console->writeOut( "** LINT ERRORS ** Lint raised errors!\n"); $this->getErrorExcuse( 'lint', 'Lint issued unresolved errors!', 'lint-excuses'); break; case ArcanistLintWorkflow::RESULT_POSTPONED: $this->console->writeOut( "** LINT POSTPONED ** ". "Lint results are postponed.\n"); break; } $this->unresolvedLint = array(); foreach ($lint_workflow->getUnresolvedMessages() as $message) { $this->unresolvedLint[] = $message->toDictionary(); } $this->postponedLinters = $lint_workflow->getPostponedLinters(); return $lint_result; } catch (ArcanistNoEngineException $ex) { $this->console->writeOut("No lint engine configured for this project.\n"); } catch (ArcanistNoEffectException $ex) { $this->console->writeOut($ex->getMessage()."\n"); } return null; } /** * @task lintunit */ private function runUnit() { if ($this->getArgument('nounit') || $this->getArgument('only') || $this->isRawDiffSource() || $this->getArgument('head')) { return ArcanistUnitWorkflow::RESULT_SKIP; } $repository_api = $this->getRepositoryAPI(); $this->console->writeOut("Running unit tests...\n"); try { $argv = $this->getPassthruArgumentsAsArgv('unit'); if ($repository_api->supportsCommitRanges()) { $argv[] = '--rev'; $argv[] = $repository_api->getBaseCommit(); } $unit_workflow = $this->buildChildWorkflow('unit', $argv); $unit_result = $unit_workflow->run(); switch ($unit_result) { case ArcanistUnitWorkflow::RESULT_OKAY: $this->console->writeOut( "** UNIT OKAY ** No unit test failures.\n"); break; case ArcanistUnitWorkflow::RESULT_UNSOUND: if ($this->getArgument('ignore-unsound-tests')) { echo phutil_console_format( "** UNIT UNSOUND ** Unit testing raised errors, ". "but all failing tests are unsound.\n"); } else { $continue = $this->console->confirm( 'Unit test results included failures, but all failing tests '. 'are known to be unsound. Ignore unsound test failures?'); if (!$continue) { throw new ArcanistUserAbortException(); } } break; case ArcanistUnitWorkflow::RESULT_FAIL: $this->console->writeOut( "** UNIT ERRORS ** Unit testing raised errors!\n"); $this->getErrorExcuse( 'unit', 'Unit test results include failures!', 'unit-excuses'); break; } $this->testResults = array(); foreach ($unit_workflow->getTestResults() as $test) { $this->testResults[] = $test->toDictionary(); } return $unit_result; } catch (ArcanistNoEngineException $ex) { $this->console->writeOut( "No unit test engine is configured for this project.\n"); } catch (ArcanistNoEffectException $ex) { $this->console->writeOut($ex->getMessage()."\n"); } return null; } public function getTestResults() { return $this->testResults; } private function getSkipExcuse($prompt, $history) { $excuse = $this->getArgument('excuse'); if ($excuse === null) { $history = $this->getRepositoryAPI()->getScratchFilePath($history); $excuse = phutil_console_prompt($prompt, $history); if ($excuse == '') { throw new ArcanistUserAbortException(); } } return $excuse; } private function getErrorExcuse($type, $prompt, $history) { if ($this->getArgument('excuse')) { $this->console->sendMessage(array( 'type' => $type, 'confirm' => $prompt.' Ignore them?', )); return; } $history = $this->getRepositoryAPI()->getScratchFilePath($history); $prompt .= ' Provide explanation to continue or press Enter to abort.'; $this->console->writeOut("\n\n%s", phutil_console_wrap($prompt)); $this->console->sendMessage(array( 'type' => $type, 'prompt' => 'Explanation:', 'history' => $history, )); } public function handleServerMessage(PhutilConsoleMessage $message) { $data = $message->getData(); if ($this->getArgument('excuse')) { try { phutil_console_require_tty(); } catch (PhutilConsoleStdinNotInteractiveException $ex) { $this->excuses[$data['type']] = $this->getArgument('excuse'); return null; } } $response = ''; if (isset($data['prompt'])) { $response = phutil_console_prompt($data['prompt'], idx($data, 'history')); } else if (phutil_console_confirm($data['confirm'])) { $response = $this->getArgument('excuse'); } if ($response == '') { throw new ArcanistUserAbortException(); } $this->excuses[$data['type']] = $response; return null; } /* -( Commit and Update Messages )----------------------------------------- */ /** * @task message */ private function buildCommitMessage() { if ($this->getArgument('preview') || $this->getArgument('only')) { return null; } $is_create = $this->getArgument('create'); $is_update = $this->getArgument('update'); $is_raw = $this->isRawDiffSource(); $is_message = $this->getArgument('use-commit-message'); $is_verbatim = $this->getArgument('verbatim'); if ($is_message) { return $this->getCommitMessageFromCommit($is_message); } if ($is_verbatim) { return $this->getCommitMessageFromUser(); } if (!$is_raw && !$is_create && !$is_update) { $repository_api = $this->getRepositoryAPI(); $revisions = $repository_api->loadWorkingCopyDifferentialRevisions( $this->getConduit(), array( 'authors' => array($this->getUserPHID()), 'status' => 'status-open', )); if (!$revisions) { $is_create = true; } else if (count($revisions) == 1) { $revision = head($revisions); $is_update = $revision['id']; } else { throw new ArcanistUsageException( "There are several revisions which match the working copy:\n\n". $this->renderRevisionList($revisions)."\n". "Use '--update' to choose one, or '--create' to create a new ". "revision."); } } $message = null; if ($is_create) { $message_file = $this->getArgument('message-file'); if ($message_file) { return $this->getCommitMessageFromFile($message_file); } else { return $this->getCommitMessageFromUser(); } } else if ($is_update) { $revision_id = $this->normalizeRevisionID($is_update); if (!is_numeric($revision_id)) { throw new ArcanistUsageException( 'Parameter to --update must be a Differential Revision number'); } return $this->getCommitMessageFromRevision($revision_id); } else { // This is --raw without enough info to create a revision, so force just // a diff. return null; } } /** * @task message */ private function getCommitMessageFromCommit($commit) { $text = $this->getRepositoryAPI()->getCommitMessage($commit); $message = ArcanistDifferentialCommitMessage::newFromRawCorpus($text); $message->pullDataFromConduit($this->getConduit()); $this->validateCommitMessage($message); return $message; } /** * @task message */ private function getCommitMessageFromUser() { $conduit = $this->getConduit(); $template = null; if (!$this->getArgument('verbatim')) { $saved = $this->readScratchFile('create-message'); if ($saved) { $where = $this->getReadableScratchFilePath('create-message'); $preview = explode("\n", $saved); $preview = array_shift($preview); $preview = trim($preview); $preview = id(new PhutilUTF8StringTruncator()) ->setMaximumGlyphs(64) ->truncateString($preview); if ($preview) { $preview = "Message begins:\n\n {$preview}\n\n"; } else { $preview = null; } echo "You have a saved revision message in '{$where}'.\n". "{$preview}". "You can use this message, or discard it."; $use = phutil_console_confirm( 'Do you want to use this message?', $default_no = false); if ($use) { $template = $saved; } else { $this->removeScratchFile('create-message'); } } } $template_is_default = false; $notes = array(); $included = array(); list($fields, $notes, $included_commits) = $this->getDefaultCreateFields(); if ($template) { $fields = array(); $notes = array(); } else { if (!$fields) { $template_is_default = true; } if ($notes) { $commit = head($this->getRepositoryAPI()->getLocalCommitInformation()); $template = $commit['message']; } else { $template = $conduit->callMethodSynchronous( 'differential.getcommitmessage', array( 'revision_id' => null, 'edit' => 'create', 'fields' => $fields, )); } } $old_message = $template; $included = array(); if ($included_commits) { foreach ($included_commits as $commit) { $included[] = ' '.$commit; } $in_branch = ''; if (!$this->isRawDiffSource()) { $in_branch = ' in branch '.$this->getRepositoryAPI()->getBranchName(); } $included = array_merge( array( '', "Included commits{$in_branch}:", '', ), $included); } $issues = array_merge( array( 'NEW DIFFERENTIAL REVISION', 'Describe the changes in this new revision.', ), $included, array( '', 'arc could not identify any existing revision in your working copy.', 'If you intended to update an existing revision, use:', '', ' $ arc diff --update ', )); if ($notes) { $issues = array_merge($issues, array(''), $notes); } $done = false; $first = true; while (!$done) { $template = rtrim($template, "\r\n")."\n\n"; foreach ($issues as $issue) { $template .= rtrim('# '.$issue)."\n"; } $template .= "\n"; if ($first && $this->getArgument('verbatim') && !$template_is_default) { $new_template = $template; } else { $new_template = $this->newInteractiveEditor($template) ->setName('new-commit') ->editInteractively(); } $first = false; if ($template_is_default && ($new_template == $template)) { throw new ArcanistUsageException('Template not edited.'); } $template = ArcanistCommentRemover::removeComments($new_template); // With --raw-command, we may not have a repository API. if ($this->hasRepositoryAPI()) { $repository_api = $this->getRepositoryAPI(); // special check for whether to amend here. optimizes a common git // workflow. we can't do this for mercurial because the mq extension // is popular and incompatible with hg commit --amend ; see T2011. $should_amend = (count($included_commits) == 1 && $repository_api instanceof ArcanistGitAPI && $this->shouldAmend()); } else { $should_amend = false; } if ($should_amend) { $wrote = (rtrim($old_message) != rtrim($template)); if ($wrote) { $repository_api->amendCommit($template); $where = 'commit message'; } } else { $wrote = $this->writeScratchFile('create-message', $template); $where = "'".$this->getReadableScratchFilePath('create-message')."'"; } try { $message = ArcanistDifferentialCommitMessage::newFromRawCorpus( $template); $message->pullDataFromConduit($conduit); $this->validateCommitMessage($message); $done = true; } catch (ArcanistDifferentialCommitMessageParserException $ex) { echo "Commit message has errors:\n\n"; $issues = array('Resolve these errors:'); foreach ($ex->getParserErrors() as $error) { echo phutil_console_wrap("- ".$error."\n", 6); $issues[] = ' - '.$error; } echo "\n"; echo 'You must resolve these errors to continue.'; $again = phutil_console_confirm( 'Do you want to edit the message?', $default_no = false); if ($again) { // Keep going. } else { $saved = null; if ($wrote) { $saved = "A copy was saved to {$where}."; } throw new ArcanistUsageException( "Message has unresolved errrors. {$saved}"); } } catch (Exception $ex) { if ($wrote) { echo phutil_console_wrap("(Message saved to {$where}.)\n"); } throw $ex; } } return $message; } /** * @task message */ private function getCommitMessageFromFile($file) { $conduit = $this->getConduit(); $data = Filesystem::readFile($file); $message = ArcanistDifferentialCommitMessage::newFromRawCorpus($data); $message->pullDataFromConduit($conduit); $this->validateCommitMessage($message); return $message; } /** * @task message */ private function getCommitMessageFromRevision($revision_id) { $id = $revision_id; $revision = $this->getConduit()->callMethodSynchronous( 'differential.query', array( 'ids' => array($id), )); $revision = head($revision); if (!$revision) { throw new ArcanistUsageException( "Revision '{$revision_id}' does not exist!"); } $this->checkRevisionOwnership($revision); $message = $this->getConduit()->callMethodSynchronous( 'differential.getcommitmessage', array( 'revision_id' => $id, 'edit' => false, )); $this->commitMessageFromRevision = $message; $obj = ArcanistDifferentialCommitMessage::newFromRawCorpus($message); $obj->pullDataFromConduit($this->getConduit()); return $obj; } /** * @task message */ private function validateCommitMessage( ArcanistDifferentialCommitMessage $message) { $futures = array(); $revision_id = $message->getRevisionID(); if ($revision_id) { $futures['revision'] = $this->getConduit()->callMethod( 'differential.query', array( 'ids' => array($revision_id), )); } $reviewers = $message->getFieldValue('reviewerPHIDs'); if (!$reviewers) { $confirm = 'You have not specified any reviewers. Continue anyway?'; if (!phutil_console_confirm($confirm)) { throw new ArcanistUsageException('Specify reviewers and retry.'); } } else { $futures['reviewers'] = $this->getConduit()->callMethod( 'user.query', array( 'phids' => $reviewers, )); } foreach (new FutureIterator($futures) as $key => $future) { $result = $future->resolve(); switch ($key) { case 'revision': if (empty($result)) { throw new ArcanistUsageException( "There is no revision D{$revision_id}."); } $this->checkRevisionOwnership(head($result)); break; case 'reviewers': $untils = array(); foreach ($result as $user) { if (idx($user, 'currentStatus') == 'away') { $untils[] = $user['currentStatusUntil']; } } if (count($untils) == count($reviewers)) { $until = date('l, M j Y', min($untils)); $confirm = "All reviewers are away until {$until}. ". "Continue anyway?"; if (!phutil_console_confirm($confirm)) { throw new ArcanistUsageException( 'Specify available reviewers and retry.'); } } break; } } } /** * @task message */ private function getUpdateMessage(array $fields, $template = '') { if ($this->getArgument('raw')) { throw new ArcanistUsageException( "When using '--raw' to update a revision, specify an update message ". "with '--message'. (Normally, we'd launch an editor to ask you for a ". "message, but can not do that because stdin is the diff source.)"); } // When updating a revision using git without specifying '--message', try // to prefill with the message in HEAD if it isn't a template message. The // idea is that if you do: // // $ git commit -a -m 'fix some junk' // $ arc diff // // ...you shouldn't have to retype the update message. Similar things apply // to Mercurial. if ($template == '') { $comments = $this->getDefaultUpdateMessage(); $template = rtrim($comments). "\n\n". "# Updating D{$fields['revisionID']}: {$fields['title']}\n". "#\n". "# Enter a brief description of the changes included in this update.\n". "# The first line is used as subject, next lines as comment.\n". "#\n". "# If you intended to create a new revision, use:\n". "# $ arc diff --create\n". "\n"; } $comments = $this->newInteractiveEditor($template) ->setName('differential-update-comments') ->editInteractively(); return $comments; } private function getDefaultCreateFields() { $result = array(array(), array(), array()); if ($this->isRawDiffSource()) { return $result; } $repository_api = $this->getRepositoryAPI(); $local = $repository_api->getLocalCommitInformation(); if ($local) { $result = $this->parseCommitMessagesIntoFields($local); if ($this->getArgument('create')) { unset($result[0]['revisionID']); } } $result[0] = $this->dispatchWillBuildEvent($result[0]); return $result; } /** * Convert a list of commits from `getLocalCommitInformation()` into * a format usable by arc to create a new diff. Specifically, we emit: * * - A dictionary of commit message fields. * - A list of errors encountered while parsing the messages. * - A human-readable list of the commits themselves. * * For example, if the user runs "arc diff HEAD^^^" and selects a diff range * which includes several diffs, we attempt to merge them somewhat * intelligently into a single message, because we can only send one * "Summary:", "Reviewers:", etc., field to Differential. We also return * errors (e.g., if the user typed a reviewer name incorrectly) and a * summary of the commits themselves. * * @param dict Local commit information. * @return list Complex output, see summary. * @task message */ private function parseCommitMessagesIntoFields(array $local) { $conduit = $this->getConduit(); $local = ipull($local, null, 'commit'); // If the user provided "--reviewers" or "--ccs", add a faux message to // the list with the implied fields. $faux_message = array(); if ($this->getArgument('reviewers')) { $faux_message[] = 'Reviewers: '.$this->getArgument('reviewers'); } if ($this->getArgument('cc')) { $faux_message[] = 'CC: '.$this->getArgument('cc'); } if ($faux_message) { $faux_message = implode("\n\n", $faux_message); $local = array( '(Flags) ' => array( 'message' => $faux_message, 'summary' => 'Command-Line Flags', ), ) + $local; } // Build a human-readable list of the commits, so we can show the user which // commits are included in the diff. $included = array(); foreach ($local as $hash => $info) { $included[] = substr($hash, 0, 12).' '.$info['summary']; } // Parse all of the messages into fields. $messages = array(); foreach ($local as $hash => $info) { $text = $info['message']; $obj = ArcanistDifferentialCommitMessage::newFromRawCorpus($text); $messages[$hash] = $obj; } $notes = array(); $fields = array(); foreach ($messages as $hash => $message) { try { $message->pullDataFromConduit($conduit, $partial = true); $fields[$hash] = $message->getFields(); } catch (ArcanistDifferentialCommitMessageParserException $ex) { if ($this->getArgument('verbatim')) { // In verbatim mode, just bail when we hit an error. The user can // rerun without --verbatim if they want to fix it manually. Most // users will probably `git commit --amend` instead. throw $ex; } $fields[$hash] = $message->getFields(); $frev = substr($hash, 0, 12); $notes[] = "NOTE: commit {$frev} could not be completely parsed:"; foreach ($ex->getParserErrors() as $error) { $notes[] = " - {$error}"; } } } // Merge commit message fields. We do this somewhat-intelligently so that // multiple "Reviewers" or "CC" fields will merge into the concatenation // of all values. // We have special parsing rules for 'title' because we can't merge // multiple titles, and one-line commit messages like "fix stuff" will // parse as titles. Instead, pick the first title we encounter. When we // encounter subsequent titles, treat them as part of the summary. Then // we merge all the summaries together below. $result = array(); // Process fields in oldest-first order, so earlier commits get to set the // title of record and reviewers/ccs are listed in chronological order. $fields = array_reverse($fields); foreach ($fields as $hash => $dict) { $title = idx($dict, 'title'); if (!strlen($title)) { continue; } if (!isset($result['title'])) { // We don't have a title yet, so use this one. $result['title'] = $title; } else { // We already have a title, so merge this new title into the summary. $summary = idx($dict, 'summary'); if ($summary) { $summary = $title."\n\n".$summary; } else { $summary = $title; } $fields[$hash]['summary'] = $summary; } } // Now, merge all the other fields in a general sort of way. foreach ($fields as $hash => $dict) { foreach ($dict as $key => $value) { if ($key == 'title') { // This has been handled above, and either assigned directly or // merged into the summary. continue; } if (is_array($value)) { // For array values, merge the arrays, appending the new values. // Examples are "Reviewers" and "Cc", where this produces a list of // all users specified as reviewers. $cur = idx($result, $key, array()); $new = array_merge($cur, $value); $result[$key] = $new; continue; } else { if (!strlen(trim($value))) { // Ignore empty fields. continue; } // For string values, append the new field to the old field with // a blank line separating them. Examples are "Test Plan" and // "Summary". $cur = idx($result, $key, ''); if (strlen($cur)) { $new = $cur."\n\n".$value; } else { $new = $value; } $result[$key] = $new; } } } return array($result, $notes, $included); } private function getDefaultUpdateMessage() { if ($this->isRawDiffSource()) { return null; } $repository_api = $this->getRepositoryAPI(); if ($repository_api instanceof ArcanistGitAPI) { return $this->getGitUpdateMessage(); } if ($repository_api instanceof ArcanistMercurialAPI) { return $this->getMercurialUpdateMessage(); } return null; } /** * Retrieve the git messages between HEAD and the last update. * * @task message */ private function getGitUpdateMessage() { $repository_api = $this->getRepositoryAPI(); $parser = $this->newDiffParser(); $commit_messages = $repository_api->getGitCommitLog(); $commit_messages = $parser->parseDiff($commit_messages); if (count($commit_messages) == 1) { // If there's only one message, assume this is an amend-based workflow and // that using it to prefill doesn't make sense. return null; } // We have more than one message, so figure out which ones are new. We // do this by pulling the current diff and comparing commit hashes in the // working copy with attached commit hashes. It's not super important that // we always get this 100% right, we're just trying to do something // reasonable. $local = $this->loadActiveLocalCommitInfo(); $hashes = ipull($local, null, 'commit'); $usable = array(); foreach ($commit_messages as $message) { $text = $message->getMetadata('message'); $parsed = ArcanistDifferentialCommitMessage::newFromRawCorpus($text); if ($parsed->getRevisionID()) { // If this is an amended commit message with a revision ID, it's // certainly not new. Stop marking commits as usable and break out. break; } if (isset($hashes[$message->getCommitHash()])) { // If this commit is currently part of the diff, stop using commit // messages, since anything older than this isn't new. break; } // Otherwise, this looks new, so it's a usable commit message. $usable[] = $text; } if (!$usable) { // No new commit messages, so we don't have anywhere to start from. return null; } return $this->formatUsableLogs($usable); } /** * Retrieve the hg messages between tip and the last update. * * @task message */ private function getMercurialUpdateMessage() { $repository_api = $this->getRepositoryAPI(); $messages = $repository_api->getCommitMessageLog(); if (count($messages) == 1) { // If there's only one message, assume this is an amend-based workflow and // that using it to prefill doesn't make sense. return null; } $local = $this->loadActiveLocalCommitInfo(); $hashes = ipull($local, null, 'commit'); $usable = array(); foreach ($messages as $rev => $message) { if (isset($hashes[$rev])) { // If this commit is currently part of the active diff on the revision, // stop using commit messages, since anything older than this isn't new. break; } // Otherwise, this looks new, so it's a usable commit message. $usable[] = $message; } if (!$usable) { // No new commit messages, so we don't have anywhere to start from. return null; } return $this->formatUsableLogs($usable); } /** * Format log messages to prefill a diff update. * * @task message */ private function formatUsableLogs(array $usable) { // Flip messages so they'll read chronologically (oldest-first) in the // template, e.g.: // // - Added foobar. // - Fixed foobar bug. // - Documented foobar. $usable = array_reverse($usable); $default = array(); foreach ($usable as $message) { // Pick the first line out of each message. $text = trim($message); $text = head(explode("\n", $text)); $default[] = ' - '.$text."\n"; } return implode('', $default); } private function loadActiveLocalCommitInfo() { $current_diff = $this->getConduit()->callMethodSynchronous( 'differential.querydiffs', array( 'revisionIDs' => array($this->revisionID), )); $current_diff = head($current_diff); $properties = idx($current_diff, 'properties', array()); return idx($properties, 'local:commits', array()); } /* -( Diff Specification )------------------------------------------------- */ /** * @task diffspec */ private function getLintStatus($lint_result) { $map = array( ArcanistLintWorkflow::RESULT_OKAY => 'okay', ArcanistLintWorkflow::RESULT_ERRORS => 'fail', ArcanistLintWorkflow::RESULT_WARNINGS => 'warn', ArcanistLintWorkflow::RESULT_SKIP => 'skip', ArcanistLintWorkflow::RESULT_POSTPONED => 'postponed', ); return idx($map, $lint_result, 'none'); } /** * @task diffspec */ private function getUnitStatus($unit_result) { $map = array( ArcanistUnitWorkflow::RESULT_OKAY => 'okay', ArcanistUnitWorkflow::RESULT_FAIL => 'fail', ArcanistUnitWorkflow::RESULT_UNSOUND => 'warn', ArcanistUnitWorkflow::RESULT_SKIP => 'skip', ArcanistUnitWorkflow::RESULT_POSTPONED => 'postponed', ); return idx($map, $unit_result, 'none'); } /** * @task diffspec */ private function buildDiffSpecification() { $base_revision = null; $base_path = null; $vcs = null; $repo_uuid = null; $parent = null; $source_path = null; $branch = null; $bookmark = null; if (!$this->isRawDiffSource()) { $repository_api = $this->getRepositoryAPI(); $base_revision = $repository_api->getSourceControlBaseRevision(); $base_path = $repository_api->getSourceControlPath(); $vcs = $repository_api->getSourceControlSystemName(); $source_path = $repository_api->getPath(); $branch = $repository_api->getBranchName(); $repo_uuid = $repository_api->getRepositoryUUID(); if ($repository_api instanceof ArcanistGitAPI) { $info = $this->getGitParentLogInfo(); if ($info['parent']) { $parent = $info['parent']; } if ($info['base_revision']) { $base_revision = $info['base_revision']; } if ($info['base_path']) { $base_path = $info['base_path']; } if ($info['uuid']) { $repo_uuid = $info['uuid']; } } else if ($repository_api instanceof ArcanistMercurialAPI) { $bookmark = $repository_api->getActiveBookmark(); $svn_info = $repository_api->getSubversionInfo(); $repo_uuid = idx($svn_info, 'uuid'); $base_path = idx($svn_info, 'base_path', $base_path); $base_revision = idx($svn_info, 'base_revision', $base_revision); // TODO: provide parent info } } $project_id = null; if ($this->requiresWorkingCopy()) { $project_id = $this->getWorkingCopy()->getProjectID(); } $data = array( 'sourceMachine' => php_uname('n'), 'sourcePath' => $source_path, 'branch' => $branch, 'bookmark' => $bookmark, 'sourceControlSystem' => $vcs, 'sourceControlPath' => $base_path, 'sourceControlBaseRevision' => $base_revision, 'creationMethod' => 'arc', - 'arcanistProject' => $project_id, ); if (!$this->isRawDiffSource()) { $repository_phid = $this->getRepositoryPHID(); if ($repository_phid) { $data['repositoryPHID'] = $repository_phid; } } return $data; } /* -( Diff Properties )---------------------------------------------------- */ /** * Update lint information for the diff. * * @return void * * @task diffprop */ private function updateLintDiffProperty() { if (strlen($this->excuses['lint'])) { $this->updateDiffProperty('arc:lint-excuse', json_encode($this->excuses['lint'])); } if ($this->unresolvedLint) { $this->updateDiffProperty('arc:lint', json_encode($this->unresolvedLint)); } $postponed = $this->postponedLinters; if ($postponed) { $this->updateDiffProperty('arc:lint-postponed', json_encode($postponed)); } } /** * Update unit test information for the diff. * * @return void * * @task diffprop */ private function updateUnitDiffProperty() { if (strlen($this->excuses['unit'])) { $this->updateDiffProperty('arc:unit-excuse', json_encode($this->excuses['unit'])); } if ($this->testResults) { $this->updateDiffProperty('arc:unit', json_encode($this->testResults)); } } /** * Update local commit information for the diff. * * @task diffprop */ private function updateLocalDiffProperty() { if ($this->isRawDiffSource()) { return; } $local_info = $this->getRepositoryAPI()->getLocalCommitInformation(); if (!$local_info) { return; } $this->updateDiffProperty('local:commits', json_encode($local_info)); } /** * Update an arbitrary diff property. * * @param string Diff property name. * @param string Diff property value. * @return void * * @task diffprop */ private function updateDiffProperty($name, $data) { $this->diffPropertyFutures[] = $this->getConduit()->callMethod( 'differential.setdiffproperty', array( 'diff_id' => $this->getDiffID(), 'name' => $name, 'data' => $data, )); } /** * Wait for finishing all diff property updates. * * @return void * * @task diffprop */ private function resolveDiffPropertyUpdates() { id(new FutureIterator($this->diffPropertyFutures)) ->resolveAll(); $this->diffPropertyFutures = array(); } private function dispatchWillCreateRevisionEvent(array $fields) { $event = $this->dispatchEvent( ArcanistEventType::TYPE_REVISION_WILLCREATEREVISION, array( 'specification' => $fields, )); return $event->getValue('specification'); } private function dispatchWillBuildEvent(array $fields) { $event = $this->dispatchEvent( ArcanistEventType::TYPE_DIFF_WILLBUILDMESSAGE, array( 'fields' => $fields, )); return $event->getValue('fields'); } private function checkRevisionOwnership(array $revision) { if ($revision['authorPHID'] == $this->getUserPHID()) { return; } $id = $revision['id']; $title = $revision['title']; throw new ArcanistUsageException( pht( "You don't own revision %s '%s'. You can only update revisions ". "you own. You can 'Commandeer' this revision from the web ". "interface if you want to become the owner.", "D{$id}", $title)); } /* -( File Uploads )------------------------------------------------------- */ private function uploadFilesForChanges(array $changes) { assert_instances_of($changes, 'ArcanistDiffChange'); // Collect all the files we need to upload. $need_upload = array(); foreach ($changes as $key => $change) { if ($change->getFileType() != ArcanistDiffChangeType::FILE_BINARY) { continue; } if ($this->getArgument('skip-binaries')) { continue; } $name = basename($change->getCurrentPath()); $need_upload[] = array( 'type' => 'old', 'name' => $name, 'data' => $change->getOriginalFileData(), 'change' => $change, ); $need_upload[] = array( 'type' => 'new', 'name' => $name, 'data' => $change->getCurrentFileData(), 'change' => $change, ); } if (!$need_upload) { return; } // Determine mime types and file sizes. Update changes from "binary" to // "image" if the file is an image. Set image metadata. $type_image = ArcanistDiffChangeType::FILE_IMAGE; foreach ($need_upload as $key => $spec) { $change = $need_upload[$key]['change']; $type = $spec['type']; $size = strlen($spec['data']); $change->setMetadata("{$type}:file:size", $size); if ($spec['data'] === null) { // This covers the case where a file was added or removed; we don't // need to upload the other half of it (e.g., the old file data for // a file which was just added). This is distinct from an empty // file, which we do upload. unset($need_upload[$key]); continue; } $mime = $this->getFileMimeType($spec['data']); if (preg_match('@^image/@', $mime)) { $change->setFileType($type_image); } $change->setMetadata("{$type}:file:mime-type", $mime); } echo pht('Uploading %d files...', count($need_upload))."\n"; $hash_futures = array(); foreach ($need_upload as $key => $spec) { $hash_futures[$key] = $this->getConduit()->callMethod( 'file.uploadhash', array( 'name' => $spec['name'], 'hash' => sha1($spec['data']), )); } $futures = id(new FutureIterator($hash_futures)) ->limit(8); foreach ($futures as $key => $future) { $type = $need_upload[$key]['type']; $change = $need_upload[$key]['change']; $name = $need_upload[$key]['name']; $phid = null; try { $phid = $future->resolve(); } catch (Exception $e) { // Just try uploading normally if the hash upload failed. continue; } if ($phid) { $change->setMetadata("{$type}:binary-phid", $phid); unset($need_upload[$key]); echo pht("Uploaded '%s' (%s).", $name, $type)."\n"; } } $upload_futures = array(); foreach ($need_upload as $key => $spec) { $upload_futures[$key] = $this->getConduit()->callMethod( 'file.upload', array( 'name' => $spec['name'], 'data_base64' => base64_encode($spec['data']), )); } $futures = id(new FutureIterator($upload_futures)) ->limit(4); foreach ($futures as $key => $future) { $type = $need_upload[$key]['type']; $change = $need_upload[$key]['change']; $name = $need_upload[$key]['name']; try { $phid = $future->resolve(); $change->setMetadata("{$type}:binary-phid", $phid); echo pht("Uploaded '%s' (%s).", $name, $type)."\n"; } catch (Exception $e) { echo pht("Failed to upload %s binary '%s'.", $type, $name)."\n\n"; echo $e->getMessage()."\n"; if (!phutil_console_confirm(pht('Continue?'), $default_no = false)) { throw new ArcanistUsageException( pht( 'Aborted due to file upload failure. You can use %s '. 'to skip binary uploads.', '--skip-binaries')); } } } echo pht('Upload complete.')."\n"; } private function getFileMimeType($data) { $tmp = new TempFile(); Filesystem::writeFile($tmp, $data); return Filesystem::getMimeType($tmp); } private function shouldOpenCreatedObjectsInBrowser() { return $this->getArgument('browse'); } } diff --git a/src/workflow/ArcanistPatchWorkflow.php b/src/workflow/ArcanistPatchWorkflow.php index 4fbc9cf0..dda5a54c 100644 --- a/src/workflow/ArcanistPatchWorkflow.php +++ b/src/workflow/ArcanistPatchWorkflow.php @@ -1,1136 +1,1135 @@ array( 'param' => 'revision_id', 'paramtype' => 'complete', 'help' => pht( "Apply changes from a Differential revision, using the most recent ". "diff that has been attached to it. You can run '%s' as a shorthand.", 'arc patch D12345'), ), 'diff' => array( 'param' => 'diff_id', 'help' => pht( 'Apply changes from a Differential diff. Normally you want to use '. '%s to get the most recent changes, but you can specifically apply '. 'an out-of-date diff or a diff which was never attached to a '. 'revision by using this flag.', '--revision'), ), 'arcbundle' => array( 'param' => 'bundlefile', 'paramtype' => 'file', 'help' => pht( "Apply changes from an arc bundle generated with '%s'.", 'arc export'), ), 'patch' => array( 'param' => 'patchfile', 'paramtype' => 'file', 'help' => pht( 'Apply changes from a git patchfile or unified patchfile.'), ), 'encoding' => array( 'param' => 'encoding', 'help' => pht( 'Attempt to convert non UTF-8 patch into specified encoding.'), ), 'update' => array( 'supports' => array('git', 'svn', 'hg'), 'help' => pht( 'Update the local working copy before applying the patch.'), 'conflicts' => array( 'nobranch' => true, 'bookmark' => true, ), ), 'nocommit' => array( 'supports' => array('git', 'hg'), 'help' => pht( 'Normally under git/hg, if the patch is successful, the changes '. 'are committed to the working copy. This flag prevents the commit.'), ), 'skip-dependencies' => array( 'supports' => array('git', 'hg'), 'help' => pht( 'Normally, if a patch has dependencies that are not present in the '. 'working copy, arc tries to apply them as well. This flag prevents '. 'such work.'), ), 'nobranch' => array( 'supports' => array('git', 'hg'), 'help' => pht( 'Normally, a new branch (git) or bookmark (hg) is created and then '. 'the patch is applied and committed in the new branch/bookmark. '. 'This flag cherry-picks the resultant commit onto the original '. 'branch and deletes the temporary branch.'), 'conflicts' => array( 'update' => true, ), ), 'force' => array( 'help' => pht('Do not run any sanity checks.'), ), '*' => 'name', ); } protected function didParseArguments() { $source = null; $requested = 0; if ($this->getArgument('revision')) { $source = self::SOURCE_REVISION; $requested++; } if ($this->getArgument('diff')) { $source = self::SOURCE_DIFF; $requested++; } if ($this->getArgument('arcbundle')) { $source = self::SOURCE_BUNDLE; $requested++; } if ($this->getArgument('patch')) { $source = self::SOURCE_PATCH; $requested++; } $use_revision_id = null; if ($this->getArgument('name')) { $namev = $this->getArgument('name'); if (count($namev) > 1) { throw new ArcanistUsageException( pht('Specify at most one revision name.')); } $source = self::SOURCE_REVISION; $requested++; $use_revision_id = $this->normalizeRevisionID(head($namev)); } if ($requested === 0) { throw new ArcanistUsageException( pht( "Specify one of '%s', '%s' (to select the current changes attached ". "to a Differential revision), '%s' (to select a specific, ". "out-of-date diff or a diff which is not attached to a revision), ". "'%s' or '%s' to choose a patch source.", 'D12345', '--revision ', '--diff ', '--arcbundle ', '--patch ')); } else if ($requested > 1) { throw new ArcanistUsageException( pht( "Options '%s', '%s', '%s', '%s' and '%s' are not compatible. ". "Choose exactly one patch source.", 'D12345', '--revision', '--diff', '--arcbundle', '--patch')); } $this->source = $source; $this->sourceParam = nonempty( $use_revision_id, $this->getArgument($source)); } public function requiresConduit() { return ($this->getSource() != self::SOURCE_PATCH); } public function requiresRepositoryAPI() { return true; } public function requiresWorkingCopy() { return true; } private function getSource() { return $this->source; } private function getSourceParam() { return $this->sourceParam; } private function shouldCommit() { return !$this->getArgument('nocommit', false); } private function canBranch() { $repository_api = $this->getRepositoryAPI(); return ($repository_api instanceof ArcanistGitAPI) || ($repository_api instanceof ArcanistMercurialAPI); } private function shouldBranch() { $no_branch = $this->getArgument('nobranch', false); if ($no_branch) { return false; } return true; } private function getBranchName(ArcanistBundle $bundle) { $branch_name = null; $repository_api = $this->getRepositoryAPI(); $revision_id = $bundle->getRevisionID(); $base_name = 'arcpatch'; if ($revision_id) { $base_name .= "-D{$revision_id}"; } $suffixes = array(null, '_1', '_2', '_3'); foreach ($suffixes as $suffix) { $proposed_name = $base_name.$suffix; list($err) = $repository_api->execManualLocal( 'rev-parse --verify %s', $proposed_name); // no error means git rev-parse found a branch if (!$err) { echo phutil_console_format( "%s\n", pht( 'Branch name %s already exists; trying a new name.', $proposed_name)); continue; } else { $branch_name = $proposed_name; break; } } if (!$branch_name) { throw new Exception( pht( 'Arc was unable to automagically make a name for this patch. '. 'Please clean up your working copy and try again.')); } return $branch_name; } private function getBookmarkName(ArcanistBundle $bundle) { $bookmark_name = null; $repository_api = $this->getRepositoryAPI(); $revision_id = $bundle->getRevisionID(); $base_name = 'arcpatch'; if ($revision_id) { $base_name .= "-D{$revision_id}"; } $suffixes = array(null, '-1', '-2', '-3'); foreach ($suffixes as $suffix) { $proposed_name = $base_name.$suffix; list($err) = $repository_api->execManualLocal( 'log -r %s', hgsprintf('%s', $proposed_name)); // no error means hg log found a bookmark if (!$err) { echo phutil_console_format( "%s\n", pht( 'Bookmark name %s already exists; trying a new name.', $proposed_name)); continue; } else { $bookmark_name = $proposed_name; break; } } if (!$bookmark_name) { throw new Exception( pht( 'Arc was unable to automagically make a name for this patch. '. 'Please clean up your working copy and try again.')); } return $bookmark_name; } private function createBranch(ArcanistBundle $bundle, $has_base_revision) { $repository_api = $this->getRepositoryAPI(); if ($repository_api instanceof ArcanistGitAPI) { $branch_name = $this->getBranchName($bundle); $base_revision = $bundle->getBaseRevision(); if ($base_revision && $has_base_revision) { $base_revision = $repository_api->getCanonicalRevisionName( $base_revision); $repository_api->execxLocal( 'checkout -b %s %s', $branch_name, $base_revision); } else { $repository_api->execxLocal('checkout -b %s', $branch_name); } echo phutil_console_format( "%s\n", pht( 'Created and checked out branch %s.', $branch_name)); } else if ($repository_api instanceof ArcanistMercurialAPI) { $branch_name = $this->getBookmarkName($bundle); $base_revision = $bundle->getBaseRevision(); if ($base_revision && $has_base_revision) { $base_revision = $repository_api->getCanonicalRevisionName( $base_revision); echo pht("Updating to the revision's base commit")."\n"; $repository_api->execPassthru('update %s', $base_revision); } $repository_api->execxLocal('bookmark %s', $branch_name); echo phutil_console_format( "%s\n", pht( 'Created and checked out bookmark %s.', $branch_name)); } return $branch_name; } private function shouldApplyDependencies() { return !$this->getArgument('skip-dependencies', false); } private function shouldUpdateWorkingCopy() { return $this->getArgument('update', false); } private function updateWorkingCopy() { echo pht('Updating working copy...')."\n"; $this->getRepositoryAPI()->updateWorkingCopy(); echo pht('Done.')."\n"; } public function run() { $source = $this->getSource(); $param = $this->getSourceParam(); try { switch ($source) { case self::SOURCE_PATCH: if ($param == '-') { $patch = @file_get_contents('php://stdin'); if (!strlen($patch)) { throw new ArcanistUsageException( pht('Failed to read patch from stdin!')); } } else { $patch = Filesystem::readFile($param); } $bundle = ArcanistBundle::newFromDiff($patch); break; case self::SOURCE_BUNDLE: $path = $this->getArgument('arcbundle'); $bundle = ArcanistBundle::newFromArcBundle($path); break; case self::SOURCE_REVISION: $bundle = $this->loadRevisionBundleFromConduit( $this->getConduit(), $param); break; case self::SOURCE_DIFF: $bundle = $this->loadDiffBundleFromConduit( $this->getConduit(), $param); break; } } catch (ConduitClientException $ex) { if ($ex->getErrorCode() == 'ERR-INVALID-SESSION') { // Phabricator is not configured to allow anonymous access to // Differential. $this->authenticateConduit(); return $this->run(); } else { throw $ex; } } $try_encoding = nonempty($this->getArgument('encoding'), null); if (!$try_encoding) { if ($this->requiresConduit()) { try { $try_encoding = $this->getRepositoryEncoding(); } catch (ConduitClientException $e) { $try_encoding = null; } } } if ($try_encoding) { $bundle->setEncoding($try_encoding); } $sanity_check = !$this->getArgument('force', false); // we should update the working copy before we do ANYTHING else to // the working copy if ($this->shouldUpdateWorkingCopy()) { $this->updateWorkingCopy(); } if ($sanity_check) { $this->requireCleanWorkingCopy(); } $repository_api = $this->getRepositoryAPI(); $has_base_revision = $repository_api->hasLocalCommit( $bundle->getBaseRevision()); if ($this->canBranch() && ($this->shouldBranch() || ($this->shouldCommit() && $has_base_revision))) { if ($repository_api instanceof ArcanistGitAPI) { $original_branch = $repository_api->getBranchName(); } else if ($repository_api instanceof ArcanistMercurialAPI) { $original_branch = $repository_api->getActiveBookmark(); } // If we weren't on a branch, then record the ref we'll return to // instead. if ($original_branch === null) { if ($repository_api instanceof ArcanistGitAPI) { $original_branch = $repository_api->getCanonicalRevisionName('HEAD'); } else if ($repository_api instanceof ArcanistMercurialAPI) { $original_branch = $repository_api->getCanonicalRevisionName('.'); } } $new_branch = $this->createBranch($bundle, $has_base_revision); } if (!$has_base_revision && $this->shouldApplyDependencies()) { $this->applyDependencies($bundle); } if ($sanity_check) { $this->sanityCheck($bundle); } if ($repository_api instanceof ArcanistSubversionAPI) { $patch_err = 0; $copies = array(); $deletes = array(); $patches = array(); $propset = array(); $adds = array(); $symlinks = array(); $changes = $bundle->getChanges(); foreach ($changes as $change) { $type = $change->getType(); $should_patch = true; $filetype = $change->getFileType(); switch ($filetype) { case ArcanistDiffChangeType::FILE_SYMLINK: $should_patch = false; $symlinks[] = $change; break; } switch ($type) { case ArcanistDiffChangeType::TYPE_MOVE_AWAY: case ArcanistDiffChangeType::TYPE_MULTICOPY: case ArcanistDiffChangeType::TYPE_DELETE: $path = $change->getCurrentPath(); $fpath = $repository_api->getPath($path); if (!@file_exists($fpath)) { $ok = phutil_console_confirm( pht( "Patch deletes file '%s', but the file does not exist in ". "the working copy. Continue anyway?", $path)); if (!$ok) { throw new ArcanistUserAbortException(); } } else { $deletes[] = $change->getCurrentPath(); } $should_patch = false; break; case ArcanistDiffChangeType::TYPE_COPY_HERE: case ArcanistDiffChangeType::TYPE_MOVE_HERE: $path = $change->getOldPath(); $fpath = $repository_api->getPath($path); if (!@file_exists($fpath)) { $cpath = $change->getCurrentPath(); if ($type == ArcanistDiffChangeType::TYPE_COPY_HERE) { $verbs = pht('copies'); } else { $verbs = pht('moves'); } $ok = phutil_console_confirm( pht( "Patch %s '%s' to '%s', but source path does not exist ". "in the working copy. Continue anyway?", $verbs, $path, $cpath)); if (!$ok) { throw new ArcanistUserAbortException(); } } else { $copies[] = array( $change->getOldPath(), $change->getCurrentPath(), ); } break; case ArcanistDiffChangeType::TYPE_ADD: $adds[] = $change->getCurrentPath(); break; } if ($should_patch) { $cbundle = ArcanistBundle::newFromChanges(array($change)); $patches[$change->getCurrentPath()] = $cbundle->toUnifiedDiff(); $prop_old = $change->getOldProperties(); $prop_new = $change->getNewProperties(); $props = $prop_old + $prop_new; foreach ($props as $key => $ignored) { if (idx($prop_old, $key) !== idx($prop_new, $key)) { $propset[$change->getCurrentPath()][$key] = idx($prop_new, $key); } } } } // Before we start doing anything, create all the directories we're going // to add files to if they don't already exist. foreach ($copies as $copy) { list($src, $dst) = $copy; $this->createParentDirectoryOf($dst); } foreach ($patches as $path => $patch) { $this->createParentDirectoryOf($path); } foreach ($adds as $add) { $this->createParentDirectoryOf($add); } // TODO: The SVN patch workflow likely does not work on windows because // of the (cd ...) stuff. foreach ($copies as $copy) { list($src, $dst) = $copy; passthru( csprintf( '(cd %s; svn cp %s %s)', $repository_api->getPath(), ArcanistSubversionAPI::escapeFileNameForSVN($src), ArcanistSubversionAPI::escapeFileNameForSVN($dst))); } foreach ($deletes as $delete) { passthru( csprintf( '(cd %s; svn rm %s)', $repository_api->getPath(), ArcanistSubversionAPI::escapeFileNameForSVN($delete))); } foreach ($symlinks as $symlink) { $link_target = $symlink->getSymlinkTarget(); $link_path = $symlink->getCurrentPath(); switch ($symlink->getType()) { case ArcanistDiffChangeType::TYPE_ADD: case ArcanistDiffChangeType::TYPE_CHANGE: case ArcanistDiffChangeType::TYPE_MOVE_HERE: case ArcanistDiffChangeType::TYPE_COPY_HERE: execx( '(cd %s && ln -sf %s %s)', $repository_api->getPath(), $link_target, $link_path); break; } } foreach ($patches as $path => $patch) { $err = null; if ($patch) { $tmp = new TempFile(); Filesystem::writeFile($tmp, $patch); passthru( csprintf( '(cd %s; patch -p0 < %s)', $repository_api->getPath(), $tmp), $err); } else { passthru( csprintf( '(cd %s; touch %s)', $repository_api->getPath(), $path), $err); } if ($err) { $patch_err = max($patch_err, $err); } } foreach ($adds as $add) { passthru( csprintf( '(cd %s; svn add %s)', $repository_api->getPath(), ArcanistSubversionAPI::escapeFileNameForSVN($add))); } foreach ($propset as $path => $changes) { foreach ($changes as $prop => $value) { if ($prop == 'unix:filemode') { // Setting this property also changes the file mode. $prop = 'svn:executable'; $value = (octdec($value) & 0111 ? 'on' : null); } if ($value === null) { passthru( csprintf( '(cd %s; svn propdel %s %s)', $repository_api->getPath(), $prop, ArcanistSubversionAPI::escapeFileNameForSVN($path))); } else { passthru( csprintf( '(cd %s; svn propset %s %s %s)', $repository_api->getPath(), $prop, $value, ArcanistSubversionAPI::escapeFileNameForSVN($path))); } } } if ($patch_err == 0) { echo phutil_console_format( "** %s ** %s\n", pht('OKAY'), pht('Successfully applied patch to the working copy.')); } else { echo phutil_console_format( "\n\n** %s ** %s\n", pht('WARNING'), pht( "Some hunks could not be applied cleanly by the unix '%s' ". "utility. Your working copy may be different from the revision's ". "base, or you may be in the wrong subdirectory. You can export ". "the raw patch file using '%s', and then try to apply it by ". "fiddling with options to '%s' (particularly, %s), or manually. ". "The output above, from '%s', may be helpful in ". "figuring out what went wrong.", 'patch', 'arc export --unified', 'patch', '-p', 'patch')); } return $patch_err; } else if ($repository_api instanceof ArcanistGitAPI) { $patchfile = new TempFile(); Filesystem::writeFile($patchfile, $bundle->toGitPatch()); $passthru = new PhutilExecPassthru( 'git apply --index --reject -- %s', $patchfile); $passthru->setCWD($repository_api->getPath()); $err = $passthru->execute(); if ($err) { echo phutil_console_format( "\n** %s **\n", pht('Patch Failed!')); // NOTE: Git patches may fail if they change the case of a filename // (for instance, from 'example.c' to 'Example.c'). As of now, Git // can not apply these patches on case-insensitive filesystems and // there is no way to build a patch which works. throw new ArcanistUsageException(pht('Unable to apply patch!')); } // in case there were any submodule changes involved $repository_api->execpassthru('submodule update --init --recursive'); if ($this->shouldCommit()) { if ($bundle->getFullAuthor()) { $author_cmd = csprintf('--author=%s', $bundle->getFullAuthor()); } else { $author_cmd = ''; } $commit_message = $this->getCommitMessage($bundle); $future = $repository_api->execFutureLocal( 'commit -a %C -F - --no-verify', $author_cmd); $future->write($commit_message); $future->resolvex(); $verb = pht('committed'); } else { $verb = pht('applied'); } if ($this->canBranch() && !$this->shouldBranch() && $this->shouldCommit() && $has_base_revision) { $repository_api->execxLocal('checkout %s', $original_branch); $ex = null; try { $repository_api->execxLocal('cherry-pick %s', $new_branch); } catch (Exception $ex) { // do nothing } $repository_api->execxLocal('branch -D %s', $new_branch); if ($ex) { echo phutil_console_format( "\n** %s**\n", pht('Cherry Pick Failed!')); throw $ex; } } echo phutil_console_format( "** %s ** %s\n", pht('OKAY'), pht('Successfully %s patch.', $verb)); } else if ($repository_api instanceof ArcanistMercurialAPI) { $future = $repository_api->execFutureLocal('import --no-commit -'); $future->write($bundle->toGitPatch()); try { $future->resolvex(); } catch (CommandException $ex) { echo phutil_console_format( "\n** %s **\n", pht('Patch Failed!')); $stderr = $ex->getStdErr(); if (preg_match('/case-folding collision/', $stderr)) { echo phutil_console_wrap( phutil_console_format( "\n** %s ** %s\n", pht('WARNING'), pht( "This patch may have failed because it attempts to change ". "the case of a filename (for instance, from '%s' to '%s'). ". "Mercurial cannot apply patches like this on case-insensitive ". "filesystems. You must apply this patch manually.", 'example.c', 'Example.c'))); } throw $ex; } if ($this->shouldCommit()) { $author = coalesce($bundle->getFullAuthor(), $bundle->getAuthorName()); if ($author !== null) { $author_cmd = csprintf('-u %s', $author); } else { $author_cmd = ''; } $commit_message = $this->getCommitMessage($bundle); $future = $repository_api->execFutureLocal( 'commit %C -l -', $author_cmd); $future->write($commit_message); $future->resolvex(); if (!$this->shouldBranch() && $has_base_revision) { $original_rev = $repository_api->getCanonicalRevisionName( $original_branch); $current_parent = $repository_api->getCanonicalRevisionName( hgsprintf('%s^', $new_branch)); $err = 0; if ($original_rev != $current_parent) { list($err) = $repository_api->execManualLocal( 'rebase --dest %s --rev %s', hgsprintf('%s', $original_branch), hgsprintf('%s', $new_branch)); } $repository_api->execxLocal('bookmark --delete %s', $new_branch); if ($err) { $repository_api->execManualLocal('rebase --abort'); throw new ArcanistUsageException( phutil_console_format( "\n** %s**\n", pht('Rebase onto %s failed!', $original_branch))); } } $verb = pht('committed'); } else { $verb = pht('applied'); } echo phutil_console_format( "** %s ** %s\n", pht('OKAY'), pht('Successfully %s patch.', $verb)); } else { throw new Exception(pht('Unknown version control system.')); } return 0; } private function getCommitMessage(ArcanistBundle $bundle) { $revision_id = $bundle->getRevisionID(); $commit_message = null; $prompt_message = null; // if we have a revision id the commit message is in differential // TODO: See T848 for the authenticated stuff. if ($revision_id && $this->isConduitAuthenticated()) { $conduit = $this->getConduit(); $commit_message = $conduit->callMethodSynchronous( 'differential.getcommitmessage', array( 'revision_id' => $revision_id, )); $prompt_message = pht( ' Note arcanist failed to load the commit message '. 'from differential for revision %s.', "D{$revision_id}"); } // no revision id or failed to fetch commit message so get it from the // user on the command line if (!$commit_message) { $template = sprintf( "\n\n# %s%s\n", pht( 'Enter a commit message for this patch. If you just want to apply '. 'the patch to the working copy without committing, re-run arc patch '. 'with the %s flag.', '--nocommit'), $prompt_message); $commit_message = $this->newInteractiveEditor($template) ->setName('arcanist-patch-commit-message') ->editInteractively(); $commit_message = ArcanistCommentRemover::removeComments($commit_message); if (!strlen(trim($commit_message))) { throw new ArcanistUserAbortException(); } } return $commit_message; } protected function getShellCompletions(array $argv) { // TODO: Pull open diffs from 'arc list'? return array('ARGUMENT'); } private function applyDependencies(ArcanistBundle $bundle) { // check for (and automagically apply on the user's be-hest) any revisions // this patch depends on $graph = $this->buildDependencyGraph($bundle); if ($graph) { $start_phid = $graph->getStartPHID(); $cycle_phids = $graph->detectCycles($start_phid); if ($cycle_phids) { $phids = array_keys($graph->getNodes()); $issue = pht( 'The dependencies for this patch have a cycle. Applying them '. 'is not guaranteed to work. Continue anyway?'); $okay = phutil_console_confirm($issue, true); } else { $phids = $graph->getTopographicallySortedNodes(); $phids = array_reverse($phids); $okay = true; } if (!$okay) { return; } $dep_on_revs = $this->getConduit()->callMethodSynchronous( 'differential.query', array( 'phids' => $phids, - 'arcanistProjects' => array($bundle->getProjectID()), )); $revs = array(); foreach ($dep_on_revs as $dep_on_rev) { $revs[$dep_on_rev['phid']] = 'D'.$dep_on_rev['id']; } // order them in case we got a topological sort earlier $revs = array_select_keys($revs, $phids); if (!empty($revs)) { $base_args = array( '--force', '--skip-dependencies', '--nobranch', ); if (!$this->shouldCommit()) { $base_args[] = '--nocommit'; } foreach ($revs as $phid => $diff_id) { // we'll apply this, the actual patch, later // this should be the last in the list if ($phid == $start_phid) { continue; } $args = $base_args; $args[] = $diff_id; $apply_workflow = $this->buildChildWorkflow( 'patch', $args); $apply_workflow->run(); } } } } /** * Do the best we can to prevent PEBKAC and id10t issues. */ private function sanityCheck(ArcanistBundle $bundle) { $repository_api = $this->getRepositoryAPI(); // Check to see if the bundle's project id matches the working copy // project id $bundle_project_id = $bundle->getProjectID(); $working_copy_project_id = $this->getWorkingCopy()->getProjectID(); if (empty($bundle_project_id)) { // this means $source is SOURCE_PATCH || SOURCE_BUNDLE w/ $version = 0 // they don't come with a project id so just do nothing } else if ($bundle_project_id != $working_copy_project_id) { if ($working_copy_project_id) { $issue = pht( "This patch is for the '%s' project, but the working copy ". "belongs to the '%s' project.", $bundle_project_id, $working_copy_project_id); } else { $issue = pht( "This patch is for the '%s' project, but the working copy does ". "not have an '%s' file to identify which project it belongs to.", $bundle_project_id, '.arcconfig'); } $ok = phutil_console_confirm( pht('%s Still try to apply the patch?', $issue), $default_no = false); if (!$ok) { throw new ArcanistUserAbortException(); } } // Check to see if the bundle's base revision matches the working copy // base revision if ($repository_api->supportsLocalCommits()) { $bundle_base_rev = $bundle->getBaseRevision(); if (empty($bundle_base_rev)) { // this means $source is SOURCE_PATCH || SOURCE_BUNDLE w/ $version < 2 // they don't have a base rev so just do nothing $commit_exists = true; } else { $commit_exists = $repository_api->hasLocalCommit($bundle_base_rev); } if (!$commit_exists) { // we have a problem...! lots of work because we need to ask // differential for revision information for these base revisions // to improve our error message. $bundle_base_rev_str = null; $source_base_rev = $repository_api->getWorkingCopyRevision(); $source_base_rev_str = null; if ($repository_api instanceof ArcanistGitAPI) { $hash_type = ArcanistDifferentialRevisionHash::HASH_GIT_COMMIT; } else if ($repository_api instanceof ArcanistMercurialAPI) { $hash_type = ArcanistDifferentialRevisionHash::HASH_MERCURIAL_COMMIT; } else { $hash_type = null; } if ($hash_type) { // 2 round trips because even though we could send off one query // we wouldn't be able to tell which revisions were for which hash $hash = array($hash_type, $bundle_base_rev); $bundle_revision = $this->loadRevisionFromHash($hash); $hash = array($hash_type, $source_base_rev); $source_revision = $this->loadRevisionFromHash($hash); if ($bundle_revision) { $bundle_base_rev_str = $bundle_base_rev. ' \ D'.$bundle_revision['id']; } if ($source_revision) { $source_base_rev_str = $source_base_rev. ' \ D'.$source_revision['id']; } } $bundle_base_rev_str = nonempty( $bundle_base_rev_str, $bundle_base_rev); $source_base_rev_str = nonempty( $source_base_rev_str, $source_base_rev); $ok = phutil_console_confirm( pht( 'This diff is against commit %s, but the commit is nowhere '. 'in the working copy. Try to apply it against the current '. 'working copy state? (%s)', $bundle_base_rev_str, $source_base_rev_str), $default_no = false); if (!$ok) { throw new ArcanistUserAbortException(); } } } } /** * Create parent directories one at a time, since we need to "svn add" each * one. (Technically we could "svn add" just the topmost new directory.) */ private function createParentDirectoryOf($path) { $repository_api = $this->getRepositoryAPI(); $dir = dirname($path); if (Filesystem::pathExists($dir)) { return; } else { // Make sure the parent directory exists before we make this one. $this->createParentDirectoryOf($dir); execx( '(cd %s && mkdir %s)', $repository_api->getPath(), $dir); passthru( csprintf( '(cd %s && svn add %s)', $repository_api->getPath(), $dir)); } } private function loadRevisionFromHash($hash) { // TODO -- de-hack this as permissions become more clear with things // like T848 (add scope to OAuth) if (!$this->isConduitAuthenticated()) { return null; } $conduit = $this->getConduit(); $revisions = $conduit->callMethodSynchronous( 'differential.query', array( 'commitHashes' => array($hash), )); // grab the latest closed revision only $found_revision = null; $revisions = isort($revisions, 'dateModified'); foreach ($revisions as $revision) { if ($revision['status'] == ArcanistDifferentialRevisionStatus::CLOSED) { $found_revision = $revision; } } return $found_revision; } private function buildDependencyGraph(ArcanistBundle $bundle) { $graph = null; if ($this->getRepositoryAPI() instanceof ArcanistSubversionAPI) { return $graph; } $revision_id = $bundle->getRevisionID(); if ($revision_id) { $revisions = $this->getConduit()->callMethodSynchronous( 'differential.query', array( 'ids' => array($revision_id), )); if ($revisions) { $revision = head($revisions); $rev_auxiliary = idx($revision, 'auxiliary', array()); $phids = idx($rev_auxiliary, 'phabricator:depends-on', array()); if ($phids) { $revision_phid = $revision['phid']; $graph = id(new ArcanistDifferentialDependencyGraph()) ->setConduit($this->getConduit()) ->setRepositoryAPI($this->getRepositoryAPI()) ->setStartPHID($revision_phid) ->addNodes(array($revision_phid => $phids)) ->loadGraph(); } } } return $graph; } }