diff --git a/scripts/__init_script__.php b/scripts/__init_script__.php index 9ff10fd4..3d67b9fb 100644 --- a/scripts/__init_script__.php +++ b/scripts/__init_script__.php @@ -1,34 +1,37 @@ 0) { ob_end_clean(); } diff --git a/scripts/arcanist.php b/scripts/arcanist.php index 3808137e..59d3665d 100755 --- a/scripts/arcanist.php +++ b/scripts/arcanist.php @@ -1,386 +1,408 @@ #!/usr/bin/env php $arg) { if ($arg == '--') { break; } else if ($arg == '--trace') { unset($args[$key]); $config_trace_mode = true; } else if ($arg == '--no-ansi') { unset($args[$key]); PhutilConsoleFormatter::disableANSI(true); } else if (preg_match('/^--load-phutil-library=(.*)$/', $arg, $matches)) { unset($args[$key]); $load[] = $matches[1]; } else if (preg_match('/^--conduit-uri=(.*)$/', $arg, $matches)) { unset($args[$key]); $force_conduit = $matches[1]; } } // The POSIX extension is not available by default in some PHP installs. if (function_exists('posix_isatty') && !posix_isatty(STDOUT)) { PhutilConsoleFormatter::disableANSI(true); } $args = array_values($args); $working_directory = getcwd(); try { if ($config_trace_mode) { PhutilServiceProfiler::installEchoListener(); } if (!$args) { throw new ArcanistUsageException("No command provided. Try 'arc help'."); } $working_copy = ArcanistWorkingCopyIdentity::newFromPath($working_directory); if ($load) { $libs = $load; } else { $libs = $working_copy->getConfig('phutil_libraries'); } if ($libs) { foreach ($libs as $name => $location) { // Try to resolve the library location. We look in several places, in // order: // // 1. Inside the working copy. This is for phutil libraries within the // project. For instance "library/src" will resolve to // "./library/src" if it exists. // 2. In the same directory as the working copy. This allows you to // check out a library alongside a working copy and reference it. // If we haven't resolved yet, "library/src" will try to resolve to // "../library/src" if it exists. // 3. Using normal libphutil resolution rules. Generally, this means // that it checks for libraries next to libphutil, then libraries // in the PHP include_path. $resolved = false; // Check inside the working copy. $resolved_location = Filesystem::resolvePath( $location, $working_copy->getProjectRoot()); if (Filesystem::pathExists($resolved_location)) { $location = $resolved_location; $resolved = true; } // If we didn't find anything, check alongside the working copy. if (!$resolved) { $resolved_location = Filesystem::resolvePath( $location, dirname($working_copy->getProjectRoot())); if (Filesystem::pathExists($resolved_location)) { $location = $resolved_location; $resolved = true; } } if ($config_trace_mode) { echo "Loading phutil library '{$name}' from '{$location}'...\n"; } try { phutil_load_library($location); } catch (PhutilBootloaderException $ex) { $error_msg = sprintf( 'Failed to load library "%s" at location "%s". Please check the '. '"phutil_libraries" setting in your .arcconfig file. Refer to page '. 'http://phabricator.com/docs/arcanist/article/'. 'Setting_Up_.arcconfig.html for more info.', $name, $location); throw new ArcanistUsageException($error_msg); } catch (PhutilLibraryConflictException $ex) { if ($ex->getLibrary() != 'arcanist') { throw $ex; } $arc_dir = dirname(dirname(__FILE__)); $error_msg = "You are trying to run one copy of Arcanist on another copy of ". "Arcanist. This operation is not supported. To execute Arcanist ". "operations against this working copy, run './bin/arc' (from the ". "current working copy) not some other copy of 'arc' (you ran one ". "from '{$arc_dir}')."; throw new ArcanistUsageException($error_msg); } } } $user_config = ArcanistBaseWorkflow::readUserConfigurationFile(); $config = $working_copy->getConfig('arcanist_configuration'); if ($config) { PhutilSymbolLoader::loadClass($config); $config = new $config(); } else { $config = new ArcanistConfiguration(); } $command = strtolower($args[0]); $args = array_slice($args, 1); $workflow = $config->buildWorkflow($command); if (!$workflow) { // If the user has an alias, like 'arc alias dhelp diff help', look it up // and substitute it. We do this only after trying to resolve the workflow // normally to prevent you from doing silly things like aliasing 'alias' // to something else. list($new_command, $args) = ArcanistAliasWorkflow::resolveAliases( $command, $config, $args); if ($new_command) { $workflow = $config->buildWorkflow($new_command); } if (!$workflow) { throw new ArcanistUsageException( "Unknown command '{$command}'. Try 'arc help'."); } else { if ($config_trace_mode) { $aliases = ArcanistAliasWorkflow::getAliases(); $target = implode(' ', idx($aliases, $command, array())); echo "[alias: 'arc {$command}' -> 'arc {$target}']\n"; } $command = $new_command; } } $workflow->setArcanistConfiguration($config); $workflow->setCommand($command); $workflow->setWorkingDirectory($working_directory); $workflow->parseArguments($args); $need_working_copy = $workflow->requiresWorkingCopy(); $need_conduit = $workflow->requiresConduit(); $need_auth = $workflow->requiresAuthentication(); $need_repository_api = $workflow->requiresRepositoryAPI(); $need_conduit = $need_conduit || $need_auth; $need_working_copy = $need_working_copy || $need_conduit || $need_repository_api; if ($need_working_copy) { if (!$working_copy->getProjectRoot()) { throw new ArcanistUsageException( "There is no '.arcconfig' file in this directory or any parent ". "directory. Create a '.arcconfig' file to configure this project ". "for use with Arcanist."); } $workflow->setWorkingCopy($working_copy); } if ($force_conduit) { $conduit_uri = $force_conduit; } else { $conduit_uri = $working_copy->getConduitURI(); } if ($conduit_uri) { // Set the URI path to '/api/'. TODO: Originally, I contemplated letting // you deploy Phabricator somewhere other than the domain root, but ended // up never pursuing that. We should get rid of all "/api/" silliness // in things users are expected to configure. This is already happening // to some degree, e.g. "arc install-certificate" does it for you. $conduit_uri = new PhutilURI($conduit_uri); $conduit_uri->setPath('/api/'); $conduit_uri = (string)$conduit_uri; } $workflow->setConduitURI($conduit_uri); if ($need_conduit) { if (!$conduit_uri) { throw new ArcanistUsageException( "No Conduit URI is specified in the .arcconfig file for this project. ". "Specify the Conduit URI for the host Differential is running on."); } $workflow->establishConduit(); } $hosts_config = idx($user_config, 'hosts', array()); $host_config = idx($hosts_config, $conduit_uri, array()); $user_name = idx($host_config, 'user'); $certificate = idx($host_config, 'cert'); $description = implode(' ', $argv); $credentials = array( 'user' => $user_name, 'certificate' => $certificate, 'description' => $description, ); $workflow->setConduitCredentials($credentials); if ($need_auth) { if (!$user_name || !$certificate) { throw new ArcanistUsageException( phutil_console_format( "YOU NEED TO __INSTALL A CERTIFICATE__ TO LOGIN TO PHABRICATOR\n\n". "You are trying to connect to '{$conduit_uri}' but do not have ". "a certificate installed for this host. Run:\n\n". " $ **arc install-certificate**\n\n". "...to install one.")); } $workflow->authenticateConduit(); } if ($need_repository_api) { $repository_api = ArcanistRepositoryAPI::newAPIFromWorkingCopyIdentity( $working_copy); $workflow->setRepositoryAPI($repository_api); } $listeners = $working_copy->getConfig('events.listeners'); if ($listeners) { foreach ($listeners as $listener) { id(new $listener())->register(); } } $config->willRunWorkflow($command, $workflow); $workflow->willRunWorkflow(); $err = $workflow->run(); $config->didRunWorkflow($command, $workflow, $err); exit($err); } catch (ArcanistUsageException $ex) { echo phutil_console_format( "**Usage Exception:** %s\n", $ex->getMessage()); if ($config_trace_mode) { echo "\n"; throw $ex; } exit(1); } catch (Exception $ex) { if ($config_trace_mode) { throw $ex; } echo phutil_console_format( "\n**Exception:**\n%s\n%s\n", $ex->getMessage(), "(Run with --trace for a full exception trace.)"); exit(1); } /** * Perform some sanity checks against the possible diversity of PHP builds in * the wild, like very old versions and builds that were compiled with flags * that exclude core functionality. */ function sanity_check_environment() { $min_version = '5.2.0'; $cur_version = phpversion(); if (version_compare($cur_version, $min_version, '<')) { die_with_bad_php( "You are running PHP version '{$cur_version}', which is older than ". "the minimum version, '{$min_version}'. Update to at least ". "'{$min_version}'."); } - $need_functions = array( - 'json_decode' => '--without-json', - ); + // NOTE: We don't have phutil_is_windows() yet here. + + if (DIRECTORY_SEPARATOR != '/') { + $need_functions = array( + 'curl_init' => array('builtin-dll', 'php_curl.dll'), + ); + } else { + $need_functions = array( + 'json_decode' => array('flag', '--without-json'), + ); + } $problems = array(); $config = null; $show_config = false; - foreach ($need_functions as $fname => $flag) { + foreach ($need_functions as $fname => $resolution) { if (function_exists($fname)) { continue; } static $info; if ($info === null) { ob_start(); phpinfo(INFO_GENERAL); $info = ob_get_clean(); $matches = null; if (preg_match('/^Configure Command =>\s*(.*?)$/m', $info, $matches)) { $config = $matches[1]; } } - if (strpos($config, $flag) !== false) { + $generic = true; + list($what, $which) = $resolution; + + if ($what == 'flag' && strpos($config, $which) !== false) { $show_config = true; + $generic = false; $problems[] = - "This build of PHP was compiled with the configure flag '{$flag}', ". + "This build of PHP was compiled with the configure flag '{$which}', ". "which means it does not have the function '{$fname}()'. This ". "function is required for arc to run. Rebuild PHP without this flag. ". "You may also be able to build or install the relevant extension ". "separately."; - } else { + } + + if ($what == 'builtin-dll') { + $generic = false; + $problems[] = + "Your install of PHP does not have the '{$which}' extension enabled. ". + "Edit your php.ini file and uncomment the line which reads ". + "'extension={$which}'."; + } + + if ($generic) { $problems[] = "This build of PHP is missing the required function '{$fname}()'. ". "Rebuild PHP or install the extension which provides '{$fname}()'."; } } if ($problems) { if ($show_config) { $problems[] = "PHP was built with this configure command:\n\n{$config}"; } die_with_bad_php(implode("\n\n", $problems)); } } function die_with_bad_php($message) { echo "\nPHP CONFIGURATION ERRORS\n\n"; echo $message; echo "\n\n"; exit(1); } diff --git a/scripts/phutil_analyzer.php b/scripts/phutil_analyzer.php index 5df27104..48c59a32 100755 --- a/scripts/phutil_analyzer.php +++ b/scripts/phutil_analyzer.php @@ -1,407 +1,407 @@ #!/usr/bin/env php array_fill_keys($builtin_classes, true) + array( 'PhutilBootloader' => true, ), 'function' => array_filter( array( 'empty' => true, 'isset' => true, 'echo' => true, 'print' => true, 'exit' => true, 'die' => true, - 'phutil_load_library' => true, + 'phutil_is_windows' => true, // HPHP/i defines these functions as 'internal', but they are NOT // builtins and do not exist in vanilla PHP. Make sure we don't mark them // as builtin since we need to add dependencies for them. 'idx' => false, 'id' => false, ) + array_fill_keys($builtin_functions, true)), 'interface' => array_fill_keys($builtin_interfaces, true), ); require_once dirname(__FILE__).'/__init_script__.php'; if ($argc != 2) { $self = basename($argv[0]); echo "usage: {$self} \n"; exit(1); } phutil_require_module('phutil', 'filesystem'); $dir = Filesystem::resolvePath($argv[1]); phutil_require_module('phutil', 'parser/docblock'); phutil_require_module('phutil', 'parser/xhpast/bin'); phutil_require_module('phutil', 'parser/xhpast/api/tree'); phutil_require_module('arcanist', 'lint/linter/phutilmodule'); phutil_require_module('arcanist', 'lint/message'); phutil_require_module('arcanist', 'parser/phutilmodule'); $data = array(); $futures = array(); foreach (Filesystem::listDirectory($dir, $hidden_files = false) as $file) { if (!preg_match('/.php$/', $file)) { continue; } $data[$file] = Filesystem::readFile($dir.'/'.$file); $futures[$file] = xhpast_get_parser_future($data[$file]); } $requirements = new PhutilModuleRequirements(); $requirements->addBuiltins($builtin); $doc_parser = new PhutilDocblockParser(); $has_init = false; $has_files = false; foreach (Futures($futures) as $file => $future) { try { $tree = XHPASTTree::newFromDataAndResolvedExecFuture( $data[$file], $future->resolve()); } catch (XHPASTSyntaxErrorException $ex) { echo "Syntax Error! In '{$file}': ".$ex->getMessage()."\n"; exit(1); } $root = $tree->getRootNode(); $requirements->setCurrentFile($file); if ($file == '__init__.php') { $has_init = true; $calls = $root->selectDescendantsOfType('n_FUNCTION_CALL'); foreach ($calls as $call) { $name = $call->getChildByIndex(0); $call_name = $name->getConcreteString(); if ($call_name == 'phutil_require_source') { $params = $call->getChildByIndex(1)->getChildren(); if (count($params) !== 1) { $requirements->addLint( $call, $call->getConcreteString(), ArcanistPhutilModuleLinter::LINT_ANALYZER_SIGNATURE, "Call to phutil_require_source() must have exactly one argument."); continue; } $param = reset($params); $value = $param->getStringLiteralValue(); if ($value === null) { $requirements->addLint( $param, $param->getConcreteString(), ArcanistPhutilModuleLinter::LINT_ANALYZER_SIGNATURE, "phutil_require_source() parameter must be a string literal."); continue; } $requirements->addSourceDependency($name, $value); } else if ($call_name == 'phutil_require_module') { analyze_phutil_require_module($call, $requirements, true); } } } else { $has_files = true; $requirements->addSourceDeclaration(basename($file)); // Find symbols declared as "@phutil-external-symbol function example", // and ignore these in building dependency lists. $externals = array(); foreach ($root->getTokens() as $token) { if ($token->getTypeName() == 'T_DOC_COMMENT') { list($block, $special) = $doc_parser->parse($token->getValue()); $ext_list = idx($special, 'phutil-external-symbol'); $ext_list = explode("\n", $ext_list); $ext_list = array_filter($ext_list); foreach ($ext_list as $ext_ref) { $matches = null; if (preg_match('/^\s*(\S+)\s+(\S+)/', $ext_ref, $matches)) { $externals[$matches[1]][$matches[2]] = true; } } } } // Function uses: // - Explicit call // TODO?: String literal in ReflectionFunction(). $calls = $root->selectDescendantsOfType('n_FUNCTION_CALL'); foreach ($calls as $call) { $name = $call->getChildByIndex(0); if ($name->getTypeName() == 'n_VARIABLE' || $name->getTypeName() == 'n_VARIABLE_VARIABLE') { $requirements->addLint( $name, $name->getConcreteString(), ArcanistPhutilModuleLinter::LINT_ANALYZER_DYNAMIC, "Use of variable function calls prevents dependencies from being ". "checked statically. This module may have undetectable errors."); continue; } if ($name->getTypeName() == 'n_CLASS_STATIC_ACCESS') { // We'll pick this up later. continue; } $call_name = $name->getConcreteString(); if ($call_name == 'phutil_require_module') { analyze_phutil_require_module($call, $requirements, false); } else if ($call_name == 'call_user_func' || $call_name == 'call_user_func_array') { $params = $call->getChildByIndex(1)->getChildren(); if (count($params) == 0) { $requirements->addLint( $call, $call->getConcreteString(), ArcanistPhutilModuleLinter::LINT_ANALYZER_SIGNATURE, "Call to {$call_name}() must have at least one argument."); } $symbol = array_shift($params); $symbol_value = $symbol->getStringLiteralValue(); if ($symbol_value) { $requirements->addFunctionDependency( $symbol, $symbol_value); } else { $requirements->addLint( $symbol, $symbol->getConcreteString(), ArcanistPhutilModuleLinter::LINT_ANALYZER_DYNAMIC, "Use of variable arguments to {$call_name} prevents dependencies ". "from being checked statically. This module may have undetectable ". "errors."); } } else if (empty($externals['function'][$name->getConcreteString()])) { $requirements->addFunctionDependency( $name, $name->getConcreteString()); } } $functions = $root->selectDescendantsOfType('n_FUNCTION_DECLARATION'); foreach ($functions as $function) { $name = $function->getChildByIndex(2); $requirements->addFunctionDeclaration( $name, $name->getConcreteString()); } // Class uses: // - new // - extends (in class declaration) // - Static method call // - Static property access // - Constant use // TODO?: String literal in ReflectionClass(). // TODO?: String literal in array literal in call_user_func / // call_user_func_array(). // TODO: Raise a soft warning for use of an unknown class in: // - Typehints // - instanceof // - catch $classes = $root->selectDescendantsOfType('n_CLASS_DECLARATION'); foreach ($classes as $class) { $class_name = $class->getChildByIndex(1); $requirements->addClassDeclaration( $class_name, $class_name->getConcreteString()); $extends = $class->getChildByIndex(2); foreach ($extends->selectDescendantsOfType('n_CLASS_NAME') as $parent) { if (empty($externals['class'][$parent->getConcreteString()])) { $requirements->addClassDependency( $class_name->getConcreteString(), $parent, $parent->getConcreteString()); } } $implements = $class->getChildByIndex(3); $interfaces = $implements->selectDescendantsOfType('n_CLASS_NAME'); foreach ($interfaces as $interface) { if (empty($externals['interface'][$interface->getConcreteString()])) { $requirements->addInterfaceDependency( $class_name->getConcreteString(), $interface, $interface->getConcreteString()); } } } if (count($classes) > 1) { foreach ($classes as $class) { $class_name = $class->getChildByIndex(1); $class_string = $class_name->getConcreteString(); $requirements->addLint( $class_name, $class_string, ArcanistPhutilModuleLinter::LINT_ANALYZER_MULTIPLE_CLASSES, "This file declares more than one class. Declare only one class per ". "file."); break; } } $uses_of_new = $root->selectDescendantsOfType('n_NEW'); foreach ($uses_of_new as $new_operator) { $name = $new_operator->getChildByIndex(0); if ($name->getTypeName() == 'n_VARIABLE' || $name->getTypeName() == 'n_VARIABLE_VARIABLE') { $requirements->addLint( $name, $name->getConcreteString(), ArcanistPhutilModuleLinter::LINT_ANALYZER_DYNAMIC, "Use of variable class instantiation prevents dependencies from ". "being checked statically. This module may have undetectable ". "errors."); continue; } if (empty($externals['class'][$name->getConcreteString()])) { $requirements->addClassDependency( null, $name, $name->getConcreteString()); } } $static_uses = $root->selectDescendantsOfType('n_CLASS_STATIC_ACCESS'); foreach ($static_uses as $static_use) { $name = $static_use->getChildByIndex(0); if ($name->getTypeName() != 'n_CLASS_NAME') { echo "WARNING UNLINTABLE\n"; continue; } $name_concrete = $name->getConcreteString(); $magic_names = array( 'static' => true, 'parent' => true, 'self' => true, ); if (isset($magic_names[$name_concrete])) { continue; } if (empty($externals['class'][$name_concrete])) { $requirements->addClassDependency( null, $name, $name_concrete); } } // Interface uses: // - implements // - extends (in interface declaration) $interfaces = $root->selectDescendantsOfType('n_INTERFACE_DECLARATION'); foreach ($interfaces as $interface) { $interface_name = $interface->getChildByIndex(1); $requirements->addInterfaceDeclaration( $interface_name, $interface_name->getConcreteString()); $extends = $interface->getChildByIndex(2); foreach ($extends->selectDescendantsOfType('n_CLASS_NAME') as $parent) { if (empty($externals['interface'][$parent->getConcreteString()])) { $requirements->addInterfaceDependency( $interface_name->getConcreteString(), $parent, $parent->getConcreteString()); } } } } } if (!$has_init && $has_files) { $requirements->addRawLint( ArcanistPhutilModuleLinter::LINT_ANALYZER_NO_INIT, "Create an __init__.php file in this module."); } echo json_encode($requirements->toDictionary()); /** * Parses meaning from calls to phutil_require_module() in __init__.php files. * * @group module */ function analyze_phutil_require_module( XHPASTNode $call, PhutilModuleRequirements $requirements, $create_dependency) { $name = $call->getChildByIndex(0); $params = $call->getChildByIndex(1)->getChildren(); if (count($params) !== 2) { $requirements->addLint( $call, $call->getConcreteString(), ArcanistPhutilModuleLinter::LINT_ANALYZER_SIGNATURE, "Call to phutil_require_module() must have exactly two arguments."); return; } $module_param = array_pop($params); $library_param = array_pop($params); $library_value = $library_param->getStringLiteralValue(); if ($library_value === null) { $requirements->addLint( $library_param, $library_param->getConcreteString(), ArcanistPhutilModuleLinter::LINT_ANALYZER_SIGNATURE, "phutil_require_module() parameters must be string literals."); return; } $module_value = $module_param->getStringLiteralValue(); if ($module_value === null) { $requirements->addLint( $module_param, $module_param->getConcreteString(), ArcanistPhutilModuleLinter::LINT_ANALYZER_SIGNATURE, "phutil_require_module() parameters must be string literals."); return; } if ($create_dependency) { $requirements->addModuleDependency( $name, $library_value.':'.$module_value); } } diff --git a/src/workflow/alias/ArcanistAliasWorkflow.php b/src/workflow/alias/ArcanistAliasWorkflow.php index 7a1704e5..70cbfda1 100644 --- a/src/workflow/alias/ArcanistAliasWorkflow.php +++ b/src/workflow/alias/ArcanistAliasWorkflow.php @@ -1,153 +1,158 @@ 'argv', ); } public static function getAliases() { $config = self::readUserConfigurationFile(); return idx($config, 'aliases', array()); } private function writeAliases(array $aliases) { $config = self::readUserConfigurationFile(); $config['aliases'] = $aliases; self::writeUserConfigurationFile($config); } public function run() { $aliases = self::getAliases(); $argv = $this->getArgument('argv'); if (count($argv) == 0) { if ($aliases) { foreach ($aliases as $alias => $binding) { echo phutil_console_format( "**%s** %s\n", $alias, implode(' ' , $binding)); } } else { echo "You haven't defined any aliases yet.\n"; } } else if (count($argv) == 1) { if (empty($aliases[$argv[0]])) { echo "No alias '{$argv[0]}' to remove.\n"; } else { echo phutil_console_format( "'**arc %s**' is currently aliased to '**arc %s**'.", $argv[0], implode(' ', $aliases[$argv[0]])); $ok = phutil_console_confirm('Delete this alias?'); if ($ok) { $was = implode(' ', $aliases[$argv[0]]); unset($aliases[$argv[0]]); $this->writeAliases($aliases); echo "Unaliased '{$argv[0]}' (was '{$was}').\n"; } else { throw new ArcanistUserAbortException(); } } } else { $arc_config = $this->getArcanistConfiguration(); if ($arc_config->buildWorkflow($argv[0])) { throw new ArcanistUsageException( "You can not create an alias for '{$argv[0]}' because it is a ". "builtin command. 'arc alias' can only create new commands."); } $aliases[$argv[0]] = array_slice($argv, 1); echo phutil_console_format( "Aliased '**arc %s**' to '**arc %s**'.\n", $argv[0], implode(' ', $aliases[$argv[0]])); $this->writeAliases($aliases); } return 0; } public static function resolveAliases( $command, ArcanistConfiguration $config, array $argv) { $aliases = ArcanistAliasWorkflow::getAliases(); if (!isset($aliases[$command])) { return array(null, $argv); } $new_command = head($aliases[$command]); $workflow = $config->buildWorkflow($new_command); if (!$workflow) { return array(null, $argv); } $alias_argv = array_slice($aliases[$command], 1); foreach ($alias_argv as $alias_arg) { if (!in_array($alias_arg, $argv)) { array_unshift($argv, $alias_arg); } } return array($new_command, $argv); } } diff --git a/src/workflow/amend/ArcanistAmendWorkflow.php b/src/workflow/amend/ArcanistAmendWorkflow.php index fa0bc7c7..b0e46092 100644 --- a/src/workflow/amend/ArcanistAmendWorkflow.php +++ b/src/workflow/amend/ArcanistAmendWorkflow.php @@ -1,189 +1,195 @@ array( 'help' => "Show the amended commit message, without modifying the working copy." ), 'revision' => array( 'param' => 'revision_id', 'help' => "Amend a specific revision. If you do not specify a revision, ". "arc will look in the commit message at HEAD.", ), ); } public function run() { $is_show = $this->getArgument('show'); $repository_api = $this->getRepositoryAPI(); if (!($repository_api instanceof ArcanistGitAPI)) { throw new ArcanistUsageException( "You may only run 'arc amend' in a git working copy."); } if (!$is_show) { if ($this->isHistoryImmutable()) { throw new ArcanistUsageException( "This project is marked as adhering to a conservative history ". "mutability doctrine (having an immutable local history), which ". "precludes amending commit messages. You can use 'arc merge' to ". "merge feature branches instead."); } if ($repository_api->getUncommittedChanges()) { throw new ArcanistUsageException( "You have uncommitted changes in this branch. Stage and commit (or ". "revert) them before proceeding."); } } $revision_id = null; if ($this->getArgument('revision')) { $revision_id = $this->normalizeRevisionID($this->getArgument('revision')); } $in_working_copy = $repository_api->loadWorkingCopyDifferentialRevisions( $this->getConduit(), array( 'authors' => array($this->getUserPHID()), 'status' => 'status-any', )); $in_working_copy = ipull($in_working_copy, null, 'id'); if (!$revision_id) { if (count($in_working_copy) == 0) { throw new ArcanistUsageException( "No revision specified with '--revision', and no revisions found ". "in the working copy. Use '--revision ' to specify which ". "revision you want to amend."); } else if (count($in_working_copy) > 1) { $message = "More than one revision was found in the working copy:\n". $this->renderRevisionList($in_working_copy)."\n". "Use '--revision ' to specify which revision you want to ". "amend."; throw new ArcanistUsageException($message); } else { $revision_id = key($in_working_copy); } } $conduit = $this->getConduit(); try { $message = $conduit->callMethodSynchronous( 'differential.getcommitmessage', array( 'revision_id' => $revision_id, 'edit' => false, ) ); } catch (ConduitClientException $ex) { if (strpos($ex->getMessage(), 'ERR_NOT_FOUND') === false) { throw $ex; } else { throw new ArcanistUsageException( "Revision D{$revision_id} does not exist." ); } } $revision = $conduit->callMethodSynchronous( 'differential.query', array( 'ids' => array($revision_id), )); if (empty($revision)) { throw new Exception( "Failed to lookup information for 'D{$revision_id}'!"); } $revision = head($revision); $revision_title = $revision['title']; if (!$is_show) { if ($revision_id && empty($in_working_copy[$revision_id])) { $ok = phutil_console_confirm( "The revision 'D{$revision_id}' does not appear to be in the ". "working copy. Are you sure you want to amend HEAD with the ". "commit message for 'D{$revision_id}: {$revision_title}'?"); if (!$ok) { throw new ArcanistUserAbortException(); } } } if ($is_show) { echo $message."\n"; } else { echo phutil_console_format( "Amending commit message to reflect revision **%s**.\n", "D{$revision_id}: {$revision_title}"); $repository_api->amendGitHeadCommit($message); $mark_workflow = $this->buildChildWorkflow( 'mark-committed', array( '--finalize', $revision_id, )); $mark_workflow->run(); } return 0; } protected function getSupportedRevisionControlSystems() { return array('git'); } } diff --git a/src/workflow/base/ArcanistBaseWorkflow.php b/src/workflow/base/ArcanistBaseWorkflow.php index 5e3dd820..41bfa6d3 100644 --- a/src/workflow/base/ArcanistBaseWorkflow.php +++ b/src/workflow/base/ArcanistBaseWorkflow.php @@ -1,1156 +1,1170 @@ conduit) { throw new Exception( "You can not change the Conduit URI after a conduit is already open."); } $this->conduitURI = $conduit_uri; return $this; } /** * Returns the URI the conduit connection within the workflow uses. * * @return string * @task conduit */ final public function getConduitURI() { return $this->conduitURI; } /** * Open a conduit channel to the server which was previously configured by * calling @{method:setConduitURI}. Arcanist will do this automatically if * the workflow returns ##true## from @{method:requiresConduit}, or you can * later upgrade a workflow and build a conduit by invoking it manually. * * You must establish a conduit before you can make conduit calls. * * NOTE: You must call @{method:setConduitURI} before you can call this * method. * * @return this * @task conduit */ final public function establishConduit() { if ($this->conduit) { return $this; } if (!$this->conduitURI) { throw new Exception( "You must specify a Conduit URI with setConduitURI() before you can ". "establish a conduit."); } $this->conduit = new ConduitClient($this->conduitURI); return $this; } /** * Set credentials which will be used to authenticate against Conduit. These * credentials can then be used to establish an authenticated connection to * conduit by calling @{method:authenticateConduit}. Arcanist sets some * defaults for all workflows regardless of whether or not they return true * from @{method:requireAuthentication}, based on the ##~/.arcrc## and * ##.arcconf## files if they are present. Thus, you can generally upgrade a * workflow which does not require authentication into an authenticated * workflow by later invoking @{method:requireAuthentication}. You should not * normally need to call this method unless you are specifically overriding * the defaults. * * NOTE: You can not call this method after calling * @{method:authenticateConduit}. * * @param dict A credential dictionary, see @{method:authenticateConduit}. * @return this * @task conduit */ final public function setConduitCredentials(array $credentials) { if ($this->isConduitAuthenticated()) { throw new Exception( "You may not set new credentials after authenticating conduit."); } $this->conduitCredentials = $credentials; return $this; } /** * Open and authenticate a conduit connection to a Phabricator server using * provided credentials. Normally, Arcanist does this for you automatically * when you return true from @{method:requiresAuthentication}, but you can * also upgrade an existing workflow to one with an authenticated conduit * by invoking this method manually. * * You must authenticate the conduit before you can make authenticated conduit * calls (almost all calls require authentication). * * This method uses credentials provided via @{method:setConduitCredentials} * to authenticate to the server: * * - ##user## (required) The username to authenticate with. * - ##certificate## (required) The Conduit certificate to use. * - ##description## (optional) Description of the invoking command. * * Successful authentication allows you to call @{method:getUserPHID} and * @{method:getUserName}, as well as use the client you access with * @{method:getConduit} to make authenticated calls. * * NOTE: You must call @{method:setConduitURI} and * @{method:setConduitCredentials} before you invoke this method. * * @return this * @task conduit */ final public function authenticateConduit() { if ($this->isConduitAuthenticated()) { return $this; } $this->establishConduit(); $credentials = $this->conduitCredentials; if (!$credentials) { throw new Exception( "Set conduit credentials with setConduitCredentials() before ". "authenticating conduit!"); } if (empty($credentials['user']) || empty($credentials['certificate'])) { throw new Exception( "Credentials must include a 'user' and a 'certificate'."); } $description = idx($credentials, 'description', ''); $user = $credentials['user']; $certificate = $credentials['certificate']; try { $connection = $this->getConduit()->callMethodSynchronous( 'conduit.connect', array( 'client' => 'arc', 'clientVersion' => 3, 'clientDescription' => php_uname('n').':'.$description, 'user' => $user, 'certificate' => $certificate, 'host' => $this->conduitURI, )); } catch (ConduitClientException $ex) { if ($ex->getErrorCode() == 'ERR-NO-CERTIFICATE' || $ex->getErrorCode() == 'ERR-INVALID-USER') { $conduit_uri = $this->conduitURI; $message = "\n". phutil_console_format( "YOU NEED TO __INSTALL A CERTIFICATE__ TO LOGIN TO PHABRICATOR"). "\n\n". phutil_console_format( " To do this, run: **arc install-certificate**"). "\n\n". "The server '{$conduit_uri}' rejected your request:". "\n". $ex->getMessage(); throw new ArcanistUsageException($message); } else { throw $ex; } } $this->userName = $user; $this->userPHID = $connection['userPHID']; $this->conduitAuthenticated = true; return $this; } /** * @return bool True if conduit is authenticated, false otherwise. * @task conduit */ final protected function isConduitAuthenticated() { return (bool) $this->conduitAuthenticated; } /** * Override this to return true if your workflow requires a conduit channel. * Arc will build the channel for you before your workflow executes. This * implies that you only need an unauthenticated channel; if you need * authentication, override @{method:requiresAuthentication}. * * @return bool True if arc should build a conduit channel before running * the workflow. * @task conduit */ public function requiresConduit() { return false; } /** * Override this to return true if your workflow requires an authenticated * conduit channel. This implies that it requires a conduit. Arc will build * and authenticate the channel for you before the workflow executes. * * @return bool True if arc should build an authenticated conduit channel * before running the workflow. * @task conduit */ public function requiresAuthentication() { return false; } /** * Returns the PHID for the user once they've authenticated via Conduit. * * @return phid Authenticated user PHID. * @task conduit */ final public function getUserPHID() { if (!$this->userPHID) { $workflow = get_class($this); throw new Exception( "This workflow ('{$workflow}') requires authentication, override ". "requiresAuthentication() to return true."); } return $this->userPHID; } /** * Deprecated. See @{method:getUserPHID}. * * @deprecated */ final public function getUserGUID() { phutil_deprecated( 'ArcanistBaseWorkflow::getUserGUID', 'This method has been renamed to getUserPHID().'); return $this->getUserPHID(); } /** * Return the username for the user once they've authenticated via Conduit. * * @return string Authenticated username. * @task conduit */ final public function getUserName() { return $this->userName; } /** * Get the established @{class@libphutil:ConduitClient} in order to make * Conduit method calls. Before the client is available it must be connected, * either implicitly by making @{method:requireConduit} or * @{method:requireAuthentication} return true, or explicitly by calling * @{method:establishConduit} or @{method:authenticateConduit}. * * @return @{class@libphutil:ConduitClient} Live conduit client. * @task conduit */ final public function getConduit() { if (!$this->conduit) { $workflow = get_class($this); throw new Exception( "This workflow ('{$workflow}') requires a Conduit, override ". "requiresConduit() to return true."); } return $this->conduit; } public function setArcanistConfiguration($arcanist_configuration) { $this->arcanistConfiguration = $arcanist_configuration; return $this; } public function getArcanistConfiguration() { return $this->arcanistConfiguration; } + public function getCommandSynopses() { + return get_class($this).": Undocumented"; + } + public function getCommandHelp() { return get_class($this).": Undocumented"; } public function requiresWorkingCopy() { return false; } public function requiresRepositoryAPI() { return false; } public function setCommand($command) { $this->command = $command; return $this; } public function getCommand() { return $this->command; } public function getArguments() { return array(); } public function setWorkingDirectory($working_directory) { $this->workingDirectory = $working_directory; return $this; } public function getWorkingDirectory() { return $this->workingDirectory; } private function setParentWorkflow($parent_workflow) { $this->parentWorkflow = $parent_workflow; return $this; } protected function getParentWorkflow() { return $this->parentWorkflow; } public function buildChildWorkflow($command, array $argv) { $arc_config = $this->getArcanistConfiguration(); $workflow = $arc_config->buildWorkflow($command); $workflow->setParentWorkflow($this); $workflow->setCommand($command); if ($this->repositoryAPI) { $workflow->setRepositoryAPI($this->repositoryAPI); } if ($this->userPHID) { $workflow->userPHID = $this->getUserPHID(); $workflow->userName = $this->getUserName(); } if ($this->conduit) { $workflow->conduit = $this->conduit; } if ($this->workingCopy) { $workflow->setWorkingCopy($this->workingCopy); } $workflow->setArcanistConfiguration($arc_config); $workflow->parseArguments(array_values($argv)); return $workflow; } public function getArgument($key, $default = null) { $args = $this->arguments; if (!array_key_exists($key, $args)) { return $default; } return $args[$key]; } final public function getCompleteArgumentSpecification() { $spec = $this->getArguments(); $arc_config = $this->getArcanistConfiguration(); $command = $this->getCommand(); $spec += $arc_config->getCustomArgumentsForCommand($command); return $spec; } public function parseArguments(array $args) { $spec = $this->getCompleteArgumentSpecification(); $dict = array(); $more_key = null; if (!empty($spec['*'])) { $more_key = $spec['*']; unset($spec['*']); $dict[$more_key] = array(); } $short_to_long_map = array(); foreach ($spec as $long => $options) { if (!empty($options['short'])) { $short_to_long_map[$options['short']] = $long; } } $more = array(); for ($ii = 0; $ii < count($args); $ii++) { $arg = $args[$ii]; $arg_name = null; $arg_key = null; if ($arg == '--') { $more = array_merge( $more, array_slice($args, $ii + 1)); break; } else if (!strncmp($arg, '--', 2)) { $arg_key = substr($arg, 2); if (!array_key_exists($arg_key, $spec)) { throw new ArcanistUsageException( "Unknown argument '{$arg_key}'. Try 'arc help'."); } } else if (!strncmp($arg, '-', 1)) { $arg_key = substr($arg, 1); if (empty($short_to_long_map[$arg_key])) { throw new ArcanistUsageException( "Unknown argument '{$arg_key}'. Try 'arc help'."); } $arg_key = $short_to_long_map[$arg_key]; } else { $more[] = $arg; continue; } $options = $spec[$arg_key]; if (empty($options['param'])) { $dict[$arg_key] = true; } else { if ($ii == count($args) - 1) { throw new ArcanistUsageException( "Option '{$arg}' requires a parameter."); } $dict[$arg_key] = $args[$ii + 1]; $ii++; } } if ($more) { if ($more_key) { $dict[$more_key] = $more; } else { $example = reset($more); throw new ArcanistUsageException( "Unrecognized argument '{$example}'. Try 'arc help'."); } } foreach ($dict as $key => $value) { if (empty($spec[$key]['conflicts'])) { continue; } foreach ($spec[$key]['conflicts'] as $conflict => $more) { if (isset($dict[$conflict])) { if ($more) { $more = ': '.$more; } else { $more = '.'; } // TODO: We'll always display these as long-form, when the user might // have typed them as short form. throw new ArcanistUsageException( "Arguments '--{$key}' and '--{$conflict}' are mutually exclusive". $more); } } } $this->arguments = $dict; $this->didParseArguments(); return $this; } protected function didParseArguments() { // Override this to customize workflow argument behavior. } public function getWorkingCopy() { if (!$this->workingCopy) { $workflow = get_class($this); throw new Exception( "This workflow ('{$workflow}') requires a working copy, override ". "requiresWorkingCopy() to return true."); } return $this->workingCopy; } public function setWorkingCopy( ArcanistWorkingCopyIdentity $working_copy) { $this->workingCopy = $working_copy; return $this; } public function setRepositoryAPI($api) { $this->repositoryAPI = $api; return $this; } public function getRepositoryAPI() { if (!$this->repositoryAPI) { $workflow = get_class($this); throw new Exception( "This workflow ('{$workflow}') requires a Repository API, override ". "requiresRepositoryAPI() to return true."); } return $this->repositoryAPI; } protected function shouldRequireCleanUntrackedFiles() { return empty($this->arguments['allow-untracked']); } public function requireCleanWorkingCopy() { $api = $this->getRepositoryAPI(); $working_copy_desc = phutil_console_format( " Working copy: __%s__\n\n", $api->getPath()); $untracked = $api->getUntrackedChanges(); if ($this->shouldRequireCleanUntrackedFiles()) { // Exempt ".arc/" scratch files from this warning so that things work // a little more smoothly if no one has gotten around to adding .arc to // the ignore list. foreach ($untracked as $key => $path) { if (preg_match('@\.arc/@', $path)) { unset($untracked[$key]); } } if (!empty($untracked)) { echo "You have untracked files in this working copy.\n\n". $working_copy_desc. " Untracked files in working copy:\n". " ".implode("\n ", $untracked)."\n\n"; if ($api instanceof ArcanistGitAPI) { echo phutil_console_wrap( "Since you don't have '.gitignore' rules for these files and have ". "not listed them in '.git/info/exclude', you may have forgotten ". "to 'git add' them to your commit."); } else if ($api instanceof ArcanistSubversionAPI) { echo phutil_console_wrap( "Since you don't have 'svn:ignore' rules for these files, you may ". "have forgotten to 'svn add' them."); } else if ($api instanceof ArcanistMercurialAPI) { echo phutil_console_wrap( "Since you don't have '.hgignore' rules for these files, you ". "may have forgotten to 'hg add' them to your commit."); } $prompt = "Do you want to continue without adding these files?"; if (!phutil_console_confirm($prompt, $default_no = false)) { throw new ArcanistUserAbortException(); } } } $incomplete = $api->getIncompleteChanges(); if ($incomplete) { throw new ArcanistUsageException( "You have incompletely checked out directories in this working copy. ". "Fix them before proceeding.\n\n". $working_copy_desc. " Incomplete directories in working copy:\n". " ".implode("\n ", $incomplete)."\n\n". "You can fix these paths by running 'svn update' on them."); } $conflicts = $api->getMergeConflicts(); if ($conflicts) { throw new ArcanistUsageException( "You have merge conflicts in this working copy. Resolve merge ". "conflicts before proceeding.\n\n". $working_copy_desc. " Conflicts in working copy:\n". " ".implode("\n ", $conflicts)."\n"); } $unstaged = $api->getUnstagedChanges(); if ($unstaged) { throw new ArcanistUsageException( "You have unstaged changes in this working copy. Stage and commit (or ". "revert) them before proceeding.\n\n". $working_copy_desc. " Unstaged changes in working copy:\n". " ".implode("\n ", $unstaged)."\n"); } $uncommitted = $api->getUncommittedChanges(); if ($uncommitted) { throw new ArcanistUsageException( "You have uncommitted changes in this branch. Commit (or revert) them ". "before proceeding.\n\n". $working_copy_desc. " Uncommitted changes in working copy\n". " ".implode("\n ", $uncommitted)."\n"); } } protected function chooseRevision( array $revision_data, $revision_id, $prompt = null) { $revisions = array(); foreach ($revision_data as $data) { $ref = ArcanistDifferentialRevisionRef::newFromDictionary($data); $revisions[$ref->getID()] = $ref; } if ($revision_id) { $revision_id = $this->normalizeRevisionID($revision_id); if (empty($revisions[$revision_id])) { throw new ArcanistChooseInvalidRevisionException(); } return $revisions[$revision_id]; } if (!count($revisions)) { throw new ArcanistChooseNoRevisionsException(); } $repository_api = $this->getRepositoryAPI(); $candidates = $revisions; if (count($candidates) == 1) { $candidate = reset($candidates); $revision_id = $candidate->getID(); } if ($revision_id) { return $revisions[$revision_id]; } $revision_indexes = array_keys($revisions); echo "\n"; $ii = 1; foreach ($revisions as $revision) { echo ' ['.$ii++.'] D'.$revision->getID().' '.$revision->getName()."\n"; } while (true) { $id = phutil_console_prompt($prompt); $id = trim(strtoupper($id), 'D'); if (isset($revisions[$id])) { return $revisions[$id]; } if (isset($revision_indexes[$id - 1])) { return $revisions[$revision_indexes[$id - 1]]; } } } protected function loadDiffBundleFromConduit( ConduitClient $conduit, $diff_id) { return $this->loadBundleFromConduit( $conduit, array( 'diff_id' => $diff_id, )); } protected function loadRevisionBundleFromConduit( ConduitClient $conduit, $revision_id) { return $this->loadBundleFromConduit( $conduit, array( 'revision_id' => $revision_id, )); } private function loadBundleFromConduit( ConduitClient $conduit, $params) { $future = $conduit->callMethod('differential.getdiff', $params); $diff = $future->resolve(); $changes = array(); foreach ($diff['changes'] as $changedict) { $changes[] = ArcanistDiffChange::newFromDictionary($changedict); } $bundle = ArcanistBundle::newFromChanges($changes); $bundle->setConduit($conduit); $bundle->setProjectID($diff['projectName']); $bundle->setBaseRevision($diff['sourceControlBaseRevision']); $bundle->setRevisionID($diff['revisionID']); return $bundle; } /** * Return a list of lines changed by the current diff, or ##null## if the * change list is meaningless (for example, because the path is a directory * or binary file). * * @param string Path within the repository. * @param string Change selection mode (see ArcanistDiffHunk). * @return list|null List of changed line numbers, or null to indicate that * the path is not a line-oriented text file. */ protected function getChangedLines($path, $mode) { $repository_api = $this->getRepositoryAPI(); $full_path = $repository_api->getPath($path); if (is_dir($full_path)) { return null; } $change = $this->getChange($path); if ($change->getFileType() !== ArcanistDiffChangeType::FILE_TEXT) { return null; } $lines = $change->getChangedLines($mode); return array_keys($lines); } private function getChange($path) { $repository_api = $this->getRepositoryAPI(); if ($repository_api instanceof ArcanistSubversionAPI) { // NOTE: In SVN, we don't currently support a "get all local changes" // operation, so special case it. if (empty($this->changeCache[$path])) { $diff = $repository_api->getRawDiffText($path); $parser = new ArcanistDiffParser(); $changes = $parser->parseDiff($diff); if (count($changes) != 1) { throw new Exception("Expected exactly one change."); } $this->changeCache[$path] = reset($changes); } } else if ($repository_api->supportsRelativeLocalCommits()) { if (empty($this->changeCache)) { $changes = $repository_api->getAllLocalChanges(); foreach ($changes as $change) { $this->changeCache[$change->getCurrentPath()] = $change; } } } else { throw new Exception("Missing VCS support."); } if (empty($this->changeCache[$path])) { if ($repository_api instanceof ArcanistGitAPI) { // This can legitimately occur under git if you make a change, "git // commit" it, and then revert the change in the working copy and run // "arc lint". $change = new ArcanistDiffChange(); $change->setCurrentPath($path); return $change; } else { throw new Exception( "Trying to get change for unchanged path '{$path}'!"); } } return $this->changeCache[$path]; } final public function willRunWorkflow() { $spec = $this->getCompleteArgumentSpecification(); foreach ($this->arguments as $arg => $value) { if (empty($spec[$arg])) { continue; } $options = $spec[$arg]; if (!empty($options['supports'])) { $system_name = $this->getRepositoryAPI()->getSourceControlSystemName(); if (!in_array($system_name, $options['supports'])) { $extended_info = null; if (!empty($options['nosupport'][$system_name])) { $extended_info = ' '.$options['nosupport'][$system_name]; } throw new ArcanistUsageException( "Option '--{$arg}' is not supported under {$system_name}.". $extended_info); } } } } protected function normalizeRevisionID($revision_id) { return ltrim(strtoupper($revision_id), 'D'); } protected function shouldShellComplete() { return true; } protected function getShellCompletions(array $argv) { return array(); } protected function getSupportedRevisionControlSystems() { return array('any'); } protected function getPassthruArgumentsAsMap($command) { $map = array(); foreach ($this->getCompleteArgumentSpecification() as $key => $spec) { if (!empty($spec['passthru'][$command])) { if (isset($this->arguments[$key])) { $map[$key] = $this->arguments[$key]; } } } return $map; } protected function getPassthruArgumentsAsArgv($command) { $spec = $this->getCompleteArgumentSpecification(); $map = $this->getPassthruArgumentsAsMap($command); $argv = array(); foreach ($map as $key => $value) { $argv[] = '--'.$key; if (!empty($spec[$key]['param'])) { $argv[] = $value; } } return $argv; } public static function getUserConfigurationFileLocation() { - return getenv('HOME').'/.arcrc'; + if (phutil_is_windows()) { + return getenv('APPDATA').'/.arcrc'; + } else { + return getenv('HOME').'/.arcrc'; + } } public static function readUserConfigurationFile() { $user_config = array(); $user_config_path = self::getUserConfigurationFileLocation(); if (Filesystem::pathExists($user_config_path)) { - $mode = fileperms($user_config_path); - if (!$mode) { - throw new Exception("Unable to get perms of '{$user_config_path}'!"); - } - if ($mode & 0177) { - // Mode should allow only owner access. - $prompt = "File permissions on your ~/.arcrc are too open. ". - "Fix them by chmod'ing to 600?"; - if (!phutil_console_confirm($prompt, $default_no = false)) { - throw new ArcanistUsageException("Set ~/.arcrc to file mode 600."); + + if (!phutil_is_windows()) { + $mode = fileperms($user_config_path); + if (!$mode) { + throw new Exception("Unable to get perms of '{$user_config_path}'!"); + } + if ($mode & 0177) { + // Mode should allow only owner access. + $prompt = "File permissions on your ~/.arcrc are too open. ". + "Fix them by chmod'ing to 600?"; + if (!phutil_console_confirm($prompt, $default_no = false)) { + throw new ArcanistUsageException("Set ~/.arcrc to file mode 600."); + } + execx('chmod 600 %s', $user_config_path); } - execx('chmod 600 %s', $user_config_path); } $user_config_data = Filesystem::readFile($user_config_path); $user_config = json_decode($user_config_data, true); if (!is_array($user_config)) { throw new ArcanistUsageException( "Your '~/.arcrc' file is not a valid JSON file."); } } return $user_config; } public static function writeUserConfigurationFile($config) { $json_encoder = new PhutilJSON(); $json = $json_encoder->encodeFormatted($config); $path = self::getUserConfigurationFileLocation(); Filesystem::writeFile($path, $json); - execx('chmod 600 %s', $path); + + if (!phutil_is_windows()) { + execx('chmod 600 %s', $path); + } } /** * Write a message to stderr so that '--json' flags or stdout which is meant * to be piped somewhere aren't disrupted. * * @param string Message to write to stderr. * @return void */ protected function writeStatusMessage($msg) { file_put_contents('php://stderr', $msg); } protected function isHistoryImmutable() { $working_copy = $this->getWorkingCopy(); return ($working_copy->getConfig('immutable_history') === true); } /** * Workflows like 'lint' and 'unit' operate on a list of working copy paths. * The user can either specify the paths explicitly ("a.js b.php"), or by * specfifying a revision ("--rev a3f10f1f") to select all paths modified * since that revision, or by omitting both and letting arc choose the * default relative revision. * * This method takes the user's selections and returns the paths that the * workflow should act upon. * * @param list List of explicitly provided paths. * @param string|null Revision name, if provided. * @return list List of paths the workflow should act on. */ protected function selectPathsForWorkflow(array $paths, $rev) { if ($paths) { $working_copy = $this->getWorkingCopy(); foreach ($paths as $key => $path) { $full_path = Filesystem::resolvePath($path); if (!Filesystem::pathExists($full_path)) { throw new ArcanistUsageException("Path '{$path}' does not exist!"); } $relative_path = Filesystem::readablePath( $full_path, $working_copy->getProjectRoot()); $paths[$key] = $relative_path; } } else { $repository_api = $this->getRepositoryAPI(); if ($rev) { $repository_api->parseRelativeLocalCommit(array($rev)); } $paths = $repository_api->getWorkingCopyStatus(); foreach ($paths as $path => $flags) { if ($flags & ArcanistRepositoryAPI::FLAG_UNTRACKED) { unset($paths[$path]); } } $paths = array_keys($paths); } return array_values($paths); } protected function renderRevisionList(array $revisions) { $list = array(); foreach ($revisions as $revision) { $list[] = ' - D'.$revision['id'].': '.$revision['title']."\n"; } return implode('', $list); } /* -( Scratch Files )------------------------------------------------------ */ /** * Try to read a scratch file, if it exists and is readable. * * @param string Scratch file name. * @return mixed String for file contents, or false for failure. * @task scratch */ protected function readScratchFile($path) { $full_path = $this->getScratchFilePath($path); if (!$full_path) { return false; } if (!Filesystem::pathExists($full_path)) { return false; } try { $result = Filesystem::readFile($full_path); } catch (FilesystemException $ex) { return false; } return $result; } /** * Try to write a scratch file, if there's somewhere to put it and we can * write there. * * @param string Scratch file name to write. * @param string Data to write. * @return bool True on success, false on failure. * @task scratch */ protected function writeScratchFile($path, $data) { $dir = $this->getScratchFilePath(''); if (!$dir) { return false; } if (!Filesystem::pathExists($dir)) { try { execx('mkdir %s', $dir); } catch (Exception $ex) { return false; } } try { Filesystem::writeFile($this->getScratchFilePath($path), $data); } catch (FilesystemException $ex) { return false; } return true; } /** * Try to remove a scratch file. * * @param string Scratch file name to remove. * @return bool True if the file was removed successfully. * @task scratch */ protected function removeScratchFile($path) { $full_path = $this->getScratchFilePath($path); if (!$full_path) { return false; } try { Filesystem::remove($full_path); } catch (FilesystemException $ex) { return false; } return true; } /** * Get a human-readable description of the scratch file location. * * @param string Scratch file name. * @return mixed String, or false on failure. * @task scratch */ protected function getReadableScratchFilePath($path) { $full_path = $this->getScratchFilePath($path); if ($full_path) { return Filesystem::readablePath( $full_path, $this->getRepositoryAPI()->getPath()); } else { return false; } } /** * Get the path to a scratch file, if possible. * * @param string Scratch file name. * @return mixed File path, or false on failure. * @task scratch */ protected function getScratchFilePath($path) { if (!$this->repositoryAPI) { return false; } $repository_api = $this->getRepositoryAPI(); return $repository_api->getPath('.arc/'.$path); } } diff --git a/src/workflow/branch/ArcanistBranchWorkflow.php b/src/workflow/branch/ArcanistBranchWorkflow.php index e46306be..8731f520 100644 --- a/src/workflow/branch/ArcanistBranchWorkflow.php +++ b/src/workflow/branch/ArcanistBranchWorkflow.php @@ -1,169 +1,175 @@ array( 'help' => "Include committed and abandoned revisions", ), 'by-status' => array( 'help' => 'Group output by revision status.', ), ); } public function run() { $repository_api = $this->getRepositoryAPI(); if (!($repository_api instanceof ArcanistGitAPI)) { throw new ArcanistUsageException( "arc branch is only supported under git." ); } $this->branches = BranchInfo::loadAll($repository_api); $all_revisions = array_unique( array_filter(mpull($this->branches, 'getRevisionId'))); $revision_status = $this->loadDifferentialStatuses($all_revisions); $owner = $repository_api->getRepositoryOwner(); foreach ($this->branches as $branch) { if ($branch->getCommitAuthor() != $owner) { $branch->setStatus('Not Yours'); continue; } $rev_id = $branch->getRevisionID(); if ($rev_id) { $status = idx($revision_status, $rev_id, 'Unknown Status'); $branch->setStatus($status); } else { $branch->setStatus('No Revision'); } } if (!$this->getArgument('view-all')) { $this->filterOutFinished(); } $this->printInColumns(); } /** * Makes a conduit call to differential to find out revision statuses * based on their IDs */ private function loadDifferentialStatuses($rev_ids) { $conduit = $this->getConduit(); $revisions = $conduit->callMethodSynchronous( 'differential.query', array( 'ids' => $rev_ids, )); $statuses = ipull($revisions, 'statusName', 'id'); return $statuses; } /** * Removes the branches with status either committed or abandoned. */ private function filterOutFinished() { foreach ($this->branches as $id => $branch) { if ($branch->isCurrentHead() ) { continue; //never filter the current branch } $status = $branch->getStatus(); if ($status == 'Committed' || $status == 'Abandoned') { unset($this->branches[$id]); } } } public function printInColumns() { $longest_name = 0; $longest_status = 0; foreach ($this->branches as $branch) { $longest_name = max(strlen($branch->getFormattedName()), $longest_name); $longest_status = max(strlen($branch->getStatus()), $longest_status); } if ($this->getArgument('by-status')) { $by_status = mgroup($this->branches, 'getStatus'); foreach (array('Accepted', 'Needs Revision', 'Needs Review', 'No Revision') as $status) { $branches = idx($by_status, $status); if (!$branches) { continue; } echo reset($branches)->getFormattedStatus()."\n"; foreach ($branches as $branch) { $name_markdown = $branch->getFormattedName(); $subject = $branch->getCommitDisplayName(); $name_markdown = str_pad($name_markdown, $longest_name + 4, ' '); echo " $name_markdown $subject\n"; } } } else { foreach ($this->branches as $branch) { $name_markdown = $branch->getFormattedName(); $status_markdown = $branch->getFormattedStatus(); $subject = $branch->getCommitDisplayName(); $subject_pad = $longest_status - strlen($branch->getStatus()) + 4; $name_markdown = str_pad($name_markdown, $longest_name + 4, ' '); $subject = str_pad($subject, strlen($subject) + $subject_pad, ' ', STR_PAD_LEFT); echo "$name_markdown $status_markdown $subject\n"; } } } } diff --git a/src/workflow/call-conduit/ArcanistCallConduitWorkflow.php b/src/workflow/call-conduit/ArcanistCallConduitWorkflow.php index 713e4840..adfea16a 100644 --- a/src/workflow/call-conduit/ArcanistCallConduitWorkflow.php +++ b/src/workflow/call-conduit/ArcanistCallConduitWorkflow.php @@ -1,99 +1,104 @@ 'method', ); } public function shouldShellComplete() { return false; } public function requiresConduit() { return true; } public function requiresAuthentication() { return true; } public function run() { $method = $this->getArgument('method', array()); if (count($method) !== 1) { throw new ArcanistUsageException( "Provide exactly one Conduit method name."); } $method = reset($method); $params = @file_get_contents('php://stdin'); $params = json_decode($params, true); if (!is_array($params)) { throw new ArcanistUsageException( "Provide method parameters on stdin as a JSON blob."); } $error = null; $error_message = null; try { $result = $this->getConduit()->callMethodSynchronous( $method, $params); } catch (ConduitClientException $ex) { $error = $ex->getErrorCode(); $error_message = $ex->getMessage(); $result = null; } echo json_encode(array( 'error' => $error, 'errorMessage' => $error_message, 'response' => $result, ))."\n"; return 0; } } diff --git a/src/workflow/commit/ArcanistCommitWorkflow.php b/src/workflow/commit/ArcanistCommitWorkflow.php index dca10af6..04e8acdd 100644 --- a/src/workflow/commit/ArcanistCommitWorkflow.php +++ b/src/workflow/commit/ArcanistCommitWorkflow.php @@ -1,342 +1,348 @@ revisionID; } public function getArguments() { return array( 'show' => array( 'help' => "Show the command which would be issued, but do not actually ". "commit anything." ), 'revision' => array( 'param' => 'revision_id', 'help' => "Commit a specific revision. If you do not specify a revision, ". "arc will look for committable revisions.", ) ); } public function run() { $repository_api = $this->getRepositoryAPI(); if (!($repository_api instanceof ArcanistSubversionAPI)) { throw new ArcanistUsageException( "'arc commit' is only supported under svn."); } $revision_id = $this->normalizeRevisionID($this->getArgument('revision')); if (!$revision_id) { $revisions = $repository_api->loadWorkingCopyDifferentialRevisions( $this->getConduit(), array( 'authors' => array($this->getUserPHID()), 'status' => 'status-accepted', )); if (count($revisions) == 0) { throw new ArcanistUsageException( "Unable to identify the revision in the working copy. Use ". "'--revision ' to select a revision."); } else if (count($revisions) > 1) { throw new ArcanistUsageException( "More than one revision exists in the working copy:\n\n". $this->renderRevisionList($revisions)."\n". "Use '--revision ' to select a revision."); } } else { $revisions = $this->getConduit()->callMethodSynchronous( 'differential.query', array( 'ids' => array($revision_id), )); if (count($revisions) == 0) { throw new ArcanistUsageException( "Revision 'D{$revision_id}' does not exist."); } } $revision = head($revisions); $this->revisionID = $revision['id']; $revision_id = $revision['id']; $this->runSanityChecks($revision); $message = $this->getConduit()->callMethodSynchronous( 'differential.getcommitmessage', array( 'revision_id' => $revision_id, 'edit' => false, )); $event = new PhutilEvent( ArcanistEventType::TYPE_COMMIT_WILLCOMMITSVN, array( 'message' => $message, 'workflow' => $this, ) ); PhutilEventEngine::dispatchEvent($event); $message = $event->getValue('message'); if ($this->getArgument('show')) { echo $message; return 0; } $revision_title = $revision['title']; echo "Committing 'D{$revision_id}: {$revision_title}'...\n"; $files = $this->getCommitFileList($revision); $files = implode(' ', array_map('escapeshellarg', $files)); $message = escapeshellarg($message); $root = escapeshellarg($repository_api->getPath()); $lang = $this->getSVNLangEnvVar(); // Specify LANG explicitly so that UTF-8 commit messages don't break // subversion. $command = "(cd {$root} && LANG={$lang} svn commit {$files} -m {$message})"; $err = phutil_passthru('%C', $command); if ($err) { throw new Exception("Executing 'svn commit' failed!"); } $mark_workflow = $this->buildChildWorkflow( 'mark-committed', array( '--finalize', $revision_id, )); $mark_workflow->run(); return $err; } protected function getCommitFileList(array $revision) { $repository_api = $this->getRepositoryAPI(); $revision_id = $revision['id']; $commit_paths = $this->getConduit()->callMethodSynchronous( 'differential.getcommitpaths', array( 'revision_id' => $revision_id, )); $dir_paths = array(); foreach ($commit_paths as $path) { $path = dirname($path); while ($path != '.') { $dir_paths[$path] = true; $path = dirname($path); } } $commit_paths = array_fill_keys($commit_paths, true); $status = $repository_api->getSVNStatus(); $modified_but_not_included = array(); foreach ($status as $path => $mask) { if (!empty($dir_paths[$path])) { $commit_paths[$path] = true; } if (!empty($commit_paths[$path])) { continue; } foreach ($commit_paths as $will_commit => $ignored) { if (Filesystem::isDescendant($path, $will_commit)) { throw new ArcanistUsageException( "This commit includes the directory '{$will_commit}', but ". "it contains a modified path ('{$path}') which is NOT included ". "in the commit. Subversion can not handle this operation and ". "will commit the path anyway. You need to sort out the working ". "copy changes to '{$path}' before you may proceed with the ". "commit."); } } $modified_but_not_included[] = $path; } if ($modified_but_not_included) { if (count($modified_but_not_included) == 1) { $prefix = "A locally modified path is not included in this revision:"; $prompt = "It will NOT be committed. Commit this revision anyway?"; } else { $prefix = "Locally modified paths are not included in this revision:"; $prompt = "They will NOT be committed. Commit this revision anyway?"; } $this->promptFileWarning($prefix, $prompt, $modified_but_not_included); } $do_not_exist = array(); foreach ($commit_paths as $path => $ignored) { $disk_path = $repository_api->getPath($path); if (file_exists($disk_path)) { continue; } if (is_link($disk_path)) { continue; } if (idx($status, $path) & ArcanistRepositoryAPI::FLAG_DELETED) { continue; } $do_not_exist[] = $path; unset($commit_paths[$path]); } if ($do_not_exist) { if (count($do_not_exist) == 1) { $prefix = "Revision includes changes to a path that does not exist:"; $prompt = "Commit this revision anyway?"; } else { $prefix = "Revision includes changes to paths that do not exist:"; $prompt = "Commit this revision anyway?"; } $this->promptFileWarning($prefix, $prompt, $do_not_exist); } $files = array_keys($commit_paths); if (empty($files)) { throw new ArcanistUsageException( "There is nothing left to commit. None of the modified paths exist."); } return $files; } protected function promptFileWarning($prefix, $prompt, array $paths) { echo $prefix."\n\n"; foreach ($paths as $path) { echo " ".$path."\n"; } if (!phutil_console_confirm($prompt)) { throw new ArcanistUserAbortException(); } } protected function getSupportedRevisionControlSystems() { return array('svn'); } /** * On some systems, we need to specify "en_US.UTF-8" instead of "en_US.utf8", * and SVN spews some bewildering warnings if we don't: * * svn: warning: cannot set LC_CTYPE locale * svn: warning: environment variable LANG is en_US.utf8 * svn: warning: please check that your locale name is correct * * For example, is happens on my 10.6.7 machine with Subversion 1.6.15. */ private function getSVNLangEnvVar() { $locale = 'en_US.utf8'; try { list($locales) = execx('locale -a'); $locales = explode("\n", trim($locales)); $locales = array_fill_keys($locales, true); if (isset($locales['en_US.UTF-8'])) { $locale = 'en_US.UTF-8'; } } catch (Exception $ex) { // Ignore. } return $locale; } private function runSanityChecks(array $revision) { $repository_api = $this->getRepositoryAPI(); $revision_id = $revision['id']; $revision_title = $revision['title']; $confirm = array(); if ($revision['status'] != ArcanistDifferentialRevisionStatus::ACCEPTED) { $confirm[] = "Revision 'D{$revision_id}: {$revision_title}' has not been accepted. ". "Commit this revision anyway?"; } if ($revision['authorPHID'] != $this->getUserPHID()) { $confirm[] = "You are not the author of 'D{$revision_id}: {$revision_title}'. ". "Commit this revision anyway?"; } $revision_source = $revision['sourcePath']; $current_source = $repository_api->getPath(); if ($revision_source != $current_source) { $confirm[] = "Revision 'D{$revision_id}: {$revision_title}' was generated from ". "'{$revision_source}', but current working copy root is ". "'{$current_source}'. Commit this revision anyway?"; } foreach ($confirm as $thing) { if (!phutil_console_confirm($thing)) { throw new ArcanistUserAbortException(); } } } } diff --git a/src/workflow/cover/ArcanistCoverWorkflow.php b/src/workflow/cover/ArcanistCoverWorkflow.php index 6413a8be..69afd21a 100644 --- a/src/workflow/cover/ArcanistCoverWorkflow.php +++ b/src/workflow/cover/ArcanistCoverWorkflow.php @@ -1,158 +1,164 @@ getRepositoryAPI(); $paths = $repository_api->getWorkingCopyStatus(); foreach ($paths as $path => $status) { if (is_dir($path)) { unset($paths[$path]); } if ($status & ArcanistRepositoryAPI::FLAG_UNTRACKED) { unset($paths[$path]); } if ($status & ArcanistRepositoryAPI::FLAG_ADDED) { unset($paths[$path]); } } $paths = array_keys($paths); if (!$paths) { throw new ArcanistNoEffectException( "You're covered, you didn't change anything."); } $covers = array(); foreach ($paths as $path) { $lines = $this->getChangedLines($path, 'cover'); if (!$lines) { continue; } $blame = $repository_api->getBlame($path); foreach ($lines as $line) { list($author, $revision) = idx($blame, $line, array(null, null)); if (!$author) { continue; } if (!isset($covers[$author])) { $covers[$author] = array(); } if (!isset($covers[$author][$path])) { $covers[$author][$path] = array( 'lines' => array(), 'revisions' => array(), ); } $covers[$author][$path]['lines'][] = $line; $covers[$author][$path]['revisions'][] = $revision; } } if (count($covers)) { foreach ($covers as $author => $files) { echo phutil_console_format( "**%s**\n", $author); foreach ($files as $file => $info) { $line_noun = count($info['lines']) == 1 ? 'line' : 'lines'; $lines = $this->readableSequenceFromLineNumbers($info['lines']); echo " {$file}: {$line_noun} {$lines}\n"; } } } else { echo "You're covered, your changes didn't touch anyone else's code.\n"; } return 0; } private function readableSequenceFromLineNumbers(array $array) { $sequence = array(); $last = null; $seq = null; $array = array_unique(array_map('intval', $array)); sort($array); foreach ($array as $element) { if ($seq !== null && $element == ($seq + 1)) { $seq++; continue; } if ($seq === null) { $last = $element; $seq = $element; continue; } if ($seq > $last) { $sequence[] = $last.'-'.$seq; } else { $sequence[] = $last; } $last = $element; $seq = $element; } if ($last !== null && $seq > $last) { $sequence[] = $last.'-'.$seq; } else if ($last !== null) { $sequence[] = $element; } return implode(', ', $sequence); } } diff --git a/src/workflow/diff/ArcanistDiffWorkflow.php b/src/workflow/diff/ArcanistDiffWorkflow.php index fbf26e8b..b650868e 100644 --- a/src/workflow/diff/ArcanistDiffWorkflow.php +++ b/src/workflow/diff/ArcanistDiffWorkflow.php @@ -1,2012 +1,2017 @@ isRawDiffSource(); } public function requiresConduit() { return true; } public function requiresAuthentication() { return true; } public function requiresRepositoryAPI() { return !$this->isRawDiffSource(); } public function getDiffID() { return $this->diffID; } public function getArguments() { return array( 'message' => array( 'short' => 'm', 'supports' => array( 'git', ), 'nosupport' => array( 'svn' => 'Edit revisions via the web interface when using SVN.', ), 'param' => 'message', 'help' => "When updating a revision under git, 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', ), '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.", ), 'auto' => array( 'help' => "(Unstable!) Heuristically select --create or --update. ". "This may become the default behvaior of arc.", 'conflicts' => array( 'raw', ), ), '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( 'supports' => array( 'git', ), 'nosupport' => array( 'svn' => 'Revisions are never created directly when using SVN.', ), '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.', ), ), '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' => "Raise lint advice in addition to lint warnings and errors.", '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, ), ), 'json' => array( 'help' => 'Emit machine-readable JSON. EXPERIMENTAL! Probably does not work!', ), 'no-amend' => array( 'help' => 'Never amend commits in the working copy.', ), '*' => 'paths', ); } public function isRawDiffSource() { return $this->getArgument('raw') || $this->getArgument('raw-command'); } public function run() { $this->runDiffSetupBasics(); $paths = $this->generateAffectedPaths(); // Do this before we start linting or running unit tests so we can detect // things like a missing test plan or invalid reviewers immediately. $commit_message = $this->buildCommitMessage(); $lint_result = $this->runLint($paths); $unit_result = $this->runUnit($paths); $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']; if ($this->unitWorkflow) { $this->unitWorkflow->setDifferentialDiffID($diff_info['diffid']); } $this->updateLintDiffProperty(); $this->updateUnitDiffProperty(); $this->updateLocalDiffProperty(); $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(); } } else { $message = $commit_message; $revision = array( 'diffid' => $this->getDiffID(), 'fields' => $message->getFields(), ); if ($message->getRevisionID()) { // TODO: This is silly -- we're getting a text corpus from the server // and then sending it right back to be parsed. This should be a // single call. $remote_corpus = $conduit->callMethodSynchronous( 'differential.getcommitmessage', array( 'revision_id' => $message->getRevisionID(), 'edit' => true, 'fields' => array(), )); $remote_message = ArcanistDifferentialCommitMessage::newFromRawCorpus( $remote_corpus); $remote_message->pullDataFromConduit($conduit); $sync = array('title', 'summary', 'testPlan'); foreach ($sync as $field) { $local = $message->getFieldValue($field); $remote_message->setFieldValue($field, $local); } $should_edit = $this->getArgument('edit'); /* TODO: This is a complicated mess. We need to move to storing a checksum of the non-auto-sync fields as they existed at original diff time and using changes from that to detect user edits, not comparison of the client and server values since they diverge without user edits (because of Herald and explicit server-side user changes). if (!$should_edit) { $local_sum = $message->getChecksum(); $remote_sum = $remote_message->getChecksum(); if ($local_sum != $remote_sum) { $prompt = "You have made local changes to your commit message. Arcanist ". "ignores most local changes. Instead, use the '--edit' flag to ". "edit revision information. Edit revision information now?"; $should_edit = phutil_console_confirm( $prompt, $default_no = false); } } */ $revision['fields'] = $remote_message->getFields(); if ($should_edit) { $updated_corpus = $conduit->callMethodSynchronous( 'differential.getcommitmessage', array( 'revision_id' => $message->getRevisionID(), 'edit' => true, 'fields' => $message->getFields(), )); $new_text = id(new PhutilInteractiveEditor($updated_corpus)) ->setName('differential-edit-revision-info') ->editInteractively(); $new_message = ArcanistDifferentialCommitMessage::newFromRawCorpus( $new_text); $new_message->pullDataFromConduit($conduit); $revision['fields'] = $new_message->getFields(); } $revision['id'] = $message->getRevisionID(); $this->revisionID = $revision['id']; $update_message = $this->getUpdateMessage(); $revision['message'] = $update_message; $future = $conduit->callMethod( 'differential.updaterevision', $revision); $result = $future->resolve(); echo "Updated an existing Differential revision:\n"; } else { $revision['user'] = $this->getUserPHID(); $future = $conduit->callMethod( 'differential.createrevision', $revision); $result = $future->resolve(); $revised_message = $conduit->callMethodSynchronous( 'differential.getcommitmessage', array( 'revision_id' => $result['revisionid'], )); if ($this->requiresRepositoryAPI()) { $repository_api = $this->getRepositoryAPI(); if (($repository_api instanceof ArcanistGitAPI) && $this->shouldAmend()) { echo "Updating commit message...\n"; $repository_api->amendGitHeadCommit($revised_message); } } echo "Created a new Differential revision:\n"; } $uri = $result['uri']; echo phutil_console_format( " **Revision URI:** __%s__\n\n", $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 runDiffSetupBasics() { if ($this->requiresRepositoryAPI()) { $repository_api = $this->getRepositoryAPI(); if ($this->getArgument('less-context')) { $repository_api->setDiffLinesOfContext(3); } } $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()) { $this->requireCleanWorkingCopy(); } } protected function shouldOnlyCreateDiff() { if ($this->getArgument('create')) { return false; } if ($this->getArgument('update')) { return false; } if ($this->getArgument('auto')) { return false; } if ($this->getArgument('use-commit-message')) { return false; } if ($this->isRawDiffSource()) { return true; } $repository_api = $this->getRepositoryAPI(); if ($repository_api instanceof ArcanistSubversionAPI) { return true; } if ($repository_api instanceof ArcanistMercurialAPI) { return true; } if ($this->isHistoryImmutable()) { 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 if ($repository_api->supportsRelativeLocalCommits()) { $repository_api->parseRelativeLocalCommit( $this->getArgument('paths', array())); $paths = $repository_api->getWorkingCopyStatus(); } else { throw new Exception("Unknown VCS!"); } foreach ($paths as $path => $mask) { if ($mask & ArcanistRepositoryAPI::FLAG_UNTRACKED) { unset($paths[$path]); } } return $paths; } protected function generateChanges() { $parser = new ArcanistDiffParser(); $is_raw = $this->isRawDiffSource(); if ($is_raw) { if ($this->getArgument('raw')) { file_put_contents('php://stderr', "Reading diff from stdin...\n"); $raw_diff = file_get_contents('php://stdin'); } else if ($this->getArgument('raw-command')) { list($raw_diff) = execx($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(); 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(); $changes = $parser->parseDiff($diff); } else { throw new Exception("Repository API is not supported."); } if (count($changes) > 250) { $count = number_format(count($changes)); $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. 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 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(); } else { throw new ArcanistUsageException( "Aborted generation of gigantic diff."); } } } } $try_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) { $try_encoding = nonempty($this->getArgument('encoding'), null); if ($try_encoding === null) { // Make a call to check if there's an encoding specified for this // project. try { $project_info = $this->getConduit()->callMethodSynchronous( 'arcanist.projectinfo', array( 'name' => $this->getWorkingCopy()->getProjectID(), )); $try_encoding = nonempty($project_info['encoding'], false); } catch (ConduitClientException $e) { if ($e->getErrorCode() == 'ERR-BAD-ARCANIST-PROJECT') { echo phutil_console_wrap( "Lookup of encoding in arcanist project failed\n". $e->getMessage() ); $try_encoding = false; } else { throw $e; } } } if ($try_encoding) { // NOTE: This feature is HIGHLY EXPERIMENTAL and will cause a lot // of issues. Use it at your own risk. $corpus = mb_convert_encoding($corpus, 'UTF-8', $try_encoding); $name = $change->getCurrentPath(); if (phutil_is_utf8($corpus)) { $this->writeStatusMessage( "[Experimental] 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) { $learn_more = "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"; if (count($utf8_problems) == 1) { $utf8_warning = "This diff includes a file which is not valid UTF-8 (it has invalid ". "byte sequences). You can either stop this workflow and fix it, or ". "continue. If you continue, this file will be marked as binary.\n\n". $learn_more. " AFFECTED FILE\n"; $confirm = "Do you want to mark this file as binary and continue?"; } else { $utf8_warning = "This diff includes files 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.\n\n". $learn_more. " AFFECTED FILES\n"; $confirm = "Do you want to mark these files as binary and continue?"; } 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(); } } } foreach ($changes as $change) { if ($change->getFileType() != ArcanistDiffChangeType::FILE_BINARY) { continue; } $path = $change->getCurrentPath(); $name = basename($path); $old_file = $repository_api->getOriginalFileData($path); $old_dict = $this->uploadFile($old_file, $name, 'old binary'); if ($old_dict['guid']) { $change->setMetadata('old:binary-phid', $old_dict['guid']); } $change->setMetadata('old:file:size', $old_dict['size']); $change->setMetadata('old:file:mime-type', $old_dict['mime']); $new_file = $repository_api->getCurrentFileData($path); $new_dict = $this->uploadFile($new_file, $name, 'new binary'); if ($new_dict['guid']) { $change->setMetadata('new:binary-phid', $new_dict['guid']); } $change->setMetadata('new:file:size', $new_dict['size']); $change->setMetadata('new:file:mime-type', $new_dict['mime']); if (preg_match('@^image/@', $new_dict['mime'])) { $change->setFileType(ArcanistDiffChangeType::FILE_IMAGE); } } return $changes; } private function uploadFile($data, $name, $desc) { $result = array( 'guid' => null, 'mime' => null, 'size' => null ); $result['size'] = $size = strlen($data); if (!$size) { return $result; } $future = new ExecFuture('file -b --mime -'); $future->write($data); list($mime_type) = $future->resolvex(); $mime_type = trim($mime_type); $result['mime'] = $mime_type; echo "Uploading {$desc} '{$name}' ({$mime_type}, {$size} bytes)...\n"; try { $guid = $this->getConduit()->callMethodSynchronous( 'file.upload', array( 'data_base64' => base64_encode($data), 'name' => $name, )); $result['guid'] = $guid; } catch (ConduitClientException $e) { $message = "Failed to upload {$desc} '{$name}'. Continue?"; if (!phutil_console_confirm($message, $default_no = false)) { throw new ArcanistUsageException( 'Aborted due to file upload failure.' ); } } return $result; } private function getGitParentLogInfo() { $info = array( 'parent' => null, 'base_revision' => null, 'base_path' => null, 'uuid' => null, ); $conduit = $this->getConduit(); $repository_api = $this->getRepositoryAPI(); $parser = new ArcanistDiffParser(); $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. } } 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); } 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() { return !$this->isHistoryImmutable() && !$this->getArgument('no-amend'); } /* -( Lint and Unit Tests )------------------------------------------------ */ /** * @task lintunit */ private function runLint($paths) { if ($this->getArgument('nolint') || $this->getArgument('only') || $this->isRawDiffSource()) { return ArcanistLintWorkflow::RESULT_SKIP; } $repository_api = $this->getRepositoryAPI(); echo "Linting...\n"; try { $argv = $this->getPassthruArgumentsAsArgv('lint'); if ($repository_api->supportsRelativeLocalCommits()) { $argv[] = '--rev'; $argv[] = $repository_api->getRelativeCommit(); } $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(); $continue = false; switch ($lint_result) { case ArcanistLintWorkflow::RESULT_OKAY: echo phutil_console_format( "** LINT OKAY ** No lint problems.\n"); break; case ArcanistLintWorkflow::RESULT_WARNINGS: $msg = "Lint issued unresolved warnings. "; $msg .= $this->getArgument('excuse') ? "Ignore them?" : "Provide explanation and continue?"; $continue = phutil_console_confirm($msg); if (!$continue) { throw new ArcanistUserAbortException(); } break; case ArcanistLintWorkflow::RESULT_ERRORS: echo phutil_console_format( "** LINT ERRORS ** Lint raised errors!\n"); $msg = "Lint issued unresolved errors! "; $msg .= $this->getArgument('excuse') ? "Ignore lint errors?" : "Provide explanation and continue?"; $continue = phutil_console_confirm($msg); if (!$continue) { throw new ArcanistUserAbortException(); } break; } $this->unresolvedLint = $lint_workflow->getUnresolvedMessages(); if ($continue) { if ($this->getArgument('excuse')) { $this->unitExcuse = $this->getArgument('excuse'); } else { $template = "\n\n# Provide an explanation for these lint failures:\n"; foreach ($this->unresolvedLint as $message) { $template = $template."# ". $message->getPath().":". $message->getLine()." ". $message->getCode()." :: ". $message->getDescription()."\n"; } $this->lintExcuse = $this->getErrorExcuse($template); } } return $lint_result; } catch (ArcanistNoEngineException $ex) { echo "No lint engine configured for this project.\n"; } catch (ArcanistNoEffectException $ex) { echo "No paths to lint.\n"; } return null; } /** * @task lintunit */ private function runUnit($paths) { if ($this->getArgument('nounit') || $this->getArgument('only') || $this->isRawDiffSource()) { return ArcanistUnitWorkflow::RESULT_SKIP; } $repository_api = $this->getRepositoryAPI(); echo "Running unit tests...\n"; try { $argv = $this->getPassthruArgumentsAsArgv('unit'); if ($repository_api->supportsRelativeLocalCommits()) { $argv[] = '--rev'; $argv[] = $repository_api->getRelativeCommit(); } $this->unitWorkflow = $this->buildChildWorkflow('unit', $argv); $unit_result = $this->unitWorkflow->run(); $explain = false; switch ($unit_result) { case ArcanistUnitWorkflow::RESULT_OKAY: echo phutil_console_format( "** UNIT OKAY ** No unit test failures.\n"); break; case ArcanistUnitWorkflow::RESULT_UNSOUND: $continue = phutil_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: echo phutil_console_format( "** UNIT ERRORS ** Unit testing raised errors!\n"); $msg = "Unit test results include failures! "; $msg .= $this->getArgument('excuse') ? "Ignore test failures?" : "Explain test failures and continue?"; $continue = phutil_console_confirm($msg); if (!$continue) { throw new ArcanistUserAbortException(); } $explain = true; break; } $this->testResults = $this->unitWorkflow->getTestResults(); if ($explain) { if ($this->getArgument('excuse')) { $this->unitExcuse = $this->getArgument('excuse'); } else { $template = "\n\n". "# Provide an explanation for these unit test failures:\n"; foreach ($this->testResults as $test) { $testResult = $test->getResult(); switch ($testResult) { case ArcanistUnitTestResult::RESULT_FAIL: case ArcanistUnitTestResult::RESULT_BROKEN: $template = $template."# ". $test->getName()." :: ". $test->getResult()."\n"; break; default: break; } } $this->unitExcuse = $this->getErrorExcuse($template); } } return $unit_result; } catch (ArcanistNoEngineException $ex) { echo "No unit test engine is configured for this project.\n"; } catch (ArcanistNoEffectException $ex) { echo "No tests to run.\n"; } return null; } private function getErrorExcuse($template) { $new_template = id(new PhutilInteractiveEditor($template)) ->setName('error-excuse') ->editInteractively(); if ($new_template == $template) { throw new ArcanistUsageException( "No explanation provided."); } $template = preg_replace('/^\s*#.*$/m', '', $new_template); $template = rtrim($template)."\n"; return $template; } /* -( Commit and Update Messages )----------------------------------------- */ /** * @task message */ private function buildCommitMessage() { $is_create = $this->getArgument('create'); $is_update = $this->getArgument('update'); $is_auto = $this->getArgument('auto'); $is_raw = $this->isRawDiffSource(); $is_message = $this->getArgument('use-commit-message'); if ($is_message) { return $this->getCommitMessageFromCommit($is_message); } if ($is_auto) { $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 in the specified commit range:\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(); } } if ($is_update) { return $this->getCommitMessageFromRevision($is_update); } if ($is_raw) { return null; } if (!$this->shouldOnlyCreateDiff()) { return $this->getGitCommitMessage(); } return null; } /** * @task message */ private function getCommitMessageFromCommit($rev) { $change = $this->getRepositoryAPI()->getCommitMessageForRevision($rev); $message = ArcanistDifferentialCommitMessage::newFromRawCorpus( $change->getMetadata('message')); $message->pullDataFromConduit($this->getConduit()); $this->validateCommitMessage($message); return $message; } /** * @task message */ private function getCommitMessageFromUser() { $conduit = $this->getConduit(); $template = null; $saved = $this->readScratchFile('create-message'); if ($saved) { $where = $this->getReadableScratchFilePath('create-message'); $preview = explode("\n", $saved); $preview = array_shift($preview); $preview = trim($preview); $preview = phutil_utf8_shorten($preview, 64); 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(); if (!$template) { list($fields, $notes) = $this->getDefaultCreateFields(); if (!$fields) { $template_is_default = true; } $template = $conduit->callMethodSynchronous( 'differential.getcommitmessage', array( 'revision_id' => null, 'edit' => 'create', 'fields' => $fields, )); } $issues = array( 'Describe this revision.', '', 'If you intended to update a existing revision, use ', '`arc diff --update `.', ); if ($notes) { $issues = array_merge($issues, array(''), $notes); } $done = false; while (!$done) { $template = rtrim($template)."\n\n"; foreach ($issues as $issue) { $template .= '# '.$issue."\n"; } $template .= "\n"; $new_template = id(new PhutilInteractiveEditor($template)) ->setName('new-commit') ->editInteractively(); if ($template_is_default && ($new_template == $template)) { throw new ArcanistUsageException( "Template not edited."); } $template = preg_replace('/^\s*#.*$/m', '', $new_template); $template = rtrim($template)."\n"; $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 " - ".$error."\n"; $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("(Commit messaged 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 = $this->normalizeRevisionID($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!"); } if ($revision['authorPHID'] != $this->getUserPHID()) { $rev_title = $revision['title']; throw new ArcanistUsageException( "You don't own revision D{$id} '{$rev_title}'. You can only update ". "revisions you own."); } $message = $this->getConduit()->callMethodSynchronous( 'differential.getcommitmessage', array( 'revision_id' => $id, 'edit' => false, )); $obj = ArcanistDifferentialCommitMessage::newFromRawCorpus($message); $obj->pullDataFromConduit($this->getConduit()); return $obj; } /** * @task message */ private function validateCommitMessage( ArcanistDifferentialCommitMessage $message) { $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 if (in_array($this->getUserPHID(), $reviewers)) { throw new ArcanistUsageException( "You can not be a reviewer for your own revision."); } } /** * @task message */ private function getUpdateMessage() { $comments = $this->getArgument('message'); if (strlen($comments)) { return $comments; } // 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. $comments = $this->getDefaultUpdateMessage(); $template = rtrim($comments). "\n\n". "# Enter a brief description of the changes included in this update.". "\n"; $comments = id(new PhutilInteractiveEditor($template)) ->setName('differential-update-comments') ->editInteractively(); $comments = preg_replace('/^\s*#.*$/m', '', $comments); $comments = rtrim($comments); if (!strlen($comments)) { throw new ArcanistUserAbortException(); } return $comments; } /** * @task message */ private function getGitCommitMessage() { $conduit = $this->getConduit(); $repository_api = $this->getRepositoryAPI(); $commit_messages = $this->getLocalGitCommitMessages(); $problems = array(); $parsed = array(); $hashes = array(); foreach ($commit_messages as $key => $change) { $problems[$key] = array(); $hashes[$key] = $change->getCommitHash(); try { $message = ArcanistDifferentialCommitMessage::newFromRawCorpus( $change->getMetadata('message')); $message->pullDataFromConduit($conduit); $parsed[$key] = $message; } catch (ArcanistDifferentialCommitMessageParserException $ex) { foreach ($ex->getParserErrors() as $problem) { $problems[$key][] = $problem; } continue; } } $valid = array(); foreach ($problems as $key => $problem_list) { if ($problem_list) { continue; } $valid[$key] = $parsed[$key]; } $blessed = null; if (count($valid) == 1) { $blessed = head($valid); } else if (count($valid) > 1) { echo phutil_console_wrap( "Changes in the specified commit range include more than one commit ". "with a valid template commit message. Choose the message you want ". "to use (you can also use the -C flag).\n\n"); foreach ($valid as $key => $message) { $hash = substr($hashes[$key], 0, 7); $title = $commit_messages[$key]->getMetadata('message'); $title = head(explode("\n", trim($title))); $title = phutil_utf8_shorten($title, 64); echo " {$hash} {$title}\n"; } do { $choose = phutil_console_prompt('Use which commit message?'); foreach ($valid as $key => $message) { if (!strncmp($hashes[$key], $choose, strlen($choose))) { $blessed = $valid[$key]; break; } } } while (!$blessed); } if (!$blessed) { $desc = implode("\n", array_mergev($problems)); if (count($problems) > 1) { throw new ArcanistUsageException( "All changes between the specified commits have template parsing ". "problems:\n\n".$desc."\n\nIf you only want to create a diff ". "(not a revision), use --preview to ignore commit messages."); } else if (count($problems) == 1) { $user_guide = 'http://phabricator.com/docs/phabricator/'. 'article/Arcanist_User_Guide.html'; throw new ArcanistUsageException( "Commit message is not properly formatted:\n\n".$desc."\n\n". "You should use the standard git commit template to provide a ". "commit message. If you only want to create a diff (not a ". "revision), use --preview to ignore commit messages.\n\n". "See this document for instructions on configuring the commit ". "template:\n\n {$user_guide}\n"); } } if ($blessed) { $this->validateCommitMessage($blessed); } return $blessed; } private function getLocalGitCommitMessages() { $repository_api = $this->getRepositoryAPI(); $parser = new ArcanistDiffParser(); $commit_messages = $repository_api->getGitCommitLog(); if (!strlen($commit_messages)) { if (!$repository_api->getHasCommits()) { throw new ArcanistUsageException( "This git repository doesn't have any commits yet. You need to ". "commit something before you can diff against it."); } else { throw new ArcanistUsageException( "The commit range doesn't include any commits. (Did you diff ". "against the wrong commit?)"); } } return $parser->parseDiff($commit_messages); } private function getDefaultCreateFields() { $empty = array(array(), array()); if (!$this->requiresRepositoryAPI()) { return $empty; } $repository_api = $this->getRepositoryAPI(); if ($repository_api instanceof ArcanistGitAPI) { return $this->getGitCreateFields(); } return $empty; } private function getGitCreateFields() { $conduit = $this->getConduit(); $changes = $this->getLocalGitCommitMessages(); $commits = array(); foreach ($changes as $key => $change) { $commits[$change->getCommitHash()] = $change->getMetadata('message'); } $messages = array(); foreach ($commits as $hash => $text) { $messages[$hash] = ArcanistDifferentialCommitMessage::newFromRawCorpus( $text); } $fields = array(); $notes = array(); foreach ($messages as $hash => $message) { try { $message->pullDataFromConduit($conduit, $partial = true); $fields += $message->getFields(); } catch (ArcanistDifferentialCommitMessageParserException $ex) { $fields += $message->getFields(); $frev = substr($hash, 0, 8); $notes[] = "NOTE: commit {$frev} could not be completely parsed:"; foreach ($ex->getParserErrors() as $error) { $notes[] = " - {$error}"; } } } return array($fields, $notes); } private function getDefaultUpdateMessage() { if (!$this->requiresRepositoryAPI()) { 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 = new ArcanistDiffParser(); $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[] = $message; } 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(); $local = $this->loadActiveLocalCommitInfo(); $hashes = ipull($local, null, 'rev'); $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.getdiff', array( 'revision_id' => $this->revisionID, )); $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', ); 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; if ($this->requiresRepositoryAPI()) { $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(); 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 ArcanistSubversionAPI) { $repo_uuid = $repository_api->getRepositorySVNUUID(); } else if ($repository_api instanceof ArcanistMercurialAPI) { // TODO: Provide this information. } else { throw new Exception("Unsupported repository API!"); } } $project_id = null; if ($this->requiresWorkingCopy()) { $project_id = $this->getWorkingCopy()->getProjectID(); } return array( 'sourceMachine' => php_uname('n'), 'sourcePath' => $source_path, 'branch' => $branch, 'sourceControlSystem' => $vcs, 'sourceControlPath' => $base_path, 'sourceControlBaseRevision' => $base_revision, 'parentRevisionID' => $parent, 'repositoryUUID' => $repo_uuid, 'creationMethod' => 'arc', 'arcanistProject' => $project_id, 'authorPHID' => $this->getUserPHID(), ); } /* -( Diff Properties )---------------------------------------------------- */ /** * Update lint information for the diff. * * @return void * * @task diffprop */ private function updateLintDiffProperty() { if (!$this->unresolvedLint) { return; } $data = array(); foreach ($this->unresolvedLint as $message) { $data[] = array( 'path' => $message->getPath(), 'line' => $message->getLine(), 'char' => $message->getChar(), 'code' => $message->getCode(), 'severity' => $message->getSeverity(), 'name' => $message->getName(), 'description' => $message->getDescription(), ); } $this->updateDiffProperty('arc:lint', json_encode($data)); if (strlen($this->lintExcuse)) { $this->updateDiffProperty('arc:lint-excuse', json_encode($this->lintExcuse)); } } /** * Update unit test information for the diff. * * @return void * * @task diffprop */ private function updateUnitDiffProperty() { if (!$this->testResults) { return; } $data = array(); foreach ($this->testResults as $test) { $data[] = array( 'name' => $test->getName(), 'result' => $test->getResult(), 'userdata' => $test->getUserData(), 'coverage' => $test->getCoverage(), ); } $this->updateDiffProperty('arc:unit', json_encode($data)); if (strlen($this->unitExcuse)) { $this->updateDiffProperty('arc:unit-excuse', json_encode($this->unitExcuse)); } } /** * 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->getConduit()->callMethodSynchronous( 'differential.setdiffproperty', array( 'diff_id' => $this->getDiffID(), 'name' => $name, 'data' => $data, )); } } diff --git a/src/workflow/download/ArcanistDownloadWorkflow.php b/src/workflow/download/ArcanistDownloadWorkflow.php index 4f7857d6..e3009ec3 100644 --- a/src/workflow/download/ArcanistDownloadWorkflow.php +++ b/src/workflow/download/ArcanistDownloadWorkflow.php @@ -1,120 +1,125 @@ array( 'conflicts' => array( 'as' => 'Use --show to direct the file to stdout, or --as to direct '. 'it to a named location.', ), 'help' => 'Write file to stdout instead of to disk.', ), 'as' => array( 'param' => 'name', 'help' => 'Save the file with a specific name rather than the default.', ), '*' => 'argv', ); } protected function didParseArguments() { $argv = $this->getArgument('argv'); if (!$argv) { throw new ArcanistUsageException("Specify a file to download."); } if (count($argv) > 1) { throw new ArcanistUsageException("Specify exactly one file to download."); } $file = reset($argv); if (!preg_match('/^F?\d+$/', $file)) { throw new ArcanistUsageException("Specify file by ID, e.g. F123."); } $this->id = (int)ltrim($file, 'F'); $this->saveAs = $this->getArgument('as'); $this->show = $this->getArgument('show'); } public function requiresAuthentication() { return true; } public function run() { $conduit = $this->getConduit(); $this->writeStatusMessage("Getting file information...\n"); $info = $conduit->callMethodSynchronous( 'file.info', array( 'id' => $this->id, )); $bytes = number_format($info['byteSize']); $desc = '('.$bytes.' bytes)'; if ($info['name']) { $desc = "'".$info['name']."' ".$desc; } $this->writeStatusMessage("Downloading file {$desc}...\n"); $data = $conduit->callMethodSynchronous( 'file.download', array( 'phid' => $info['phid'], )); $data = base64_decode($data); if ($this->show) { echo $data; } else { $path = Filesystem::writeUniqueFile( nonempty($this->saveAs, $info['name'], 'file'), $data); $this->writeStatusMessage("Saved file as '{$path}'.\n"); } return 0; } } diff --git a/src/workflow/export/ArcanistExportWorkflow.php b/src/workflow/export/ArcanistExportWorkflow.php index 9b1ce4e8..7b8873bd 100644 --- a/src/workflow/export/ArcanistExportWorkflow.php +++ b/src/workflow/export/ArcanistExportWorkflow.php @@ -1,225 +1,231 @@ array( 'help' => "Export change as a git patch. This format is more complete than ". "unified, but less complete than arc bundles. These patches can be ". "applied with 'git apply' or 'arc patch'.", ), 'unified' => array( 'help' => "Export change as a unified patch. This format is less complete ". "than git patches or arc bundles. These patches can be applied with ". "'patch' or 'arc patch'.", ), 'arcbundle' => array( 'param' => 'file', 'help' => "Export change as an arc bundle. This format can represent all ". "changes. These bundles can be applied with 'arc patch'.", ), 'revision' => array( 'param' => 'revision_id', 'help' => "Instead of exporting changes from the working copy, export them ". "from a Differential revision." ), 'diff' => array( 'param' => 'diff_id', 'help' => "Instead of exporting changes from the working copy, export them ". "from a Differential diff." ), '*' => 'paths', ); } protected function didParseArguments() { $source = self::SOURCE_LOCAL; $requested = 0; if ($this->getArgument('revision')) { $source = self::SOURCE_REVISION; $requested++; } if ($this->getArgument('diff')) { $source = self::SOURCE_DIFF; $requested++; } if ($requested > 1) { throw new ArcanistUsageException( "Options '--revision' and '--diff' are not compatible. Choose exactly ". "one change source."); } $this->source = $source; $this->sourceID = $this->getArgument($source); $format = null; $requested = 0; if ($this->getArgument('git')) { $format = self::FORMAT_GIT; $requested++; } if ($this->getArgument('unified')) { $format = self::FORMAT_UNIFIED; $requested++; } if ($this->getArgument('arcbundle')) { $format = self::FORMAT_BUNDLE; $requested++; } if ($requested === 0) { throw new ArcanistUsageException( "Specify one of '--git', '--unified' or '--arcbundle ' to ". "choose an export format."); } else if ($requested > 1) { throw new ArcanistUsageException( "Options '--git', '--unified' and '--arcbundle' are not compatible. ". "Choose exactly one export format."); } $this->format = $format; } public function requiresConduit() { return $this->getSource() != self::SOURCE_LOCAL; } public function requiresAuthentication() { return $this->requiresConduit(); } public function requiresRepositoryAPI() { return $this->getSource() == self::SOURCE_LOCAL; } public function requiresWorkingCopy() { return $this->getSource() == self::SOURCE_LOCAL; } private function getSource() { return $this->source; } private function getSourceID() { return $this->sourceID; } private function getFormat() { return $this->format; } public function run() { $source = $this->getSource(); switch ($source) { case self::SOURCE_LOCAL: $repository_api = $this->getRepositoryAPI(); $parser = new ArcanistDiffParser(); if ($repository_api instanceof ArcanistGitAPI) { $repository_api->parseRelativeLocalCommit( $this->getArgument('paths')); $diff = $repository_api->getFullGitDiff(); $changes = $parser->parseDiff($diff); } else { // TODO: paths support $paths = $repository_api->getWorkingCopyStatus(); $changes = $parser->parseSubversionDiff( $repository_api, $paths); } $bundle = ArcanistBundle::newFromChanges($changes); $bundle->setProjectID($this->getWorkingCopy()->getProjectID()); $bundle->setBaseRevision( $repository_api->getSourceControlBaseRevision()); // note we can't get a revision ID for SOURCE_LOCAL break; case self::SOURCE_REVISION: $bundle = $this->loadRevisionBundleFromConduit( $this->getConduit(), $this->getSourceID()); break; case self::SOURCE_DIFF: $bundle = $this->loadDiffBundleFromConduit( $this->getConduit(), $this->getSourceID()); break; } $format = $this->getFormat(); switch ($format) { case self::FORMAT_GIT: echo $bundle->toGitPatch(); break; case self::FORMAT_UNIFIED: echo $bundle->toUnifiedDiff(); break; case self::FORMAT_BUNDLE: $path = $this->getArgument('arcbundle'); echo "Writing bundle to '{$path}'...\n"; $bundle->writeToDisk($path); echo "done.\n"; break; } return 0; } } diff --git a/src/workflow/git-hook-pre-receive/ArcanistGitHookPreReceiveWorkflow.php b/src/workflow/git-hook-pre-receive/ArcanistGitHookPreReceiveWorkflow.php index 27d13519..2edc0126 100644 --- a/src/workflow/git-hook-pre-receive/ArcanistGitHookPreReceiveWorkflow.php +++ b/src/workflow/git-hook-pre-receive/ArcanistGitHookPreReceiveWorkflow.php @@ -1,128 +1,134 @@ getWorkingCopy(); if (!$working_copy->getProjectID()) { throw new ArcanistUsageException( "You have installed a git pre-receive hook in a remote without an ". ".arcconfig."); } // Git repositories have special rules in pre-receive hooks. We need to // construct the API against the .git directory instead of the project // root or commands don't work properly. $repository_api = ArcanistGitAPI::newHookAPI($_SERVER['PWD']); $root = $working_copy->getProjectRoot(); $parser = new ArcanistDiffParser(); $mark_revisions = array(); $stdin = file_get_contents('php://stdin'); $commits = array_filter(explode("\n", $stdin)); foreach ($commits as $commit) { list($old_ref, $new_ref, $refname) = explode(' ', $commit); list($log) = execx( '(cd %s && git log -n1 %s)', $repository_api->getPath(), $new_ref); $message_log = reset($parser->parseDiff($log)); $message = ArcanistDifferentialCommitMessage::newFromRawCorpus( $message_log->getMetadata('message')); $revision_id = $message->getRevisionID(); if ($revision_id) { $mark_revisions[] = $revision_id; } // TODO: Do commit message junk. $info = $repository_api->getPreReceiveHookStatus($old_ref, $new_ref); $paths = ipull($info, 'mask'); $frefs = ipull($info, 'ref'); $data = array(); foreach ($paths as $path => $mask) { list($stdout) = execx( '(cd %s && git cat-file blob %s)', $repository_api->getPath(), $frefs[$path]); $data[$path] = $stdout; } // TODO: Do commit content junk. $commit_name = $new_ref; if ($revision_id) { $commit_name = 'D'.$revision_id.' ('.$commit_name.')'; } echo "[arc pre-receive] {$commit_name} OK...\n"; } $conduit = $this->getConduit(); $futures = array(); foreach ($mark_revisions as $revision_id) { $futures[] = $conduit->callMethod( 'differential.markcommitted', array( 'revision_id' => $revision_id, )); } Futures($futures)->resolveAll(); return 0; } } diff --git a/src/workflow/help/ArcanistHelpWorkflow.php b/src/workflow/help/ArcanistHelpWorkflow.php index 8a72513e..2c0273a7 100644 --- a/src/workflow/help/ArcanistHelpWorkflow.php +++ b/src/workflow/help/ArcanistHelpWorkflow.php @@ -1,180 +1,208 @@ array( + 'help' => 'Print detailed information about each command.', + ), '*' => 'command', ); } public function run() { $arc_config = $this->getArcanistConfiguration(); $workflows = $arc_config->buildAllWorkflows(); ksort($workflows); $target = null; if ($this->getArgument('command')) { $target = reset($this->getArgument('command')); if (empty($workflows[$target])) { throw new ArcanistUsageException( "Unrecognized command '{$target}'. Try 'arc help'."); } } $cmdref = array(); foreach ($workflows as $command => $workflow) { if ($target && $target != $command) { continue; } + if (!$target && !$this->getArgument('full')) { + $cmdref[] = $workflow->getCommandSynopses(); + continue; + } $optref = array(); $arguments = $workflow->getArguments(); $config_arguments = $arc_config->getCustomArgumentsForCommand($command); // This juggling is to put the extension arguments after the normal // arguments, and make sure the normal arguments aren't overwritten. ksort($arguments); ksort($config_arguments); foreach ($config_arguments as $argument => $spec) { if (empty($arguments[$argument])) { $arguments[$argument] = $spec; } } foreach ($arguments as $argument => $spec) { if ($argument == '*') { continue; } if (!empty($spec['hide'])) { continue; } if (isset($spec['param'])) { if (isset($spec['short'])) { $optref[] = phutil_console_format( " __--%s__ __%s__, __-%s__ __%s__", $argument, $spec['param'], $spec['short'], $spec['param']); } else { $optref[] = phutil_console_format( " __--%s__ __%s__", $argument, $spec['param']); } } else { if (isset($spec['short'])) { $optref[] = phutil_console_format( " __--%s__, __-%s__", $argument, $spec['short']); } else { $optref[] = phutil_console_format( " __--%s__", $argument); } } if (isset($config_arguments[$argument])) { $optref[] = " (This is a custom option for this ". "project.)"; } if (isset($spec['supports'])) { $optref[] = " Supports: ". implode(', ', $spec['supports']); } if (isset($spec['help'])) { $docs = $spec['help']; } else { $docs = 'This option is not documented.'; } $docs = phutil_console_wrap($docs, 14); $optref[] = " {$docs}\n"; } if ($optref) { $optref = implode("\n", $optref); $optref = "\n\n".$optref; } else { $optref = "\n"; } - $cmdref[] = $workflow->getCommandHelp().$optref; + $cmdref[] = + $workflow->getCommandSynopses()."\n". + $workflow->getCommandHelp(). + $optref; } $cmdref = implode("\n\n", $cmdref); if ($target) { echo "\n".$cmdref."\n"; return; } $self = 'arc'; + $description = ($this->getArgument('full') ? + "This help file provides a detailed command reference." : + "Run 'arc help --full' to get detailed command reference."); echo phutil_console_format(<<getArgument('full')) { + return; + } + + echo phutil_console_format(<< 'uri', ); } public function shouldShellComplete() { return false; } public function requiresConduit() { return false; } public function requiresWorkingCopy() { return false; } public function run() { $uri = $this->determineConduitURI(); echo "Installing certificate for '{$uri}'...\n"; $config = self::readUserConfigurationFile(); echo "Trying to connect to server...\n"; $conduit = new ConduitClient($uri); try { $conduit->callMethodSynchronous('conduit.ping', array()); } catch (Exception $ex) { throw new ArcanistUsageException( "Failed to connect to server: ".$ex->getMessage()); } echo "Connection OK!\n"; $token_uri = new PhutilURI($uri); $token_uri->setPath('/conduit/token/'); echo "\n"; echo phutil_console_format("**LOGIN TO PHABRICATOR**\n"); echo "Open this page in your browser and login to Phabricator if ". "necessary:\n"; echo "\n"; echo " {$token_uri}\n"; echo "\n"; echo "Then paste the token on that page below."; do { $token = phutil_console_prompt('Paste token from that page:'); $token = trim($token); if (strlen($token)) { break; } } while (true); echo "\n"; echo "Downloading authentication certificate...\n"; $info = $conduit->callMethodSynchronous( 'conduit.getcertificate', array( 'token' => $token, 'host' => $uri, )); $user = $info['username']; echo "Installing certificate for '{$user}'...\n"; $config['hosts'][$uri] = array( 'user' => $user, 'cert' => $info['certificate'], ); echo "Writing ~/.arcrc...\n"; self::writeUserConfigurationFile($config); echo phutil_console_format( "** SUCCESS! ** Certificate installed.\n"); return 0; } private function determineConduitURI() { $uri = $this->getArgument('uri'); if (count($uri) > 1) { throw new ArcanistUsageException("Specify at most one URI."); } else if (count($uri) == 1) { $uri = reset($uri); } else { $conduit_uri = $this->getConduitURI(); if (!$conduit_uri) { throw new ArcanistUsageException( "Specify an explicit URI or run this command from within a project ". "which is configured with a .arcconfig."); } $uri = $conduit_uri; } $uri = new PhutilURI($uri); $uri->setPath('/api/'); return (string)$uri; } } diff --git a/src/workflow/land/ArcanistLandWorkflow.php b/src/workflow/land/ArcanistLandWorkflow.php index 3641dc2f..746ed8cc 100644 --- a/src/workflow/land/ArcanistLandWorkflow.php +++ b/src/workflow/land/ArcanistLandWorkflow.php @@ -1,335 +1,340 @@ array( 'param' => 'master', 'help' => "Land feature branch onto a branch other than ". "'master' (default).", ), 'hold' => array( 'help' => "Prepare the change to be pushed, but do not actually ". "push it.", ), 'keep-branch' => array( 'help' => "Keep the feature branch after pushing changes to the ". "remote (by default, it is deleted).", ), 'remote' => array( 'param' => 'origin', 'help' => "Push to a remote other than 'origin' (default).", ), 'merge' => array( 'help' => 'Perform a --no-ff merge, not a --squash merge. If the '. 'project is marked as having an immutable history, this is '. 'the default behavior.', ), 'revision' => array( 'param' => 'id', 'help' => 'Use the message from a specific revision, rather than '. 'inferring the revision based on branch content.', ), '*' => 'branch', ); } public function run() { $branch = $this->getArgument('branch'); if (count($branch) !== 1) { throw new ArcanistUsageException( "Specify exactly one branch to land changes from."); } $branch = head($branch); $remote = $this->getArgument('remote', 'origin'); $onto = $this->getArgument('onto', 'master'); $is_immutable = $this->isHistoryImmutable() || $this->getArgument('merge'); $repository_api = $this->getRepositoryAPI(); if (!($repository_api instanceof ArcanistGitAPI)) { throw new ArcanistUsageException("'arc land' only supports git."); } list($err) = exec_manual( '(cd %s && git rev-parse --verify %s)', $repository_api->getPath(), $branch); if ($err) { throw new ArcanistUsageException("Branch '{$branch}' does not exist."); } $this->requireCleanWorkingCopy(); $repository_api->parseRelativeLocalCommit(array($remote.'/'.$onto)); $old_branch = $repository_api->getBranchName(); execx( '(cd %s && git checkout %s)', $repository_api->getPath(), $onto); echo phutil_console_format( "Switched to branch **%s**. Updating branch...\n", $onto); execx( '(cd %s && git pull --ff-only)', $repository_api->getPath()); list($out) = execx( '(cd %s && git log %s/%s..%s)', $repository_api->getPath(), $remote, $onto, $onto); if (strlen(trim($out))) { throw new ArcanistUsageException( "Local branch '{$onto}' is ahead of '{$remote}/{$onto}', so landing ". "a feature branch would push additional changes. Push or reset the ". "changes in '{$onto}' before running 'arc land'."); } execx( '(cd %s && git checkout %s)', $repository_api->getPath(), $branch); echo phutil_console_format( "Switched to branch **%s**. Identifying and merging...\n", $branch); if (!$is_immutable) { $err = phutil_passthru( '(cd %s && git rebase %s)', $repository_api->getPath(), $onto); if ($err) { throw new ArcanistUsageException( "'git rebase {$onto}' failed. You can abort with 'git rebase ". "--abort', or resolve conflicts and use 'git rebase --continue' to ". "continue forward. After resolving the rebase, run 'arc land' ". "again."); } // Now that we've rebased, the merge-base of origin/master and HEAD may // be different. Reparse the relative commit. $repository_api->parseRelativeLocalCommit(array($remote.'/'.$onto)); } $revision_id = $this->getArgument('revision'); if ($revision_id) { $revision_id = $this->normalizeRevisionID($revision_id); $revisions = $this->getConduit()->callMethodSynchronous( 'differential.query', array( 'ids' => array($revision_id), )); if (!$revisions) { throw new ArcanistUsageException("No such revision 'D{$revision_id}'!"); } } else { $revisions = $repository_api->loadWorkingCopyDifferentialRevisions( $this->getConduit(), array( 'authors' => array($this->getUserPHID()), )); } if (!count($revisions)) { throw new ArcanistUsageException( "arc can not identify which revision exists on branch '{$branch}'. ". "Update the revision with recent changes to synchronize the branch ". "name and hashes, or use 'arc amend' to amend the commit message at ". "HEAD, or use '--revision ' to select a revision explicitly."); } else if (count($revisions) > 1) { $message = "There are multiple revisions on feature branch '{$branch}' which are ". "not present on '{$onto}':\n\n". $this->renderRevisionList($revisions)."\n". "Separate these revisions onto different branches, or use ". "'--revision ' to select one."; throw new ArcanistUsageException($message); } $revision = head($revisions); $rev_id = $revision['id']; $rev_title = $revision['title']; if ($revision['status'] != ArcanistDifferentialRevisionStatus::ACCEPTED) { $ok = phutil_console_confirm( "Revision 'D{$rev_id}: {$rev_title}' has not been accepted. Continue ". "anyway?"); if (!$ok) { throw new ArcanistUserAbortException(); } } echo "Landing revision 'D{$rev_id}: {$rev_title}'...\n"; $message = $this->getConduit()->callMethodSynchronous( 'differential.getcommitmessage', array( 'revision_id' => $revision['id'], )); execx( '(cd %s && git checkout %s)', $repository_api->getPath(), $onto); if ($is_immutable) { // In immutable histories, do a --no-ff merge to force a merge commit with // the right message. $err = phutil_passthru( '(cd %s && git merge --no-ff -m %s %s)', $repository_api->getPath(), $message, $branch); if ($err) { throw new ArcanistUsageException( "'git merge' failed. Your working copy has been left in a partially ". "merged state. You can: abort with 'git merge --abort'; or follow ". "the instructions to complete the merge."); } } else { // In mutable histories, do a --squash merge. execx( '(cd %s && git merge --squash --ff-only %s)', $repository_api->getPath(), $branch); execx( '(cd %s && git commit -m %s)', $repository_api->getPath(), $message); } if ($this->getArgument('hold')) { echo phutil_console_format( "Holding change in **%s**: it has NOT been pushed yet.\n", $onto); } else { echo "Pushing change...\n\n"; $err = phutil_passthru( '(cd %s && git push %s %s)', $repository_api->getPath(), $remote, $onto); if ($err) { throw new ArcanistUsageException("'git push' failed."); } $mark_workflow = $this->buildChildWorkflow( 'mark-committed', array( '--finalize', '--quiet', $revision['id'], )); $mark_workflow->run(); echo "\n"; } if (!$this->getArgument('keep-branch')) { list($ref) = execx( '(cd %s && git rev-parse --verify %s)', $repository_api->getPath(), $branch); $ref = trim($ref); $recovery_command = csprintf( 'git checkout -b %s %s', $branch, $ref); echo "Cleaning up feature branch...\n"; echo "(Use `{$recovery_command}` if you want it back.)\n"; execx( '(cd %s && git branch -D %s)', $repository_api->getPath(), $branch); } // If we were on some branch A and the user ran "arc land B", switch back // to A. if (($old_branch != $branch) && ($old_branch != $onto)) { execx( '(cd %s && git checkout %s)', $repository_api->getPath(), $old_branch); echo phutil_console_format( "Switched back to branch **%s**.\n", $old_branch); } echo "Done.\n"; return 0; } protected function getSupportedRevisionControlSystems() { return array('git'); } } diff --git a/src/workflow/liberate/ArcanistLiberateWorkflow.php b/src/workflow/liberate/ArcanistLiberateWorkflow.php index f779d348..934b073a 100644 --- a/src/workflow/liberate/ArcanistLiberateWorkflow.php +++ b/src/workflow/liberate/ArcanistLiberateWorkflow.php @@ -1,336 +1,342 @@ array( 'help' => "Drop the module cache before liberating. This will completely ". "reanalyze the entire library. Thorough, but slow!", ), 'force-update' => array( 'help' => "Force the library map to be updated, even in the presence of ". "lint errors.", ), 'remap' => array( 'hide' => true, 'help' => "Internal. Run the remap step of liberation. You do not need to ". "run this unless you are debugging the workflow.", ), 'verify' => array( 'hide' => true, 'help' => "Internal. Run the verify step of liberation. You do not need to ". "run this unless you are debugging the workflow.", ), '*' => 'argv', ); } public function run() { $argv = $this->getArgument('argv'); if (count($argv) > 1) { throw new ArcanistUsageException( "Provide only one path to 'arc liberate'. The path should be a ". "directory where you want to create or update a libphutil library."); } else if (count($argv) == 0) { $path = getcwd(); } else { $path = reset($argv); } $is_remap = $this->getArgument('remap'); $is_verify = $this->getArgument('verify'); $path = Filesystem::resolvePath($path); if (Filesystem::pathExists($path) && is_dir($path)) { $init = id(new FileFinder($path)) ->withPath('*/__phutil_library_init__.php') ->find(); } else { $init = null; } if ($init) { if (count($init) > 1) { throw new ArcanistUsageException( "Specified directory contains more than one libphutil library. Use ". "a more specific path."); } $path = Filesystem::resolvePath(dirname(reset($init)), $path); } else { $found = false; foreach (Filesystem::walkToRoot($path) as $dir) { if (Filesystem::pathExists($dir.'/__phutil_library_init__.php')) { $path = $dir; break; } } if (!$found) { echo "No library currently exists at that path...\n"; $this->liberateCreateDirectory($path); $this->liberateCreateLibrary($path); } } if ($this->getArgument('remap')) { return $this->liberateRunRemap($path); } if ($this->getArgument('verify')) { return $this->liberateRunVerify($path); } $readable = Filesystem::readablePath($path); echo "Using library root at '{$readable}'...\n"; if ($this->getArgument('all')) { echo "Dropping module cache...\n"; Filesystem::remove($path.'/.phutil_module_cache'); } echo "Mapping library...\n"; // Force a rebuild of the library map before running lint. The remap // operation will load the map before regenerating it, so if a class has // been renamed (say, from OldClass to NewClass) this rebuild will // cause the initial remap to see NewClass and correctly remove includes // caused by use of OldClass. $this->liberateGetChangedPaths($path); $arc_bin = $this->getScriptPath('bin/arc'); do { $future = new ExecFuture( '%s liberate --remap -- %s', $arc_bin, $path); $wrote = $future->resolveJSON(); foreach ($wrote as $wrote_path) { echo "Updated '{$wrote_path}'...\n"; } } while ($wrote); echo "Verifying library...\n"; $err = 0; $cmd = csprintf('%s liberate --verify -- %s', $arc_bin, $path); passthru($cmd, $err); $do_update = (!$err || $this->getArgument('force-update')); if ($do_update) { echo "Finalizing library map...\n"; execx('%s %s', $this->getPhutilMapperLocation(), $path); } if ($err) { if ($do_update) { echo phutil_console_format( "** WARNING ** Library update forced, but lint ". "failures remain.\n"); } else { echo phutil_console_format( "** UNRESOLVED LINT ERRORS ** This library has ". "unresolved lint failures. The library map was not updated. Use ". "--force-update to force an update.\n"); } } else { echo phutil_console_format( "** OKAY ** Library updated.\n"); } return $err; } private function liberateLintModules($path, array $changed) { $engine = $this->liberateBuildLintEngine($path, $changed); if ($engine) { return $engine->run(); } else { return array(); } } private function liberateWritePatches(array $results) { $wrote = array(); foreach ($results as $result) { if ($result->isPatchable()) { $patcher = ArcanistLintPatcher::newFromArcanistLintResult($result); $patcher->writePatchToDisk(); $wrote[] = $result->getPath(); } } return $wrote; } private function liberateBuildLintEngine($path, array $changed) { $lint_map = array(); foreach ($changed as $module) { $module_path = $path.'/'.$module; $files = Filesystem::listDirectory($module_path); $lint_map[$module] = $files; } $working_copy = ArcanistWorkingCopyIdentity::newFromRootAndConfigFile( $path, json_encode( array( 'project_id' => '__arcliberate__', )), 'arc liberate'); $engine = new ArcanistLiberateLintEngine(); $engine->setWorkingCopy($working_copy); $lint_paths = array(); foreach ($lint_map as $module => $files) { foreach ($files as $file) { $lint_paths[] = $module.'/'.$file; } } if (!$lint_paths) { return null; } $engine->setPaths($lint_paths); $engine->setMinimumSeverity(ArcanistLintSeverity::SEVERITY_ERROR); return $engine; } private function liberateCreateDirectory($path) { if (Filesystem::pathExists($path)) { if (!is_dir($path)) { throw new ArcanistUsageException( "Provide a directory to create or update a libphutil library in."); } return; } echo "The directory '{$path}' does not exist."; if (!phutil_console_confirm('Do you want to create it?')) { throw new ArcanistUsageException("Cancelled."); } execx('mkdir -p %s', $path); } private function liberateCreateLibrary($path) { $init_path = $path.'/__phutil_library_init__.php'; if (Filesystem::pathExists($init_path)) { return; } echo "Creating new libphutil library in '{$path}'.\n"; echo "Choose a name for the new library.\n"; do { $name = phutil_console_prompt('What do you want to name this library?'); if (preg_match('/^[a-z]+$/', $name)) { break; } else { echo "Library name should contain only lowercase letters.\n"; } } while (true); $template = "getPhutilMapperLocation(); $future = new ExecFuture('%s %s --find-paths-for-liberate', $mapper, $path); return $future->resolveJSON(); } private function getScriptPath($script) { $root = dirname(phutil_get_library_root('arcanist')); return $root.'/'.$script; } private function getPhutilMapperLocation() { return $this->getScriptPath('scripts/phutil_mapper.php'); } private function liberateRunRemap($path) { phutil_load_library($path); $paths = $this->liberateGetChangedPaths($path); $results = $this->liberateLintModules($path, $paths); $wrote = $this->liberateWritePatches($results); echo json_encode($wrote, true); return 0; } private function liberateRunVerify($path) { phutil_load_library($path); $paths = $this->liberateGetChangedPaths($path); $results = $this->liberateLintModules($path, $paths); $renderer = new ArcanistLintRenderer(); $unresolved = false; foreach ($results as $result) { foreach ($result->getMessages() as $message) { echo $renderer->renderLintResult($result); $unresolved = true; break; } } return (int)$unresolved; } } diff --git a/src/workflow/lint/ArcanistLintWorkflow.php b/src/workflow/lint/ArcanistLintWorkflow.php index b652ea6d..93c82433 100644 --- a/src/workflow/lint/ArcanistLintWorkflow.php +++ b/src/workflow/lint/ArcanistLintWorkflow.php @@ -1,292 +1,298 @@ shouldAmendChanges = $should_amend; return $this; } - public function getCommandHelp() { + public function getCommandSynopses() { return phutil_console_format(<< array( 'help' => "Show all lint warnings, not just those on changed lines." ), 'rev' => array( 'param' => 'revision', 'help' => "Lint changes since a specific revision.", 'supports' => array( 'git', 'hg', ), 'nosupport' => array( 'svn' => "Lint does not currently support --rev in SVN.", ), ), 'output' => array( 'param' => 'format', 'help' => "With 'summary', show lint warnings in a more compact format. ". "With 'json', show lint warnings in machine-readable JSON format." ), 'advice' => array( 'help' => "Show lint advice, not just warnings and errors." ), 'engine' => array( 'param' => 'classname', 'help' => "Override configured lint engine for this project." ), 'apply-patches' => array( 'help' => 'Apply patches suggested by lint to the working copy without '. 'prompting.', 'conflicts' => array( 'never-apply-patches' => true, ), ), 'never-apply-patches' => array( 'help' => 'Never apply patches suggested by lint.', 'conflicts' => array( 'apply-patches' => true, ), ), '*' => 'paths', ); } public function requiresWorkingCopy() { return true; } public function requiresRepositoryAPI() { return true; } public function run() { $working_copy = $this->getWorkingCopy(); $engine = $this->getArgument('engine'); if (!$engine) { $engine = $working_copy->getConfig('lint_engine'); if (!$engine) { throw new ArcanistNoEngineException( "No lint engine configured for this project. Edit .arcconfig to ". "specify a lint engine."); } } $rev = $this->getArgument('rev'); $paths = $this->getArgument('paths'); if ($rev && $paths) { throw new ArcanistUsageException("Specify either --rev or paths."); } $should_lint_all = $this->getArgument('lintall'); if ($paths) { // NOTE: When the user specifies paths, we imply --lintall and show all // warnings for the paths in question. This is easier to deal with for // us and less confusing for users. $should_lint_all = true; } $paths = $this->selectPathsForWorkflow($paths, $rev); PhutilSymbolLoader::loadClass($engine); if (!is_subclass_of($engine, 'ArcanistLintEngine')) { throw new ArcanistUsageException( "Configured lint engine '{$engine}' is not a subclass of ". "'ArcanistLintEngine'."); } $engine = newv($engine, array()); $engine->setWorkingCopy($working_copy); if ($this->getArgument('advice')) { $engine->setMinimumSeverity(ArcanistLintSeverity::SEVERITY_ADVICE); } else { $engine->setMinimumSeverity(ArcanistLintSeverity::SEVERITY_WARNING); } // Propagate information about which lines changed to the lint engine. // This is used so that the lint engine can drop warning messages // concerning lines that weren't in the change. $engine->setPaths($paths); if (!$should_lint_all) { foreach ($paths as $path) { // Note that getChangedLines() returns null to indicate that a file // is binary or a directory (i.e., changed lines are not relevant). $engine->setPathChangedLines( $path, $this->getChangedLines($path, 'new')); } } $results = $engine->run(); if ($this->getArgument('never-apply-patches')) { $apply_patches = false; } else { $apply_patches = true; } if ($this->getArgument('apply-patches')) { $prompt_patches = false; } else { $prompt_patches = true; } $wrote_to_disk = false; switch ($this->getArgument('output')) { case 'json': $renderer = new ArcanistLintJSONRenderer(); $prompt_patches = false; $apply_patches = $this->getArgument('apply-patches'); break; case 'summary': $renderer = new ArcanistLintSummaryRenderer(); break; default: $renderer = new ArcanistLintRenderer(); break; } foreach ($results as $result) { if (!$result->getMessages()) { continue; } echo $renderer->renderLintResult($result); if ($apply_patches && $result->isPatchable()) { $patcher = ArcanistLintPatcher::newFromArcanistLintResult($result); $old = $patcher->getUnmodifiedFileContent(); $new = $patcher->getModifiedFileContent(); if ($prompt_patches) { $old_file = $result->getFilePathOnDisk(); if (!Filesystem::pathExists($old_file)) { $old_file = '/dev/null'; } $new_file = new TempFile(); Filesystem::writeFile($new_file, $new); // TODO: Improve the behavior here, make it more like // difference_render(). passthru(csprintf("diff -u %s %s", $old_file, $new_file)); $prompt = phutil_console_format( "Apply this patch to __%s__?", $result->getPath()); if (!phutil_console_confirm($prompt, $default_no = false)) { continue; } } $patcher->writePatchToDisk(); $wrote_to_disk = true; } } $repository_api = $this->getRepositoryAPI(); if ($wrote_to_disk && ($repository_api instanceof ArcanistGitAPI) && $this->shouldAmendChanges) { $amend = phutil_console_confirm("Amend HEAD with lint patches?"); if ($amend) { execx( '(cd %s; git commit -a --amend -C HEAD)', $repository_api->getPath()); } else { throw new ArcanistUsageException( "Sort out the lint changes that were applied to the working ". "copy and relint."); } } $unresolved = array(); $has_warnings = false; $has_errors = false; foreach ($results as $result) { foreach ($result->getMessages() as $message) { if (!$message->isPatchApplied()) { if ($message->isError()) { $has_errors = true; } else if ($message->isWarning()) { $has_warnings = true; } $unresolved[] = $message; } } } $this->unresolvedMessages = $unresolved; // Take the most severe lint message severity and use that // as the result code. if ($has_errors) { $result_code = self::RESULT_ERRORS; } else if ($has_warnings) { $result_code = self::RESULT_WARNINGS; } else { $result_code = self::RESULT_OKAY; } if (!$this->getParentWorkflow()) { if ($result_code == self::RESULT_OKAY) { echo $renderer->renderOkayResult(); } } return $result_code; } public function getUnresolvedMessages() { return $this->unresolvedMessages; } } diff --git a/src/workflow/list/ArcanistListWorkflow.php b/src/workflow/list/ArcanistListWorkflow.php index 4e63e56c..d165156d 100644 --- a/src/workflow/list/ArcanistListWorkflow.php +++ b/src/workflow/list/ArcanistListWorkflow.php @@ -1,101 +1,107 @@ getConduit()->callMethodSynchronous( 'differential.query', array( 'authors' => array($this->getUserPHID()), 'status' => 'status-open', )); if (!$revisions) { echo "You have no open Differential revisions.\n"; return 0; } $repository_api = $this->getRepositoryAPI(); $info = array(); $status_len = 0; foreach ($revisions as $key => $revision) { $revision_path = Filesystem::resolvePath($revision['sourcePath']); $current_path = Filesystem::resolvePath($repository_api->getPath()); if ($revision_path == $current_path) { $info[$key]['here'] = 1; } else { $info[$key]['here'] = 0; } $info[$key]['sort'] = sprintf( '%d%04d%08d', $info[$key]['here'], $revision['status'], $revision['id']); $info[$key]['statusColorized'] = BranchInfo::renderColorizedRevisionStatus( $revision['statusName']); $status_len = max( $status_len, strlen($info[$key]['statusColorized'])); } $info = isort($info, 'sort'); foreach ($info as $key => $spec) { $revision = $revisions[$key]; printf( "%s %-".($status_len + 4)."s D%d: %s\n", $spec['here'] ? phutil_console_format('**%s**', '*') : ' ', $spec['statusColorized'], $revision['id'], $revision['title']); } return 0; } } diff --git a/src/workflow/mark-committed/ArcanistMarkCommittedWorkflow.php b/src/workflow/mark-committed/ArcanistMarkCommittedWorkflow.php index e8bbfa2f..ff56bf4f 100644 --- a/src/workflow/mark-committed/ArcanistMarkCommittedWorkflow.php +++ b/src/workflow/mark-committed/ArcanistMarkCommittedWorkflow.php @@ -1,166 +1,172 @@ array( 'help' => "Mark committed only if the repository is untracked and the ". "revision is accepted. Continue even if the mark can't happen. This ". "is a soft version of 'mark-committed' used by other workflows.", ), 'quiet' => array( 'help' => 'Do not print a success message.', ), '*' => 'revision', ); } public function requiresConduit() { return true; } public function requiresAuthentication() { return true; } public function requiresRepositoryAPI() { // NOTE: Technically we only use this to generate the right message at // the end, and you can even get the wrong message (e.g., if you run // "arc mark-committed D123" from a git repository, but D123 is an SVN // revision). We could be smarter about this, but it's just display fluff. return true; } public function run() { $is_finalize = $this->getArgument('finalize'); $conduit = $this->getConduit(); $revision_list = $this->getArgument('revision', array()); if (!$revision_list) { throw new ArcanistUsageException( "mark-committed requires a revision number."); } if (count($revision_list) != 1) { throw new ArcanistUsageException( "mark-committed requires exactly one revision."); } $revision_id = reset($revision_list); $revision_id = $this->normalizeRevisionID($revision_id); $revision = null; try { $revision = $conduit->callMethodSynchronous( 'differential.getrevision', array( 'revision_id' => $revision_id, ) ); } catch (Exception $ex) { if (!$is_finalize) { throw new ArcanistUsageException( "Revision D{$revision_id} does not exist." ); } } if (!$is_finalize && $revision['statusName'] != 'Accepted') { throw new ArcanistUsageException( "Revision D{$revision_id} is not committable. You can only mark ". "revisions which have been 'accepted' as committed." ); } if ($revision) { if (!$is_finalize && $revision['authorPHID'] != $this->getUserPHID()) { $prompt = "You are not the author of revision D{$revision_id}, ". 'are you sure you want to mark it committed?'; if (!phutil_console_confirm($prompt)) { throw new ArcanistUserAbortException(); } } $actually_mark = true; if ($is_finalize) { $project_info = $conduit->callMethodSynchronous( 'arcanist.projectinfo', array( 'name' => $this->getWorkingCopy()->getProjectID(), )); if ($project_info['tracked'] || $revision['statusName'] != 'Accepted') { $actually_mark = false; } } if ($actually_mark) { $revision_name = $revision['title']; echo "Marking revision D{$revision_id} '{$revision_name}' ". "committed...\n"; $conduit->callMethodSynchronous( 'differential.markcommitted', array( 'revision_id' => $revision_id, )); } } $status = $revision['statusName']; if ($status == 'Accepted' || $status == 'Committed') { // If this has already been attached to commits, don't show the // "you can push this commit" message since we know it's been committed // already. $is_finalized = empty($revision['commits']); } else { $is_finalized = false; } if (!$this->getArgument('quiet')) { if ($is_finalized) { $message = $this->getRepositoryAPI()->getFinalizedRevisionMessage(); echo phutil_console_wrap($message)."\n"; } else { echo "Done.\n"; } } return 0; } } diff --git a/src/workflow/merge/ArcanistMergeWorkflow.php b/src/workflow/merge/ArcanistMergeWorkflow.php index b572a54e..22c9f32d 100644 --- a/src/workflow/merge/ArcanistMergeWorkflow.php +++ b/src/workflow/merge/ArcanistMergeWorkflow.php @@ -1,60 +1,65 @@ 'ignored', ); } public function run() { $repository_api = $this->getRepositoryAPI(); if ($repository_api instanceof ArcanistGitAPI) { throw new ArcanistUsageException( "'arc merge' no longer supports git. Use ". "'arc land --keep-branch --hold --merge ' instead."); } throw new ArcanistUsageException('arc merge is no longer supported.'); } } diff --git a/src/workflow/paste/ArcanistPasteWorkflow.php b/src/workflow/paste/ArcanistPasteWorkflow.php index 798ab3d8..0a77a4d0 100644 --- a/src/workflow/paste/ArcanistPasteWorkflow.php +++ b/src/workflow/paste/ArcanistPasteWorkflow.php @@ -1,159 +1,164 @@ array( 'param' => 'title', 'help' => 'Title for the paste.', ), 'lang' => array( 'param' => 'language', 'help' => 'Language for syntax highlighting.', ), 'json' => array( 'help' => 'Output in JSON format.', ), '*' => 'argv', ); } public function requiresAuthentication() { return true; } protected function didParseArguments() { $this->language = $this->getArgument('lang'); $this->title = $this->getArgument('title'); $this->json = $this->getArgument('json'); $argv = $this->getArgument('argv'); if (count($argv) > 1) { throw new ArcanistUsageException("Specify only one paste to retrieve."); } else if (count($argv) == 1) { $id = $argv[0]; if (!preg_match('/^P?\d+/', $id)) { throw new ArcanistUsageException("Specify a paste ID, like P123."); } $this->id = (int)ltrim($id, 'P'); if ($this->language || $this->title) { throw new ArcanistUsageException( "Use options --lang and --title only when creating pastes."); } } } private function getTitle() { return $this->title; } private function getLanguage() { return $this->language; } private function getJSON() { return $this->json; } public function run() { if ($this->id) { return $this->getPaste(); } else { return $this->createPaste(); } } private function getPaste() { $conduit = $this->getConduit(); $info = $conduit->callMethodSynchronous( 'paste.info', array( 'paste_id' => $this->id, )); if ($this->getJSON()) { echo json_encode($info)."\n"; } else { echo $info['content']; if (!preg_match('/\\n$/', $info['content'])) { // If there's no newline, add one, since it looks stupid otherwise. If // you want byte-for-byte equivalence you can use --json. echo "\n"; } } return 0; } private function createPaste() { $conduit = $this->getConduit(); // Avoid confusion when people type "arc paste" with nothing else. $this->writeStatusMessage("Reading paste from stdin...\n"); $info = $conduit->callMethodSynchronous( 'paste.create', array( 'content' => file_get_contents('php://stdin'), 'title' => $this->getTitle(), 'language' => $this->getLanguage(), )); if ($this->getArgument('json')) { echo json_encode($info)."\n"; } else { echo $info['objectName'].': '.$info['uri']."\n"; } return 0; } } diff --git a/src/workflow/patch/ArcanistPatchWorkflow.php b/src/workflow/patch/ArcanistPatchWorkflow.php index 57bd233d..daa6a55c 100644 --- a/src/workflow/patch/ArcanistPatchWorkflow.php +++ b/src/workflow/patch/ArcanistPatchWorkflow.php @@ -1,800 +1,806 @@ array( 'param' => 'revision_id', 'paramtype' => 'complete', 'help' => "Apply changes from a Differential revision, using the most recent ". "diff that has been attached to it. You can run 'arc patch D12345' ". "as a shorthand for this.", ), 'diff' => array( 'param' => 'diff_id', 'help' => "Apply changes from a Differential diff. Normally you want to use ". "--revision 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.", ), 'arcbundle' => array( 'param' => 'bundlefile', 'paramtype' => 'file', 'help' => "Apply changes from an arc bundle generated with 'arc export'.", ), 'patch' => array( 'param' => 'patchfile', 'paramtype' => 'file', 'help' => "Apply changes from a git patchfile or unified patchfile.", ), 'update' => array( 'supports' => array( 'git', 'svn', 'hg' ), 'help' => "Update the local working copy before applying the patch.", 'conflicts' => array( 'nobranch' => true, ), ), 'nocommit' => array( 'supports' => array( 'git' ), 'help' => "Normally under git if the patch is successful the changes are ". "committed to the working copy. This flag prevents the commit.", ), 'nobranch' => array( 'supports' => array( 'git' ), 'help' => "Normally under git a new branch is created and then the patch ". "is applied and committed in the branch. This flag skips the ". "branch creation step and applies and commits the patch to the ". "current branch.", 'conflicts' => array( 'update' => true, ), ), 'force' => array( 'help' => "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("Specify at most one revision name."); } $source = self::SOURCE_REVISION; $requested++; $use_revision_id = $this->normalizeRevisionID(head($namev)); } if ($requested === 0) { throw new ArcanistUsageException( "Specify one of 'D12345', '--revision ' (to select the ". "current changes attached to a Differential revision), ". "'--diff ' (to select a specific, out-of-date diff or a ". "diff which is not attached to a revision), '--arcbundle ' ". "or '--patch ' to choose a patch source."); } else if ($requested > 1) { throw new ArcanistUsageException( "Options 'D12345', '--revision', '--diff', '--arcbundle' and ". "'--patch' are not compatible. Choose exactly one patch source."); } $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() { $no_commit = $this->getArgument('nocommit', false); if ($no_commit) { return false; } return true; } private function shouldBranch() { // git only for now $repository_api = $this->getRepositoryAPI(); if (!($repository_api instanceof ArcanistGitAPI)) { return false; } $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) = exec_manual( '(cd %s; git rev-parse --verify %s)', $repository_api->getPath(), $proposed_name ); // no error means git rev-parse found a branch if (!$err) { echo phutil_console_format( "Branch name {$proposed_name} already exists; trying a new name.\n" ); continue; } else { $branch_name = $proposed_name; break; } } if (!$branch_name) { throw new Exception( "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 createBranch(ArcanistBundle $bundle) { $branch_name = $this->getBranchName($bundle); $repository_api = $this->getRepositoryAPI(); $base_revision = $bundle->getBaseRevision(); // verify the base revision is valid // in a working copy that uses the git-svn bridge, the base revision might // be a svn uri instead of a git ref list($err) = exec_manual( '(cd %s; git rev-parse --verify %s)', $repository_api->getPath(), $base_revision ); if ($base_revision && !$err) { execx( '(cd %s; git checkout -b %s %s)', $repository_api->getPath(), $branch_name, $base_revision); } else { execx( '(cd %s; git checkout -b %s)', $repository_api->getPath(), $branch_name); } echo phutil_console_format( "Created and checked out branch {$branch_name}.\n" ); } private function shouldUpdateWorkingCopy() { return $this->getArgument('update', false); } private function updateWorkingCopy() { $repository_api = $this->getRepositoryAPI(); if ($repository_api instanceof ArcanistSubversionAPI) { execx( '(cd %s; svn up)', $repository_api->getPath()); $message = "Updated to HEAD. "; } else if ($repository_api instanceof ArcanistGitAPI) { execx( '(cd %s; git pull)', $repository_api->getPath()); $message = "Updated to HEAD. "; } else if ($repository_api instanceof ArcanistMercurialAPI) { execx( '(cd %s; hg up)', $repository_api->getPath()); $message = "Updated to tip. "; } else { throw new Exception('Unknown version control system.'); } echo phutil_console_format($message."\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( "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 (Exception $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; } } $force = $this->getArgument('force', false); if ($force) { // force means don't do any sanity checks about the patch } else { $this->sanityCheck($bundle); } // we should update the working copy before we do ANYTHING else if ($this->shouldUpdateWorkingCopy()) { $this->updateWorkingCopy(); } if ($this->shouldBranch()) { $this->createBranch($bundle); } $repository_api = $this->getRepositoryAPI(); 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( "Patch deletes file '{$path}', but the file does not exist in ". "the working copy. Continue anyway?"); 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 = 'copies'; } else { $verbs = 'moves'; } $ok = phutil_console_confirm( "Patch {$verbs} '{$path}' to '{$cpath}', but source path ". "does not exist in the working copy. Continue anyway?"); if (!$ok) { throw new ArcanistUserAbortException(); } } else { $copies[] = array( $change->getOldPath(), $change->getCurrentPath()); } break; case ArcanistDiffChangeType::TYPE_ADD: $adds[] = $change->getCurrentPath(); break; } if ($should_patch) { if ($change->getHunks()) { $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); } foreach ($copies as $copy) { list($src, $dst) = $copy; passthru( csprintf( '(cd %s; svn cp %s %s)', $repository_api->getPath(), $src, $dst)); } foreach ($deletes as $delete) { passthru( csprintf( '(cd %s; svn rm %s)', $repository_api->getPath(), $delete)); } foreach ($symlinks as $symlink) { $link_target = $symlink->getSymlinkTarget(); $link_path = $symlink->getCurrentPath(); switch ($symlink->getType()) { case ArcanistDiffChangeType::TYPE_ADD: case ArcanistDiffChangeType::TYPE_MODIFY: 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) { $tmp = new TempFile(); Filesystem::writeFile($tmp, $patch); $err = null; passthru( csprintf( '(cd %s; patch -p0 < %s)', $repository_api->getPath(), $tmp), $err); if ($err) { $patch_err = max($patch_err, $err); } } foreach ($adds as $add) { passthru( csprintf( '(cd %s; svn add %s)', $repository_api->getPath(), $add)); } foreach ($propset as $path => $changes) { foreach ($change as $prop => $value) { // TODO: Probably need to handle svn:executable specially here by // doing chmod +x or -x. if ($value === null) { passthru( csprintf( '(cd %s; svn propdel %s %s)', $repository_api->getPath(), $prop, $path)); } else { passthru( csprintf( '(cd %s; svn propset %s %s %s)', $repository_api->getPath(), $prop, $value, $path)); } } } if ($patch_err == 0) { echo phutil_console_format( "** OKAY ** Successfully applied patch ". "to the working copy.\n"); } else { echo phutil_console_format( "\n\n** WARNING ** Some hunks could not be applied ". "cleanly by the unix 'patch' 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 ". "'arc export --unified', and then try to apply it by fiddling with ". "options to 'patch' (particularly, -p), or manually. The output ". "above, from 'patch', may be helpful in figuring out what went ". "wrong.\n"); } return $patch_err; } else if ($repository_api instanceof ArcanistGitAPI) { $future = new ExecFuture( '(cd %s; git apply --index --reject)', $repository_api->getPath()); $future->write($bundle->toGitPatch()); $future->resolvex(); if ($this->shouldCommit()) { $commit_message = $this->getCommitMessage($bundle); $future = new ExecFuture( '(cd %s; git commit -a -F -)', $repository_api->getPath()); $future->write($commit_message); $future->resolvex(); $verb = 'committed'; } else { $verb = 'applied'; } echo phutil_console_format( "** OKAY ** Successfully {$verb} patch.\n"); } else if ($repository_api instanceof ArcanistMercurialAPI) { $future = new ExecFuture( '(cd %s; hg import --no-commit -)', $repository_api->getPath()); $future->write($bundle->toGitPatch()); $future->resolvex(); echo phutil_console_format( "** OKAY ** Successfully applied patch.\n"); } else { throw new Exception('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 if ($revision_id) { $conduit = $this->getConduit(); $commit_message = $conduit->callMethodSynchronous( 'differential.getcommitmessage', array( 'revision_id' => $revision_id, )); $prompt_message = " Note arcanist failed to load the commit message ". "from differential for revision 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 = "\n\n". "# 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 --nocommit flag.". $prompt_message. "\n"; $commit_message = id(new PhutilInteractiveEditor($template)) ->setName('arcanist-patch-commit-message') ->editInteractively(); $commit_message = preg_replace('/^\s*#.*$/m', '', $commit_message); $commit_message = rtrim($commit_message); if (!strlen($commit_message)) { throw new ArcanistUserAbortException(); } } return $commit_message; } public function getShellCompletions(array $argv) { // TODO: Pull open diffs from 'arc list'? return array('ARGUMENT'); } /** * Do the best we can to prevent PEBKAC and id10t issues. */ private function sanityCheck(ArcanistBundle $bundle) { // Require clean working copy $this->requireCleanWorkingCopy(); // 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) { $ok = phutil_console_confirm( "This diff is for the '{$bundle_project_id}' project but the working ". "copy belongs to the '{$working_copy_project_id}' project. ". "Still try to apply it?", $default_no = false ); if (!$ok) { throw new ArcanistUserAbortException(); } } // Check to see if the bundle's base revision matches the working copy // base revision $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 } else { $repository_api = $this->getRepositoryAPI(); $source_base_rev = $repository_api->getWorkingCopyRevision(); if ($source_base_rev != $bundle_base_rev) { // 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_str = null; // SVN doesn't store these hashes, so we're basically done already // and will have a relatively "lame" error message if ($repository_api instanceof ArcanistSubversionAPI) { $hash_type = null; } else 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( "This diff is against commit {$bundle_base_rev_str}, but the ". "working copy is at {$source_base_rev_str}. ". "Still try to apply it?", $default_no = false ); if (!$ok) { throw new ArcanistUserAbortException(); } } } // TODO -- more sanity checks here } /** * 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 committed revision only $found_revision = null; $revisions = isort($revisions, 'dateModified'); foreach ($revisions as $revision) { if ($revision['status'] == ArcanistDifferentialRevisionStatus::COMMITTED) { $found_revision = $revision; } } return $found_revision; } } diff --git a/src/workflow/shell-complete/ArcanistShellCompleteWorkflow.php b/src/workflow/shell-complete/ArcanistShellCompleteWorkflow.php index 17358089..1f0d7941 100644 --- a/src/workflow/shell-complete/ArcanistShellCompleteWorkflow.php +++ b/src/workflow/shell-complete/ArcanistShellCompleteWorkflow.php @@ -1,184 +1,190 @@ array( 'help' => 'Current term in the argument list being completed.', 'param' => 'cursor_position', ), '*' => 'argv', ); } public function shouldShellComplete() { return false; } public function run() { $pos = $this->getArgument('current'); $argv = $this->getArgument('argv', array()); $argc = count($argv); if ($pos === null) { $pos = $argc - 1; } // Determine which revision control system the working copy uses, so we // can filter out commands and flags which aren't supported. If we can't // figure it out, just return all flags/commands. $vcs = null; // We have to build our own because if we requiresWorkingCopy() we'll throw // if we aren't in a .arcconfig directory. We probably still can't do much, // but commands can raise more detailed errors. $working_copy = ArcanistWorkingCopyIdentity::newFromPath(getcwd()); if ($working_copy->getProjectRoot()) { $repository_api = ArcanistRepositoryAPI::newAPIFromWorkingCopyIdentity( $working_copy); $vcs = $repository_api->getSourceControlSystemName(); } $arc_config = $this->getArcanistConfiguration(); if ($pos == 1) { $workflows = $arc_config->buildAllWorkflows(); $complete = array(); foreach ($workflows as $name => $workflow) { if (!$workflow->shouldShellComplete()) { continue; } $supported = $workflow->getSupportedRevisionControlSystems(); $ok = (in_array('any', $supported)) || (in_array($vcs, $supported)); if (!$ok) { continue; } $complete[] = $name; } // Also permit autocompletion of "arc alias" commands. foreach (ArcanistAliasWorkflow::getAliases() as $key => $value) { $complete[] = $key; } echo implode(' ', $complete)."\n"; return 0; } else { $workflow = $arc_config->buildWorkflow($argv[1]); if (!$workflow) { list($new_command, $new_args) = ArcanistAliasWorkflow::resolveAliases( $argv[1], $arc_config, array_slice($argv, 2)); if ($new_command) { $workflow = $arc_config->buildWorkflow($new_command); } if (!$workflow) { return 1; } else { $argv = array_merge( array($argv[0]), array($new_command), $new_args); } } $arguments = $workflow->getArguments(); $prev = idx($argv, $pos - 1, null); if (!strncmp($prev, '--', 2)) { $prev = substr($prev, 2); } else { $prev = null; } if ($prev !== null && isset($arguments[$prev]) && isset($arguments[$prev]['param'])) { $type = idx($arguments[$prev], 'paramtype'); switch ($type) { case 'file': echo "FILE\n"; break; case 'complete': echo implode(' ', $workflow->getShellCompletions($argv))."\n"; break; default: echo "ARGUMENT\n"; break; } return 0; } else { $output = array(); foreach ($arguments as $argument => $spec) { if ($argument == '*') { continue; } if ($vcs && isset($spec['supports']) && !in_array($vcs, $spec['supports'])) { continue; } $output[] = '--'.$argument; } $cur = idx($argv, $pos, ''); $any_match = false; foreach ($output as $possible) { if (!strncmp($possible, $cur, strlen($cur))) { $any_match = true; } } if (!$any_match && isset($arguments['*'])) { // TODO: the '*' specifier should probably have more details about // whether or not it is a list of files. Since it almost always is in // practice, assume FILE for now. echo "FILE\n"; } else { echo implode(' ', $output)."\n"; } return 0; } } } } diff --git a/src/workflow/svn-hook-pre-commit/ArcanistSvnHookPreCommitWorkflow.php b/src/workflow/svn-hook-pre-commit/ArcanistSvnHookPreCommitWorkflow.php index a8fc9dde..975f8511 100644 --- a/src/workflow/svn-hook-pre-commit/ArcanistSvnHookPreCommitWorkflow.php +++ b/src/workflow/svn-hook-pre-commit/ArcanistSvnHookPreCommitWorkflow.php @@ -1,238 +1,244 @@ 'svnargs', ); } public function shouldShellComplete() { return false; } public function run() { $svnargs = $this->getArgument('svnargs'); $repository = $svnargs[0]; $transaction = $svnargs[1]; list($commit_message) = execx( 'svnlook log --transaction %s %s', $transaction, $repository); if (strpos($commit_message, '@bypass-lint') !== false) { return 0; } // TODO: Do stuff with commit message. list($changed) = execx( 'svnlook changed --transaction %s %s', $transaction, $repository); $paths = array(); $changed = explode("\n", trim($changed)); foreach ($changed as $line) { $matches = null; preg_match('/^..\s*(.*)$/', $line, $matches); $paths[$matches[1]] = strlen($matches[1]); } $resolved = array(); $failed = array(); $missing = array(); $found = array(); asort($paths); foreach ($paths as $path => $length) { foreach ($resolved as $rpath => $root) { if (!strncmp($path, $rpath, strlen($rpath))) { $resolved[$path] = $root; continue 2; } } $config = $path; if (basename($config) == '.arcconfig') { $resolved[$config] = $config; continue; } $config = rtrim($config, '/'); $last_config = $config; do { if (!empty($missing[$config])) { break; } else if (!empty($found[$config])) { $resolved[$path] = $found[$config]; break; } list($err) = exec_manual( 'svnlook cat --transaction %s %s %s', $transaction, $repository, $config ? $config.'/.arcconfig' : '.arcconfig'); if ($err) { $missing[$path] = true; } else { $resolved[$path] = $config ? $config.'/.arcconfig' : '.arcconfig'; $found[$config] = $resolved[$path]; break; } $config = dirname($config); if ($config == '.') { $config = ''; } if ($config == $last_config) { break; } $last_config = $config; } while (true); if (empty($resolved[$path])) { $failed[] = $path; } } if ($failed && $resolved) { $failed_paths = ' '.implode("\n ", $failed); $resolved_paths = ' '.implode("\n ", array_keys($resolved)); throw new ArcanistUsageException( "This commit includes a mixture of files in Arcanist projects and ". "outside of Arcanist projects. A commit which affects an Arcanist ". "project must affect only that project.\n\n". "Files in projects:\n\n". $resolved_paths."\n\n". "Files not in projects:\n\n". $failed_paths); } if (!$resolved) { // None of the affected paths are beneath a .arcconfig file. return 0; } $groups = array(); foreach ($resolved as $path => $project) { $groups[$project][] = $path; } if (count($groups) > 1) { $message = array(); foreach ($groups as $config => $group) { $message[] = "Files underneath '{$config}':\n\n"; $message[] = " ".implode("\n ", $group)."\n\n"; } $message = implode('', $message); throw new ArcanistUsageException( "This commit includes a mixture of files from different Arcanist ". "projects. A commit which affects an Arcanist project must affect ". "only that project.\n\n". $message); } $config_file = key($groups); $project_root = dirname($config_file); $paths = reset($groups); list($config) = execx( 'svnlook cat --transaction %s %s %s', $transaction, $repository, $config_file); $working_copy = ArcanistWorkingCopyIdentity::newFromRootAndConfigFile( $project_root, $config, $config_file." (svnlook: {$transaction} {$repository})"); $repository_api = new ArcanistSubversionHookAPI( $project_root, $transaction, $repository); $lint_engine = $working_copy->getConfig('lint_engine'); if (!$lint_engine) { return 0; } PhutilSymbolLoader::loadClass($lint_engine); $engine = newv($lint_engine, array()); $engine->setWorkingCopy($working_copy); $engine->setMinimumSeverity(ArcanistLintSeverity::SEVERITY_ERROR); $engine->setPaths($paths); $engine->setCommitHookMode(true); $engine->setHookAPI($repository_api); try { $results = $engine->run(); } catch (ArcanistNoEffectException $no_effect) { // Nothing to do, bail out. return 0; } $renderer = new ArcanistLintRenderer(); $failures = array(); foreach ($results as $result) { if (!$result->getMessages()) { continue; } $failures[] = $result; } if ($failures) { $at = "@"; $msg = phutil_console_format( "\n**LINT ERRORS**\n\n". "This changeset has lint errors. You must fix all lint errors before ". "you can commit.\n\n". "You can add '{$at}bypass-lint' to your commit message to disable ". "lint checks for this commit, or '{$at}nolint' to the file with ". "errors to disable lint for that file.\n\n"); echo phutil_console_wrap($msg); foreach ($failures as $result) { echo $renderer->renderLintResult($result); } return 1; } return 0; } } diff --git a/src/workflow/unit/ArcanistUnitWorkflow.php b/src/workflow/unit/ArcanistUnitWorkflow.php index bd2e08e6..f263d049 100644 --- a/src/workflow/unit/ArcanistUnitWorkflow.php +++ b/src/workflow/unit/ArcanistUnitWorkflow.php @@ -1,346 +1,352 @@ array( 'param' => 'revision', 'help' => "Run unit tests covering changes since a specific revision.", 'supports' => array( 'git', 'hg', ), 'nosupport' => array( 'svn' => "Arc unit does not currently support --rev in SVN.", ), ), 'engine' => array( 'param' => 'classname', 'help' => "Override configured unit engine for this project." ), 'coverage' => array( 'help' => 'Always enable coverage information.', 'conflicts' => array( 'no-coverage' => null, ), ), 'no-coverage' => array( 'help' => 'Always disable coverage information.', ), 'detailed-coverage' => array( 'help' => "Show a detailed coverage report on the CLI. Implies ". "--coverage.", ), '*' => 'paths', ); } public function requiresWorkingCopy() { return true; } public function requiresRepositoryAPI() { return true; } public function getEngine() { return $this->engine; } public function run() { $working_copy = $this->getWorkingCopy(); $engine_class = $this->getArgument( 'engine', $working_copy->getConfig('unit_engine')); if (!$engine_class) { throw new ArcanistNoEngineException( "No unit test engine is configured for this project. Edit .arcconfig ". "to specify a unit test engine."); } $paths = $this->getArgument('paths'); $rev = $this->getArgument('rev'); $paths = $this->selectPathsForWorkflow($paths, $rev); PhutilSymbolLoader::loadClass($engine_class); if (!is_subclass_of($engine_class, 'ArcanistBaseUnitTestEngine')) { throw new ArcanistUsageException( "Configured unit test engine '{$engine_class}' is not a subclass of ". "'ArcanistBaseUnitTestEngine'."); } $this->engine = newv($engine_class, array()); $this->engine->setWorkingCopy($working_copy); $this->engine->setPaths($paths); $this->engine->setArguments($this->getPassthruArgumentsAsMap('unit')); $enable_coverage = null; // Means "default". if ($this->getArgument('coverage') || $this->getArgument('detailed-coverage')) { $enable_coverage = true; } else if ($this->getArgument('no-coverage')) { $enable_coverage = false; } $this->engine->setEnableCoverage($enable_coverage); // Enable possible async tests only for 'arc diff' not 'arc unit' if ($this->getParentWorkflow()) { $this->engine->setEnableAsyncTests(true); } else { $this->engine->setEnableAsyncTests(false); } $results = $this->engine->run(); $this->testResults = $results; $status_codes = array( ArcanistUnitTestResult::RESULT_PASS => phutil_console_format( '** PASS **'), ArcanistUnitTestResult::RESULT_FAIL => phutil_console_format( '** FAIL **'), ArcanistUnitTestResult::RESULT_SKIP => phutil_console_format( '** SKIP **'), ArcanistUnitTestResult::RESULT_BROKEN => phutil_console_format( '** BROKEN **'), ArcanistUnitTestResult::RESULT_UNSOUND => phutil_console_format( '** UNSOUND **'), ArcanistUnitTestResult::RESULT_POSTPONED => phutil_console_format( '** POSTPONED **'), ); $unresolved = array(); $coverage = array(); $postponed_count = 0; foreach ($results as $result) { $result_code = $result->getResult(); if ($result_code == ArcanistUnitTestResult::RESULT_POSTPONED) { $postponed_count++; $unresolved[] = $result; } else { if ($this->engine->shouldEchoTestResults()) { echo ' '.$status_codes[$result_code]; if ($result_code == ArcanistUnitTestResult::RESULT_PASS) { echo ' '.self::formatTestDuration($result->getDuration()); } echo ' '.$result->getName()."\n"; } if ($result_code != ArcanistUnitTestResult::RESULT_PASS) { if ($this->engine->shouldEchoTestResults()) { echo $result->getUserData()."\n"; } $unresolved[] = $result; } } if ($result->getCoverage()) { foreach ($result->getCoverage() as $file => $report) { $coverage[$file][] = $report; } } } if ($postponed_count) { echo sprintf("%s %d %s\n", $status_codes[ArcanistUnitTestResult::RESULT_POSTPONED], $postponed_count, ($postponed_count > 1)?'tests':'test'); } if ($coverage) { $file_coverage = array_fill_keys( $paths, 0); $file_reports = array(); foreach ($coverage as $file => $reports) { $report = ArcanistUnitTestResult::mergeCoverage($reports); $cov = substr_count($report, 'C'); $uncov = substr_count($report, 'U'); if ($cov + $uncov) { $coverage = $cov / ($cov + $uncov); } else { $coverage = 0; } $file_coverage[$file] = $coverage; $file_reports[$file] = $report; } echo "\n"; echo phutil_console_format('__COVERAGE REPORT__'); echo "\n"; asort($file_coverage); foreach ($file_coverage as $file => $coverage) { echo phutil_console_format( " **%s%%** %s\n", sprintf('% 3d', (int)(100 * $coverage)), $file); $full_path = $working_copy->getProjectRoot().'/'.$file; if ($this->getArgument('detailed-coverage') && Filesystem::pathExists($full_path) && is_file($full_path)) { echo $this->renderDetailedCoverageReport( Filesystem::readFile($full_path), $file_reports[$file]); } } } $this->unresolvedTests = $unresolved; $overall_result = self::RESULT_OKAY; foreach ($results as $result) { $result_code = $result->getResult(); if ($result_code == ArcanistUnitTestResult::RESULT_FAIL || $result_code == ArcanistUnitTestResult::RESULT_BROKEN) { $overall_result = self::RESULT_FAIL; break; } else if ($result_code == ArcanistUnitTestResult::RESULT_UNSOUND) { $overall_result = self::RESULT_UNSOUND; } else if ($result_code == ArcanistUnitTestResult::RESULT_POSTPONED && $overall_result != self::RESULT_UNSOUND) { $overall_result = self::RESULT_POSTPONED; } } return $overall_result; } public function getUnresolvedTests() { return $this->unresolvedTests; } public function getTestResults() { return $this->testResults; } public function setDifferentialDiffID($id) { if ($this->engine) { $this->engine->setDifferentialDiffID($id); } } private static function formatTestDuration($seconds) { // Very carefully define inclusive upper bounds on acceptable unit test // durations. Times are in milliseconds and are in increasing order. $acceptableness = array( 50 => "%s\xE2\x98\x85 ", 200 => '%s ', 500 => '%s ', INF => '%s ', ); $milliseconds = $seconds * 1000; $duration = self::formatTime($seconds); foreach ($acceptableness as $upper_bound => $formatting) { if ($milliseconds <= $upper_bound) { return phutil_console_format($formatting, $duration); } } return phutil_console_format(end($acceptableness), $duration); } private static function formatTime($seconds) { if ($seconds >= 60) { $minutes = floor($seconds / 60); return sprintf('%dm%02ds', $minutes, round($seconds % 60)); } if ($seconds >= 1) { return sprintf('%4.1fs', $seconds); } $milliseconds = $seconds * 1000; if ($milliseconds >= 1) { return sprintf('%3dms', round($milliseconds)); } return ' <1ms'; } private function renderDetailedCoverageReport($data, $report) { $data = explode("\n", $data); $out = ''; $n = 0; foreach ($data as $line) { $out .= sprintf('% 5d ', $n + 1); $line = str_pad($line, 80, ' '); if (empty($report[$n])) { $c = 'N'; } else { $c = $report[$n]; } switch ($c) { case 'C': $out .= phutil_console_format( ' %s ', $line); break; case 'U': $out .= phutil_console_format( ' %s ', $line); break; case 'X': $out .= phutil_console_format( ' %s ', $line); break; default: $out .= ' '.$line.' '; break; } $out .= "\n"; $n++; } return $out; } } diff --git a/src/workflow/upload/ArcanistUploadWorkflow.php b/src/workflow/upload/ArcanistUploadWorkflow.php index 0bbbe1a1..ea108259 100644 --- a/src/workflow/upload/ArcanistUploadWorkflow.php +++ b/src/workflow/upload/ArcanistUploadWorkflow.php @@ -1,115 +1,120 @@ array( 'help' => 'Output upload information in JSON format.', ), '*' => 'paths', ); } protected function didParseArguments() { if (!$this->getArgument('paths')) { throw new ArcanistUsageException("Specify one or more files to upload."); } $this->paths = $this->getArgument('paths'); $this->json = $this->getArgument('json'); } public function requiresAuthentication() { return true; } private function getPaths() { return $this->paths; } private function getJSON() { return $this->json; } public function run() { $conduit = $this->getConduit(); $results = array(); foreach ($this->paths as $path) { $name = basename($path); $this->writeStatusMessage("Uploading '{$name}'...\n"); try { $data = Filesystem::readFile($path); } catch (FilesystemException $ex) { $this->writeStatusMessage( "Unable to upload file: ".$ex->getMessage()."\n"); $results[$path] = null; continue; } $phid = $conduit->callMethodSynchronous( 'file.upload', array( 'data_base64' => base64_encode($data), 'name' => $name, )); $info = $conduit->callMethodSynchronous( 'file.info', array( 'phid' => $phid, )); $results[$path] = $info; if (!$this->getJSON()) { echo " {$name}: ".$info['uri']."\n\n"; } } if ($this->getJSON()) { echo json_encode($results)."\n"; } else { $this->writeStatusMessage("Done.\n"); } return 0; } } diff --git a/src/workflow/which/ArcanistWhichWorkflow.php b/src/workflow/which/ArcanistWhichWorkflow.php index d344b0e1..56919bdf 100644 --- a/src/workflow/which/ArcanistWhichWorkflow.php +++ b/src/workflow/which/ArcanistWhichWorkflow.php @@ -1,116 +1,122 @@ array( 'help' => "Show revisions by any author, not just you.", ), 'any-status' => array( 'help' => "Show committed and abandoned revisions.", ), 'id' => array( 'help' => "If exactly one revision matches, print it to stdout. ". "Otherwise, exit with an error. Intended for scripts.", ), '*' => 'commit', ); } public function run() { $repository_api = $this->getRepositoryAPI(); $commit = $this->getArgument('commit'); if (count($commit)) { if (!$repository_api->supportsRelativeLocalCommits()) { throw new ArcanistUsageException( "This version control system does not support relative commits."); } else { $repository_api->parseRelativeLocalCommit($commit); } } $any_author = $this->getArgument('any-author'); $any_status = $this->getArgument('any-status'); $query = array( 'authors' => $any_author ? null : array($this->getUserPHID()), 'status' => $any_status ? 'status-any' : 'status-open', ); $revisions = $repository_api->loadWorkingCopyDifferentialRevisions( $this->getConduit(), $query); if (empty($revisions)) { $this->writeStatusMessage("No matching revisions.\n"); return 1; } if ($this->getArgument('id')) { if (count($revisions) == 1) { echo idx(head($revisions), 'id'); return 0; } else { $this->writeStatusMessage("More than one matching revision.\n"); return 1; } } foreach ($revisions as $revision) { echo 'D'.$revision['id'].' '.$revision['title']."\n"; } return 0; } }