diff --git a/.gitignore b/.gitignore index 096c30cb..d40646a4 100644 --- a/.gitignore +++ b/.gitignore @@ -1,35 +1,38 @@ # NOTE: Thinking about adding files created by your operating system, IDE, # or text editor here? Don't! Add them to your per-user .gitignore instead. # Diviner /docs/ /.divinercache/ # libphutil /src/.phutil_module_cache # User extensions /externals/includes/* /src/extensions/* # XHPAST /support/xhpast/*.a /support/xhpast/*.o /support/xhpast/parser.yacc.output /support/xhpast/node_names.hpp /support/xhpast/xhpast /support/xhpast/xhpast.exe /src/parser/xhpast/bin/xhpast ## NOTE: Don't .gitignore these files! Even though they're build artifacts, we ## want to check them in so users can build xhpast without flex/bison. # /support/xhpast/parser.yacc.cpp # /support/xhpast/parser.yacc.hpp # /support/xhpast/scanner.lex.cpp # /support/xhpast/scanner.lex.hpp # This is an OS X build artifact. /support/xhpast/xhpast.dSYM # Generated shell completion rulesets. /support/shell/rules/ + +# Python extension compiled files. +/support/hg/arc-hg.pyc diff --git a/scripts/arcanist.php b/scripts/arcanist.php index 76aacb55..af296a11 100755 --- a/scripts/arcanist.php +++ b/scripts/arcanist.php @@ -1,690 +1,698 @@ #!/usr/bin/env php parseStandardArguments(); $base_args->parsePartial( array( array( 'name' => 'load-phutil-library', 'param' => 'path', 'help' => pht('Load a libphutil library.'), 'repeat' => true, ), + array( + 'name' => 'library', + 'param' => 'path', + 'help' => pht('Load a library (same as --load-phutil-library).'), + 'repeat' => true, + ), array( 'name' => 'arcrc-file', 'param' => 'filename', ), array( 'name' => 'conduit-uri', 'param' => 'uri', 'help' => pht('Connect to Phabricator install specified by __uri__.'), ), array( 'name' => 'conduit-token', 'param' => 'token', 'help' => pht('Use a specific authentication token.'), ), array( 'name' => 'anonymous', 'help' => pht('Run workflow as a public user, without authenticating.'), ), array( 'name' => 'config', 'param' => 'key=value', 'repeat' => true, 'help' => pht( 'Specify a runtime configuration value. This will take precedence '. 'over static values, and only affect the current arcanist invocation.'), ), )); $config_trace_mode = $base_args->getArg('trace'); $force_conduit = $base_args->getArg('conduit-uri'); $force_token = $base_args->getArg('conduit-token'); $custom_arcrc = $base_args->getArg('arcrc-file'); $is_anonymous = $base_args->getArg('anonymous'); -$load = $base_args->getArg('load-phutil-library'); +$load = array_merge( + $base_args->getArg('load-phutil-library'), + $base_args->getArg('library')); $help = $base_args->getArg('help'); $args = array_values($base_args->getUnconsumedArgumentVector()); $working_directory = getcwd(); $console = PhutilConsole::getConsole(); $config = null; $workflow = null; try { if ($config_trace_mode) { echo tsprintf( "** %s ** %s\n", pht('ARGV'), csprintf('%Ls', $original_argv)); $libraries = array( 'arcanist', ); foreach ($libraries as $library_name) { echo tsprintf( "** %s ** %s\n", pht('LOAD'), pht( 'Loaded "%s" from "%s".', $library_name, phutil_get_library_root($library_name))); } } if (!$args) { if ($help) { $args = array('help'); } else { throw new ArcanistUsageException( pht('No command provided. Try `%s`.', 'arc help')); } } else if ($help) { array_unshift($args, 'help'); } $configuration_manager = new ArcanistConfigurationManager(); if ($custom_arcrc) { $configuration_manager->setUserConfigurationFileLocation($custom_arcrc); } $global_config = $configuration_manager->readUserArcConfig(); $system_config = $configuration_manager->readSystemArcConfig(); $runtime_config = $configuration_manager->applyRuntimeArcConfig($base_args); $working_copy = ArcanistWorkingCopyIdentity::newFromPath($working_directory); $configuration_manager->setWorkingCopyIdentity($working_copy); // Load additional libraries, which can provide new classes like configuration // overrides, linters and lint engines, unit test engines, etc. // If the user specified "--load-phutil-library" one or more times from // the command line, we load those libraries **instead** of whatever else // is configured. This is basically a debugging feature to let you force // specific libraries to load regardless of the state of the world. if ($load) { $console->writeLog( "%s\n", pht( 'Using `%s` flag, configuration will be ignored and configured '. 'libraries will not be loaded.', '--load-phutil-library')); // Load the flag libraries. These must load, since the user specified them // explicitly. arcanist_load_libraries( $load, $must_load = true, $lib_source = pht('a "%s" flag', '--load-phutil-library'), $working_copy); } else { // Load libraries in system 'load' config. In contrast to global config, we // fail hard here because this file is edited manually, so if 'arc' breaks // that doesn't make it any more difficult to correct. arcanist_load_libraries( idx($system_config, 'load', array()), $must_load = true, $lib_source = pht('the "%s" setting in system config', 'load'), $working_copy); // Load libraries in global 'load' config, as per "arc set-config load". We // need to fail softly if these break because errors would prevent the user // from running "arc set-config" to correct them. arcanist_load_libraries( idx($global_config, 'load', array()), $must_load = false, $lib_source = pht('the "%s" setting in global config', 'load'), $working_copy); // Load libraries in ".arcconfig". Libraries here must load. arcanist_load_libraries( $working_copy->getProjectConfig('load'), $must_load = true, $lib_source = pht('the "%s" setting in "%s"', 'load', '.arcconfig'), $working_copy); // Load libraries in ".arcconfig". Libraries here must load. arcanist_load_libraries( idx($runtime_config, 'load', array()), $must_load = true, $lib_source = pht('the %s argument', '--config "load=[...]"'), $working_copy); } $user_config = $configuration_manager->readUserConfigurationFile(); $config = new ArcanistConfiguration(); $command = strtolower($args[0]); $args = array_slice($args, 1); $workflow = $config->selectWorkflow( $command, $args, $configuration_manager, $console); $workflow->setConfigurationManager($configuration_manager); $workflow->setArcanistConfiguration($config); $workflow->setCommand($command); $workflow->setWorkingDirectory($working_directory); $workflow->parseArguments($args); // Write the command into the environment so that scripts (for example, local // Git commit hooks) can detect that they're being run via `arc` and change // their behaviors. putenv('ARCANIST='.$command); $need_working_copy = $workflow->requiresWorkingCopy(); $supported_vcs_types = $workflow->getSupportedRevisionControlSystems(); $vcs_type = $working_copy->getVCSType(); if ($vcs_type || $need_working_copy) { if (!in_array($vcs_type, $supported_vcs_types)) { throw new ArcanistUsageException( pht( '`%s %s` is only supported under %s.', 'arc', $workflow->getWorkflowName(), implode(', ', $supported_vcs_types))); } } $need_conduit = $workflow->requiresConduit(); $need_auth = $workflow->requiresAuthentication(); $need_repository_api = $workflow->requiresRepositoryAPI(); $want_repository_api = $workflow->desiresRepositoryAPI(); $want_working_copy = $workflow->desiresWorkingCopy() || $want_repository_api; $need_conduit = $need_conduit || $need_auth; $need_working_copy = $need_working_copy || $need_repository_api; if ($need_working_copy || $want_working_copy) { if ($need_working_copy && !$working_copy->getVCSType()) { throw new ArcanistUsageException( pht( 'This command must be run in a Git, Mercurial or Subversion '. 'working copy.')); } $configuration_manager->setWorkingCopyIdentity($working_copy); } if ($force_conduit) { $conduit_uri = $force_conduit; } else { $conduit_uri = $configuration_manager->getConfigFromAnySource( 'phabricator.uri'); if ($conduit_uri === null) { $conduit_uri = $configuration_manager->getConfigFromAnySource('default'); } } 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); // Apply global CA bundle from configs. $ca_bundle = $configuration_manager->getConfigFromAnySource('https.cabundle'); if ($ca_bundle) { $ca_bundle = Filesystem::resolvePath( $ca_bundle, $working_copy->getProjectRoot()); HTTPSFuture::setGlobalCABundleFromPath($ca_bundle); } $blind_key = 'https.blindly-trust-domains'; $blind_trust = $configuration_manager->getConfigFromAnySource($blind_key); if ($blind_trust) { $trust_extension = PhutilHTTPEngineExtension::requireExtension( ArcanistBlindlyTrustHTTPEngineExtension::EXTENSIONKEY); $trust_extension->setDomains($blind_trust); } if ($need_conduit) { if (!$conduit_uri) { $message = phutil_console_format( "%s\n\n - %s\n - %s\n - %s\n", pht( 'This command requires arc to connect to a Phabricator install, '. 'but no Phabricator installation is configured. To configure a '. 'Phabricator URI:'), pht( 'set a default location with `%s`; or', 'arc set-config default '), pht( 'specify `%s` explicitly; or', '--conduit-uri=uri'), pht( "run `%s` in a working copy with an '%s'.", 'arc', '.arcconfig')); $message = phutil_console_wrap($message); throw new ArcanistUsageException($message); } $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'); $conduit_token = idx($host_config, 'token'); if ($force_token) { $conduit_token = $force_token; } if ($is_anonymous) { $conduit_token = null; } $description = implode(' ', $original_argv); $credentials = array( 'user' => $user_name, 'certificate' => $certificate, 'description' => $description, 'token' => $conduit_token, ); $workflow->setConduitCredentials($credentials); $engine = id(new ArcanistConduitEngine()) ->setConduitURI($conduit_uri) ->setConduitToken($conduit_token); $workflow->setConduitEngine($engine); if ($need_auth) { if ((!$user_name || !$certificate) && (!$conduit_token)) { $arc = 'arc'; if ($force_conduit) { $arc .= csprintf(' --conduit-uri=%s', $conduit_uri); } $conduit_domain = id(new PhutilURI($conduit_uri))->getDomain(); throw new ArcanistUsageException( phutil_console_format( "%s\n\n%s\n\n%s **%s:**\n\n $ **{$arc} install-certificate**\n", pht('YOU NEED TO AUTHENTICATE TO CONTINUE'), pht( 'You are trying to connect to a server (%s) that you '. 'do not have any credentials stored for.', $conduit_domain), pht('To retrieve and store credentials for this server,'), pht('run this command'))); } $workflow->authenticateConduit(); } if ($need_repository_api || ($want_repository_api && $working_copy->getVCSType())) { $repository_api = ArcanistRepositoryAPI::newAPIFromConfigurationManager( $configuration_manager); $workflow->setRepositoryAPI($repository_api); } $listeners = $configuration_manager->getConfigFromAnySource( 'events.listeners'); if ($listeners) { foreach ($listeners as $listener) { $console->writeLog( "%s\n", pht("Registering event listener '%s'.", $listener)); try { id(new $listener())->register(); } catch (PhutilMissingSymbolException $ex) { // Continue anyway, since you may otherwise be unable to run commands // like `arc set-config events.listeners` in order to repair the damage // you've caused. We're writing out the entire exception here because // it might not have been triggered by the listener itself (for example, // the listener might use a bad class in its register() method). $console->writeErr( "%s\n", pht( "ERROR: Failed to load event listener '%s': %s", $listener, $ex->getMessage())); } } } $config->willRunWorkflow($command, $workflow); $workflow->willRunWorkflow(); try { $err = $workflow->run(); $config->didRunWorkflow($command, $workflow, $err); } catch (Exception $e) { $workflow->finalize(); throw $e; } $workflow->finalize(); exit((int)$err); } catch (ArcanistNoEffectException $ex) { echo $ex->getMessage()."\n"; } catch (Exception $ex) { $is_usage = ($ex instanceof ArcanistUsageException); if ($is_usage) { fwrite(STDERR, phutil_console_format( "**%s** %s\n", pht('Usage Exception:'), rtrim($ex->getMessage()))); } if ($config) { $config->didAbortWorkflow($command, $workflow, $ex); } if ($config_trace_mode) { fwrite(STDERR, "\n"); throw $ex; } if (!$is_usage) { fwrite(STDERR, phutil_console_format( "** %s **\n", pht('Exception'))); while ($ex) { fwrite(STDERR, $ex->getMessage()."\n"); if ($ex instanceof PhutilProxyException) { $ex = $ex->getPreviousException(); } else { $ex = null; } } fwrite(STDERR, phutil_console_format( "(%s)\n", pht('Run with `%s` for a full exception trace.', '--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() { // NOTE: We don't have phutil_is_windows() yet here. $is_windows = (DIRECTORY_SEPARATOR != '/'); // We use stream_socket_pair() which is not available on Windows earlier. $min_version = ($is_windows ? '5.3.0' : '5.2.3'); $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}'."); } if ($is_windows) { $need_functions = array( 'curl_init' => array('builtin-dll', 'php_curl.dll'), ); } else { $need_functions = array( 'curl_init' => array( 'text', "You need to install the cURL PHP extension, maybe with ". "'apt-get install php5-curl' or 'yum install php53-curl' or ". "something similar.", ), 'json_decode' => array('flag', '--without-json'), ); } $problems = array(); $config = null; $show_config = false; 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]; } } $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 '{$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."; } 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 ($what == 'text') { $generic = false; $problems[] = $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) { // NOTE: We're bailing because PHP is broken. We can't call any library // functions because they won't be loaded yet. echo "\n"; echo 'PHP CONFIGURATION ERRORS'; echo "\n\n"; echo $message; echo "\n\n"; exit(1); } function arcanist_load_libraries( $load, $must_load, $lib_source, ArcanistWorkingCopyIdentity $working_copy) { if (!$load) { return; } if (!is_array($load)) { $error = pht( 'Libraries specified by %s are invalid; expected a list. '. 'Check your configuration.', $lib_source); $console = PhutilConsole::getConsole(); $console->writeErr("%s: %s\n", pht('WARNING'), $error); return; } foreach ($load as $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. // // Note that absolute paths will just resolve absolutely through rule (1). $resolved = false; // Check inside the working copy. This also checks absolute paths, since // they'll resolve absolute and just ignore the project root. $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; } } $console = PhutilConsole::getConsole(); $console->writeLog( "%s\n", pht("Loading phutil library from '%s'...", $location)); $error = null; try { phutil_load_library($location); } catch (PhutilBootloaderException $ex) { $error = pht( "Failed to load phutil library at location '%s'. This library ". "is specified by %s. Check that the setting is correct and the ". "library is located in the right place.", $location, $lib_source); if ($must_load) { throw new ArcanistUsageException($error); } else { fwrite(STDERR, phutil_console_wrap( phutil_console_format("%s: %s\n", pht('WARNING'), $error))); } } catch (PhutilLibraryConflictException $ex) { if ($ex->getLibrary() != 'arcanist') { throw $ex; } // NOTE: If you are running `arc` against itself, we ignore the library // conflict created by loading the local `arc` library (in the current // working directory) and continue without loading it. // This means we only execute code in the `arcanist/` directory which is // associated with the binary you are running, whereas we would normally // execute local code. // This can make `arc` development slightly confusing if your setup is // especially bizarre, but it allows `arc` to be used in automation // workflows more easily. For some context, see PHI13. $executing_directory = dirname(dirname(__FILE__)); $working_directory = dirname($location); fwrite( STDERR, tsprintf( "** %s ** %s\n", pht('VERY META'), pht( 'You are running one copy of Arcanist (at path "%s") against '. 'another copy of Arcanist (at path "%s"). Code in the current '. 'working directory will not be loaded or executed.', $executing_directory, $working_directory))); } } } diff --git a/src/__phutil_library_map__.php b/src/__phutil_library_map__.php index d4f29121..90e2e2ef 100644 --- a/src/__phutil_library_map__.php +++ b/src/__phutil_library_map__.php @@ -1,1879 +1,1988 @@ 2, 'class' => array( 'AASTNode' => 'parser/aast/api/AASTNode.php', 'AASTNodeList' => 'parser/aast/api/AASTNodeList.php', 'AASTToken' => 'parser/aast/api/AASTToken.php', 'AASTTree' => 'parser/aast/api/AASTTree.php', 'AbstractDirectedGraph' => 'utils/AbstractDirectedGraph.php', 'AbstractDirectedGraphTestCase' => 'utils/__tests__/AbstractDirectedGraphTestCase.php', 'ArcanistAbstractMethodBodyXHPASTLinterRule' => 'lint/linter/xhpast/rules/ArcanistAbstractMethodBodyXHPASTLinterRule.php', 'ArcanistAbstractMethodBodyXHPASTLinterRuleTestCase' => 'lint/linter/xhpast/rules/__tests__/ArcanistAbstractMethodBodyXHPASTLinterRuleTestCase.php', 'ArcanistAbstractPrivateMethodXHPASTLinterRule' => 'lint/linter/xhpast/rules/ArcanistAbstractPrivateMethodXHPASTLinterRule.php', 'ArcanistAbstractPrivateMethodXHPASTLinterRuleTestCase' => 'lint/linter/xhpast/rules/__tests__/ArcanistAbstractPrivateMethodXHPASTLinterRuleTestCase.php', 'ArcanistAlias' => 'toolset/ArcanistAlias.php', 'ArcanistAliasEffect' => 'toolset/ArcanistAliasEffect.php', 'ArcanistAliasEngine' => 'toolset/ArcanistAliasEngine.php', 'ArcanistAliasFunctionXHPASTLinterRule' => 'lint/linter/xhpast/rules/ArcanistAliasFunctionXHPASTLinterRule.php', 'ArcanistAliasFunctionXHPASTLinterRuleTestCase' => 'lint/linter/xhpast/rules/__tests__/ArcanistAliasFunctionXHPASTLinterRuleTestCase.php', 'ArcanistAliasWorkflow' => 'toolset/workflow/ArcanistAliasWorkflow.php', 'ArcanistAliasesConfigOption' => 'config/option/ArcanistAliasesConfigOption.php', 'ArcanistAmendWorkflow' => 'workflow/ArcanistAmendWorkflow.php', 'ArcanistAnoidWorkflow' => 'workflow/ArcanistAnoidWorkflow.php', 'ArcanistArcConfigurationEngineExtension' => 'config/arc/ArcanistArcConfigurationEngineExtension.php', 'ArcanistArcToolset' => 'toolset/ArcanistArcToolset.php', 'ArcanistArcWorkflow' => 'toolset/workflow/ArcanistArcWorkflow.php', 'ArcanistArrayCombineXHPASTLinterRule' => 'lint/linter/xhpast/rules/ArcanistArrayCombineXHPASTLinterRule.php', 'ArcanistArrayCombineXHPASTLinterRuleTestCase' => 'lint/linter/xhpast/rules/__tests__/ArcanistArrayCombineXHPASTLinterRuleTestCase.php', 'ArcanistArrayIndexSpacingXHPASTLinterRule' => 'lint/linter/xhpast/rules/ArcanistArrayIndexSpacingXHPASTLinterRule.php', 'ArcanistArrayIndexSpacingXHPASTLinterRuleTestCase' => 'lint/linter/xhpast/rules/__tests__/ArcanistArrayIndexSpacingXHPASTLinterRuleTestCase.php', 'ArcanistArraySeparatorXHPASTLinterRule' => 'lint/linter/xhpast/rules/ArcanistArraySeparatorXHPASTLinterRule.php', 'ArcanistArraySeparatorXHPASTLinterRuleTestCase' => 'lint/linter/xhpast/rules/__tests__/ArcanistArraySeparatorXHPASTLinterRuleTestCase.php', 'ArcanistArrayValueXHPASTLinterRule' => 'lint/linter/xhpast/rules/ArcanistArrayValueXHPASTLinterRule.php', 'ArcanistArrayValueXHPASTLinterRuleTestCase' => 'lint/linter/xhpast/rules/__tests__/ArcanistArrayValueXHPASTLinterRuleTestCase.php', 'ArcanistBaseCommitParser' => 'parser/ArcanistBaseCommitParser.php', 'ArcanistBaseCommitParserTestCase' => 'parser/__tests__/ArcanistBaseCommitParserTestCase.php', 'ArcanistBaseXHPASTLinter' => 'lint/linter/ArcanistBaseXHPASTLinter.php', 'ArcanistBinaryExpressionSpacingXHPASTLinterRule' => 'lint/linter/xhpast/rules/ArcanistBinaryExpressionSpacingXHPASTLinterRule.php', 'ArcanistBinaryExpressionSpacingXHPASTLinterRuleTestCase' => 'lint/linter/xhpast/rules/__tests__/ArcanistBinaryExpressionSpacingXHPASTLinterRuleTestCase.php', 'ArcanistBinaryNumericScalarCasingXHPASTLinterRule' => 'lint/linter/xhpast/rules/ArcanistBinaryNumericScalarCasingXHPASTLinterRule.php', 'ArcanistBinaryNumericScalarCasingXHPASTLinterRuleTestCase' => 'lint/linter/xhpast/rules/__tests__/ArcanistBinaryNumericScalarCasingXHPASTLinterRuleTestCase.php', 'ArcanistBlacklistedFunctionXHPASTLinterRule' => 'lint/linter/xhpast/rules/ArcanistBlacklistedFunctionXHPASTLinterRule.php', 'ArcanistBlacklistedFunctionXHPASTLinterRuleTestCase' => 'lint/linter/xhpast/rules/__tests__/ArcanistBlacklistedFunctionXHPASTLinterRuleTestCase.php', 'ArcanistBlindlyTrustHTTPEngineExtension' => 'configuration/ArcanistBlindlyTrustHTTPEngineExtension.php', - 'ArcanistBookmarkWorkflow' => 'workflow/ArcanistBookmarkWorkflow.php', + 'ArcanistBookmarksWorkflow' => 'workflow/ArcanistBookmarksWorkflow.php', + 'ArcanistBoolConfigOption' => 'config/option/ArcanistBoolConfigOption.php', 'ArcanistBraceFormattingXHPASTLinterRule' => 'lint/linter/xhpast/rules/ArcanistBraceFormattingXHPASTLinterRule.php', 'ArcanistBraceFormattingXHPASTLinterRuleTestCase' => 'lint/linter/xhpast/rules/__tests__/ArcanistBraceFormattingXHPASTLinterRuleTestCase.php', - 'ArcanistBranchRef' => 'ref/ArcanistBranchRef.php', - 'ArcanistBranchWorkflow' => 'workflow/ArcanistBranchWorkflow.php', + 'ArcanistBranchesWorkflow' => 'workflow/ArcanistBranchesWorkflow.php', 'ArcanistBrowseCommitHardpointQuery' => 'browse/query/ArcanistBrowseCommitHardpointQuery.php', 'ArcanistBrowseCommitURIHardpointQuery' => 'browse/query/ArcanistBrowseCommitURIHardpointQuery.php', 'ArcanistBrowseObjectNameURIHardpointQuery' => 'browse/query/ArcanistBrowseObjectNameURIHardpointQuery.php', 'ArcanistBrowsePathURIHardpointQuery' => 'browse/query/ArcanistBrowsePathURIHardpointQuery.php', 'ArcanistBrowseRef' => 'browse/ref/ArcanistBrowseRef.php', 'ArcanistBrowseRefInspector' => 'inspector/ArcanistBrowseRefInspector.php', 'ArcanistBrowseRevisionURIHardpointQuery' => 'browse/query/ArcanistBrowseRevisionURIHardpointQuery.php', 'ArcanistBrowseURIHardpointQuery' => 'browse/query/ArcanistBrowseURIHardpointQuery.php', 'ArcanistBrowseURIRef' => 'browse/ref/ArcanistBrowseURIRef.php', 'ArcanistBrowseWorkflow' => 'browse/workflow/ArcanistBrowseWorkflow.php', - 'ArcanistBuildPlanRef' => 'ref/ArcanistBuildPlanRef.php', - 'ArcanistBuildRef' => 'ref/ArcanistBuildRef.php', + 'ArcanistBuildBuildplanHardpointQuery' => 'ref/build/ArcanistBuildBuildplanHardpointQuery.php', + 'ArcanistBuildPlanRef' => 'ref/buildplan/ArcanistBuildPlanRef.php', + 'ArcanistBuildPlanSymbolRef' => 'ref/buildplan/ArcanistBuildPlanSymbolRef.php', + 'ArcanistBuildRef' => 'ref/build/ArcanistBuildRef.php', + 'ArcanistBuildSymbolRef' => 'ref/build/ArcanistBuildSymbolRef.php', + 'ArcanistBuildableBuildsHardpointQuery' => 'ref/buildable/ArcanistBuildableBuildsHardpointQuery.php', + 'ArcanistBuildableRef' => 'ref/buildable/ArcanistBuildableRef.php', + 'ArcanistBuildableSymbolRef' => 'ref/buildable/ArcanistBuildableSymbolRef.php', 'ArcanistBundle' => 'parser/ArcanistBundle.php', 'ArcanistBundleTestCase' => 'parser/__tests__/ArcanistBundleTestCase.php', 'ArcanistCSSLintLinter' => 'lint/linter/ArcanistCSSLintLinter.php', 'ArcanistCSSLintLinterTestCase' => 'lint/linter/__tests__/ArcanistCSSLintLinterTestCase.php', 'ArcanistCSharpLinter' => 'lint/linter/ArcanistCSharpLinter.php', 'ArcanistCallConduitWorkflow' => 'workflow/ArcanistCallConduitWorkflow.php', 'ArcanistCallParenthesesXHPASTLinterRule' => 'lint/linter/xhpast/rules/ArcanistCallParenthesesXHPASTLinterRule.php', 'ArcanistCallParenthesesXHPASTLinterRuleTestCase' => 'lint/linter/xhpast/rules/__tests__/ArcanistCallParenthesesXHPASTLinterRuleTestCase.php', 'ArcanistCallTimePassByReferenceXHPASTLinterRule' => 'lint/linter/xhpast/rules/ArcanistCallTimePassByReferenceXHPASTLinterRule.php', 'ArcanistCallTimePassByReferenceXHPASTLinterRuleTestCase' => 'lint/linter/xhpast/rules/__tests__/ArcanistCallTimePassByReferenceXHPASTLinterRuleTestCase.php', 'ArcanistCapabilityNotSupportedException' => 'workflow/exception/ArcanistCapabilityNotSupportedException.php', 'ArcanistCastSpacingXHPASTLinterRule' => 'lint/linter/xhpast/rules/ArcanistCastSpacingXHPASTLinterRule.php', 'ArcanistCastSpacingXHPASTLinterRuleTestCase' => 'lint/linter/xhpast/rules/__tests__/ArcanistCastSpacingXHPASTLinterRuleTestCase.php', 'ArcanistCheckstyleXMLLintRenderer' => 'lint/renderer/ArcanistCheckstyleXMLLintRenderer.php', 'ArcanistChmodLinter' => 'lint/linter/ArcanistChmodLinter.php', 'ArcanistChmodLinterTestCase' => 'lint/linter/__tests__/ArcanistChmodLinterTestCase.php', 'ArcanistClassExtendsObjectXHPASTLinterRule' => 'lint/linter/xhpast/rules/ArcanistClassExtendsObjectXHPASTLinterRule.php', 'ArcanistClassExtendsObjectXHPASTLinterRuleTestCase' => 'lint/linter/xhpast/rules/__tests__/ArcanistClassExtendsObjectXHPASTLinterRuleTestCase.php', 'ArcanistClassFilenameMismatchXHPASTLinterRule' => 'lint/linter/xhpast/rules/ArcanistClassFilenameMismatchXHPASTLinterRule.php', 'ArcanistClassMustBeDeclaredAbstractXHPASTLinterRule' => 'lint/linter/xhpast/rules/ArcanistClassMustBeDeclaredAbstractXHPASTLinterRule.php', 'ArcanistClassMustBeDeclaredAbstractXHPASTLinterRuleTestCase' => 'lint/linter/xhpast/rules/__tests__/ArcanistClassMustBeDeclaredAbstractXHPASTLinterRuleTestCase.php', 'ArcanistClassNameLiteralXHPASTLinterRule' => 'lint/linter/xhpast/rules/ArcanistClassNameLiteralXHPASTLinterRule.php', 'ArcanistClassNameLiteralXHPASTLinterRuleTestCase' => 'lint/linter/xhpast/rules/__tests__/ArcanistClassNameLiteralXHPASTLinterRuleTestCase.php', 'ArcanistCloseRevisionWorkflow' => 'workflow/ArcanistCloseRevisionWorkflow.php', 'ArcanistClosureLinter' => 'lint/linter/ArcanistClosureLinter.php', 'ArcanistClosureLinterTestCase' => 'lint/linter/__tests__/ArcanistClosureLinterTestCase.php', 'ArcanistCoffeeLintLinter' => 'lint/linter/ArcanistCoffeeLintLinter.php', 'ArcanistCoffeeLintLinterTestCase' => 'lint/linter/__tests__/ArcanistCoffeeLintLinterTestCase.php', 'ArcanistCommand' => 'toolset/command/ArcanistCommand.php', 'ArcanistCommentRemover' => 'parser/ArcanistCommentRemover.php', 'ArcanistCommentRemoverTestCase' => 'parser/__tests__/ArcanistCommentRemoverTestCase.php', 'ArcanistCommentSpacingXHPASTLinterRule' => 'lint/linter/xhpast/rules/ArcanistCommentSpacingXHPASTLinterRule.php', 'ArcanistCommentStyleXHPASTLinterRule' => 'lint/linter/xhpast/rules/ArcanistCommentStyleXHPASTLinterRule.php', 'ArcanistCommentStyleXHPASTLinterRuleTestCase' => 'lint/linter/xhpast/rules/__tests__/ArcanistCommentStyleXHPASTLinterRuleTestCase.php', + 'ArcanistCommitGraph' => 'repository/graph/ArcanistCommitGraph.php', + 'ArcanistCommitGraphPartition' => 'repository/graph/ArcanistCommitGraphPartition.php', + 'ArcanistCommitGraphPartitionQuery' => 'repository/graph/ArcanistCommitGraphPartitionQuery.php', + 'ArcanistCommitGraphQuery' => 'repository/graph/query/ArcanistCommitGraphQuery.php', + 'ArcanistCommitGraphSet' => 'repository/graph/ArcanistCommitGraphSet.php', + 'ArcanistCommitGraphSetQuery' => 'repository/graph/ArcanistCommitGraphSetQuery.php', + 'ArcanistCommitGraphSetTreeView' => 'repository/graph/view/ArcanistCommitGraphSetTreeView.php', + 'ArcanistCommitGraphSetView' => 'repository/graph/view/ArcanistCommitGraphSetView.php', + 'ArcanistCommitGraphTestCase' => 'repository/graph/__tests__/ArcanistCommitGraphTestCase.php', + 'ArcanistCommitNode' => 'repository/graph/ArcanistCommitNode.php', 'ArcanistCommitRef' => 'ref/commit/ArcanistCommitRef.php', 'ArcanistCommitSymbolRef' => 'ref/commit/ArcanistCommitSymbolRef.php', 'ArcanistCommitSymbolRefInspector' => 'ref/commit/ArcanistCommitSymbolRefInspector.php', 'ArcanistCommitUpstreamHardpointQuery' => 'query/ArcanistCommitUpstreamHardpointQuery.php', 'ArcanistCommitWorkflow' => 'workflow/ArcanistCommitWorkflow.php', 'ArcanistCompilerLintRenderer' => 'lint/renderer/ArcanistCompilerLintRenderer.php', 'ArcanistComposerLinter' => 'lint/linter/ArcanistComposerLinter.php', 'ArcanistComprehensiveLintEngine' => 'lint/engine/ArcanistComprehensiveLintEngine.php', 'ArcanistConcatenationOperatorXHPASTLinterRule' => 'lint/linter/xhpast/rules/ArcanistConcatenationOperatorXHPASTLinterRule.php', 'ArcanistConcatenationOperatorXHPASTLinterRuleTestCase' => 'lint/linter/xhpast/rules/__tests__/ArcanistConcatenationOperatorXHPASTLinterRuleTestCase.php', - 'ArcanistConduitCall' => 'conduit/ArcanistConduitCall.php', + 'ArcanistConduitAuthenticationException' => 'exception/ArcanistConduitAuthenticationException.php', + 'ArcanistConduitCallFuture' => 'conduit/ArcanistConduitCallFuture.php', 'ArcanistConduitEngine' => 'conduit/ArcanistConduitEngine.php', 'ArcanistConduitException' => 'conduit/ArcanistConduitException.php', 'ArcanistConfigOption' => 'config/option/ArcanistConfigOption.php', 'ArcanistConfiguration' => 'configuration/ArcanistConfiguration.php', 'ArcanistConfigurationDrivenLintEngine' => 'lint/engine/ArcanistConfigurationDrivenLintEngine.php', 'ArcanistConfigurationDrivenUnitTestEngine' => 'unit/engine/ArcanistConfigurationDrivenUnitTestEngine.php', 'ArcanistConfigurationEngine' => 'config/ArcanistConfigurationEngine.php', 'ArcanistConfigurationEngineExtension' => 'config/ArcanistConfigurationEngineExtension.php', 'ArcanistConfigurationManager' => 'configuration/ArcanistConfigurationManager.php', 'ArcanistConfigurationSource' => 'config/source/ArcanistConfigurationSource.php', 'ArcanistConfigurationSourceList' => 'config/ArcanistConfigurationSourceList.php', 'ArcanistConfigurationSourceValue' => 'config/ArcanistConfigurationSourceValue.php', 'ArcanistConsoleLintRenderer' => 'lint/renderer/ArcanistConsoleLintRenderer.php', 'ArcanistConsoleLintRendererTestCase' => 'lint/renderer/__tests__/ArcanistConsoleLintRendererTestCase.php', 'ArcanistConstructorParenthesesXHPASTLinterRule' => 'lint/linter/xhpast/rules/ArcanistConstructorParenthesesXHPASTLinterRule.php', 'ArcanistConstructorParenthesesXHPASTLinterRuleTestCase' => 'lint/linter/xhpast/rules/__tests__/ArcanistConstructorParenthesesXHPASTLinterRuleTestCase.php', 'ArcanistContinueInsideSwitchXHPASTLinterRule' => 'lint/linter/xhpast/rules/ArcanistContinueInsideSwitchXHPASTLinterRule.php', 'ArcanistContinueInsideSwitchXHPASTLinterRuleTestCase' => 'lint/linter/xhpast/rules/__tests__/ArcanistContinueInsideSwitchXHPASTLinterRuleTestCase.php', 'ArcanistControlStatementSpacingXHPASTLinterRule' => 'lint/linter/xhpast/rules/ArcanistControlStatementSpacingXHPASTLinterRule.php', 'ArcanistControlStatementSpacingXHPASTLinterRuleTestCase' => 'lint/linter/xhpast/rules/__tests__/ArcanistControlStatementSpacingXHPASTLinterRuleTestCase.php', 'ArcanistCoverWorkflow' => 'workflow/ArcanistCoverWorkflow.php', 'ArcanistCppcheckLinter' => 'lint/linter/ArcanistCppcheckLinter.php', 'ArcanistCppcheckLinterTestCase' => 'lint/linter/__tests__/ArcanistCppcheckLinterTestCase.php', 'ArcanistCpplintLinter' => 'lint/linter/ArcanistCpplintLinter.php', 'ArcanistCpplintLinterTestCase' => 'lint/linter/__tests__/ArcanistCpplintLinterTestCase.php', 'ArcanistCurlyBraceArrayIndexXHPASTLinterRule' => 'lint/linter/xhpast/rules/ArcanistCurlyBraceArrayIndexXHPASTLinterRule.php', 'ArcanistCurlyBraceArrayIndexXHPASTLinterRuleTestCase' => 'lint/linter/xhpast/rules/__tests__/ArcanistCurlyBraceArrayIndexXHPASTLinterRuleTestCase.php', 'ArcanistDeclarationParenthesesXHPASTLinterRule' => 'lint/linter/xhpast/rules/ArcanistDeclarationParenthesesXHPASTLinterRule.php', 'ArcanistDeclarationParenthesesXHPASTLinterRuleTestCase' => 'lint/linter/xhpast/rules/__tests__/ArcanistDeclarationParenthesesXHPASTLinterRuleTestCase.php', 'ArcanistDefaultParametersXHPASTLinterRule' => 'lint/linter/xhpast/rules/ArcanistDefaultParametersXHPASTLinterRule.php', 'ArcanistDefaultParametersXHPASTLinterRuleTestCase' => 'lint/linter/xhpast/rules/__tests__/ArcanistDefaultParametersXHPASTLinterRuleTestCase.php', 'ArcanistDefaultsConfigurationSource' => 'config/source/ArcanistDefaultsConfigurationSource.php', 'ArcanistDeprecationXHPASTLinterRule' => 'lint/linter/xhpast/rules/ArcanistDeprecationXHPASTLinterRule.php', 'ArcanistDeprecationXHPASTLinterRuleTestCase' => 'lint/linter/xhpast/rules/__tests__/ArcanistDeprecationXHPASTLinterRuleTestCase.php', 'ArcanistDictionaryConfigurationSource' => 'config/source/ArcanistDictionaryConfigurationSource.php', 'ArcanistDiffByteSizeException' => 'exception/ArcanistDiffByteSizeException.php', 'ArcanistDiffChange' => 'parser/diff/ArcanistDiffChange.php', 'ArcanistDiffChangeType' => 'parser/diff/ArcanistDiffChangeType.php', 'ArcanistDiffHunk' => 'parser/diff/ArcanistDiffHunk.php', 'ArcanistDiffParser' => 'parser/ArcanistDiffParser.php', 'ArcanistDiffParserTestCase' => 'parser/__tests__/ArcanistDiffParserTestCase.php', 'ArcanistDiffUtils' => 'difference/ArcanistDiffUtils.php', 'ArcanistDiffUtilsTestCase' => 'difference/__tests__/ArcanistDiffUtilsTestCase.php', 'ArcanistDiffVectorNode' => 'difference/ArcanistDiffVectorNode.php', 'ArcanistDiffVectorTree' => 'difference/ArcanistDiffVectorTree.php', 'ArcanistDiffWorkflow' => 'workflow/ArcanistDiffWorkflow.php', 'ArcanistDifferentialCommitMessage' => 'differential/ArcanistDifferentialCommitMessage.php', 'ArcanistDifferentialCommitMessageParserException' => 'differential/ArcanistDifferentialCommitMessageParserException.php', 'ArcanistDifferentialDependencyGraph' => 'differential/ArcanistDifferentialDependencyGraph.php', 'ArcanistDifferentialRevisionHash' => 'differential/constants/ArcanistDifferentialRevisionHash.php', 'ArcanistDifferentialRevisionStatus' => 'differential/constants/ArcanistDifferentialRevisionStatus.php', - 'ArcanistDisplayRef' => 'ref/ArcanistDisplayRef.php', - 'ArcanistDisplayRefInterface' => 'ref/ArcanistDisplayRefInterface.php', 'ArcanistDoubleQuoteXHPASTLinterRule' => 'lint/linter/xhpast/rules/ArcanistDoubleQuoteXHPASTLinterRule.php', 'ArcanistDoubleQuoteXHPASTLinterRuleTestCase' => 'lint/linter/xhpast/rules/__tests__/ArcanistDoubleQuoteXHPASTLinterRuleTestCase.php', 'ArcanistDownloadWorkflow' => 'workflow/ArcanistDownloadWorkflow.php', 'ArcanistDuplicateKeysInArrayXHPASTLinterRule' => 'lint/linter/xhpast/rules/ArcanistDuplicateKeysInArrayXHPASTLinterRule.php', 'ArcanistDuplicateKeysInArrayXHPASTLinterRuleTestCase' => 'lint/linter/xhpast/rules/__tests__/ArcanistDuplicateKeysInArrayXHPASTLinterRuleTestCase.php', 'ArcanistDuplicateSwitchCaseXHPASTLinterRule' => 'lint/linter/xhpast/rules/ArcanistDuplicateSwitchCaseXHPASTLinterRule.php', 'ArcanistDuplicateSwitchCaseXHPASTLinterRuleTestCase' => 'lint/linter/xhpast/rules/__tests__/ArcanistDuplicateSwitchCaseXHPASTLinterRuleTestCase.php', 'ArcanistDynamicDefineXHPASTLinterRule' => 'lint/linter/xhpast/rules/ArcanistDynamicDefineXHPASTLinterRule.php', 'ArcanistDynamicDefineXHPASTLinterRuleTestCase' => 'lint/linter/xhpast/rules/__tests__/ArcanistDynamicDefineXHPASTLinterRuleTestCase.php', 'ArcanistElseIfUsageXHPASTLinterRule' => 'lint/linter/xhpast/rules/ArcanistElseIfUsageXHPASTLinterRule.php', 'ArcanistElseIfUsageXHPASTLinterRuleTestCase' => 'lint/linter/xhpast/rules/__tests__/ArcanistElseIfUsageXHPASTLinterRuleTestCase.php', 'ArcanistEmptyFileXHPASTLinterRule' => 'lint/linter/xhpast/rules/ArcanistEmptyFileXHPASTLinterRule.php', 'ArcanistEmptyStatementXHPASTLinterRule' => 'lint/linter/xhpast/rules/ArcanistEmptyStatementXHPASTLinterRule.php', 'ArcanistEmptyStatementXHPASTLinterRuleTestCase' => 'lint/linter/xhpast/rules/__tests__/ArcanistEmptyStatementXHPASTLinterRuleTestCase.php', 'ArcanistEventType' => 'events/constant/ArcanistEventType.php', 'ArcanistExitExpressionXHPASTLinterRule' => 'lint/linter/xhpast/rules/ArcanistExitExpressionXHPASTLinterRule.php', 'ArcanistExitExpressionXHPASTLinterRuleTestCase' => 'lint/linter/xhpast/rules/__tests__/ArcanistExitExpressionXHPASTLinterRuleTestCase.php', 'ArcanistExportWorkflow' => 'workflow/ArcanistExportWorkflow.php', 'ArcanistExternalLinter' => 'lint/linter/ArcanistExternalLinter.php', 'ArcanistExternalLinterTestCase' => 'lint/linter/__tests__/ArcanistExternalLinterTestCase.php', 'ArcanistExtractUseXHPASTLinterRule' => 'lint/linter/xhpast/rules/ArcanistExtractUseXHPASTLinterRule.php', 'ArcanistExtractUseXHPASTLinterRuleTestCase' => 'lint/linter/xhpast/rules/__tests__/ArcanistExtractUseXHPASTLinterRuleTestCase.php', - 'ArcanistFeatureBaseWorkflow' => 'workflow/ArcanistFeatureBaseWorkflow.php', - 'ArcanistFeatureWorkflow' => 'workflow/ArcanistFeatureWorkflow.php', 'ArcanistFileConfigurationSource' => 'config/source/ArcanistFileConfigurationSource.php', 'ArcanistFileDataRef' => 'upload/ArcanistFileDataRef.php', 'ArcanistFileRef' => 'ref/file/ArcanistFileRef.php', 'ArcanistFileSymbolRef' => 'ref/file/ArcanistFileSymbolRef.php', 'ArcanistFileUploader' => 'upload/ArcanistFileUploader.php', 'ArcanistFilenameLinter' => 'lint/linter/ArcanistFilenameLinter.php', 'ArcanistFilenameLinterTestCase' => 'lint/linter/__tests__/ArcanistFilenameLinterTestCase.php', 'ArcanistFilesystemAPI' => 'repository/api/ArcanistFilesystemAPI.php', 'ArcanistFilesystemConfigurationSource' => 'config/source/ArcanistFilesystemConfigurationSource.php', 'ArcanistFilesystemWorkingCopy' => 'workingcopy/ArcanistFilesystemWorkingCopy.php', 'ArcanistFlake8Linter' => 'lint/linter/ArcanistFlake8Linter.php', 'ArcanistFlake8LinterTestCase' => 'lint/linter/__tests__/ArcanistFlake8LinterTestCase.php', 'ArcanistFormattedStringXHPASTLinterRule' => 'lint/linter/xhpast/rules/ArcanistFormattedStringXHPASTLinterRule.php', 'ArcanistFormattedStringXHPASTLinterRuleTestCase' => 'lint/linter/xhpast/rules/__tests__/ArcanistFormattedStringXHPASTLinterRuleTestCase.php', 'ArcanistFunctionCallShouldBeTypeCastXHPASTLinterRule' => 'lint/linter/xhpast/rules/ArcanistFunctionCallShouldBeTypeCastXHPASTLinterRule.php', 'ArcanistFunctionCallShouldBeTypeCastXHPASTLinterRuleTestCase' => 'lint/linter/xhpast/rules/__tests__/ArcanistFunctionCallShouldBeTypeCastXHPASTLinterRuleTestCase.php', 'ArcanistFutureLinter' => 'lint/linter/ArcanistFutureLinter.php', 'ArcanistGeneratedLinter' => 'lint/linter/ArcanistGeneratedLinter.php', 'ArcanistGeneratedLinterTestCase' => 'lint/linter/__tests__/ArcanistGeneratedLinterTestCase.php', 'ArcanistGetConfigWorkflow' => 'workflow/ArcanistGetConfigWorkflow.php', 'ArcanistGitAPI' => 'repository/api/ArcanistGitAPI.php', + 'ArcanistGitCommitGraphQuery' => 'repository/graph/query/ArcanistGitCommitGraphQuery.php', 'ArcanistGitCommitMessageHardpointQuery' => 'query/ArcanistGitCommitMessageHardpointQuery.php', 'ArcanistGitCommitSymbolCommitHardpointQuery' => 'ref/commit/ArcanistGitCommitSymbolCommitHardpointQuery.php', - 'ArcanistGitLandEngine' => 'land/ArcanistGitLandEngine.php', + 'ArcanistGitLandEngine' => 'land/engine/ArcanistGitLandEngine.php', + 'ArcanistGitLocalState' => 'repository/state/ArcanistGitLocalState.php', + 'ArcanistGitRawCommit' => 'repository/raw/ArcanistGitRawCommit.php', + 'ArcanistGitRawCommitTestCase' => 'repository/raw/__tests__/ArcanistGitRawCommitTestCase.php', + 'ArcanistGitRepositoryMarkerQuery' => 'repository/marker/ArcanistGitRepositoryMarkerQuery.php', + 'ArcanistGitRepositoryRemoteQuery' => 'repository/remote/ArcanistGitRepositoryRemoteQuery.php', 'ArcanistGitUpstreamPath' => 'repository/api/ArcanistGitUpstreamPath.php', + 'ArcanistGitWorkEngine' => 'work/ArcanistGitWorkEngine.php', 'ArcanistGitWorkingCopy' => 'workingcopy/ArcanistGitWorkingCopy.php', 'ArcanistGitWorkingCopyRevisionHardpointQuery' => 'query/ArcanistGitWorkingCopyRevisionHardpointQuery.php', 'ArcanistGlobalVariableXHPASTLinterRule' => 'lint/linter/xhpast/rules/ArcanistGlobalVariableXHPASTLinterRule.php', 'ArcanistGlobalVariableXHPASTLinterRuleTestCase' => 'lint/linter/xhpast/rules/__tests__/ArcanistGlobalVariableXHPASTLinterRuleTestCase.php', 'ArcanistGoLintLinter' => 'lint/linter/ArcanistGoLintLinter.php', 'ArcanistGoLintLinterTestCase' => 'lint/linter/__tests__/ArcanistGoLintLinterTestCase.php', 'ArcanistGoTestResultParser' => 'unit/parser/ArcanistGoTestResultParser.php', 'ArcanistGoTestResultParserTestCase' => 'unit/parser/__tests__/ArcanistGoTestResultParserTestCase.php', + 'ArcanistGridCell' => 'console/grid/ArcanistGridCell.php', + 'ArcanistGridColumn' => 'console/grid/ArcanistGridColumn.php', + 'ArcanistGridRow' => 'console/grid/ArcanistGridRow.php', + 'ArcanistGridView' => 'console/grid/ArcanistGridView.php', 'ArcanistHLintLinter' => 'lint/linter/ArcanistHLintLinter.php', 'ArcanistHLintLinterTestCase' => 'lint/linter/__tests__/ArcanistHLintLinterTestCase.php', 'ArcanistHardpoint' => 'hardpoint/ArcanistHardpoint.php', 'ArcanistHardpointEngine' => 'hardpoint/ArcanistHardpointEngine.php', 'ArcanistHardpointFutureList' => 'hardpoint/ArcanistHardpointFutureList.php', 'ArcanistHardpointList' => 'hardpoint/ArcanistHardpointList.php', 'ArcanistHardpointObject' => 'hardpoint/ArcanistHardpointObject.php', 'ArcanistHardpointQuery' => 'hardpoint/ArcanistHardpointQuery.php', 'ArcanistHardpointRequest' => 'hardpoint/ArcanistHardpointRequest.php', 'ArcanistHardpointRequestList' => 'hardpoint/ArcanistHardpointRequestList.php', 'ArcanistHardpointTask' => 'hardpoint/ArcanistHardpointTask.php', 'ArcanistHardpointTaskResult' => 'hardpoint/ArcanistHardpointTaskResult.php', 'ArcanistHelpWorkflow' => 'toolset/workflow/ArcanistHelpWorkflow.php', 'ArcanistHexadecimalNumericScalarCasingXHPASTLinterRule' => 'lint/linter/xhpast/rules/ArcanistHexadecimalNumericScalarCasingXHPASTLinterRule.php', 'ArcanistHexadecimalNumericScalarCasingXHPASTLinterRuleTestCase' => 'lint/linter/xhpast/rules/__tests__/ArcanistHexadecimalNumericScalarCasingXHPASTLinterRuleTestCase.php', 'ArcanistHgClientChannel' => 'hgdaemon/ArcanistHgClientChannel.php', 'ArcanistHgProxyClient' => 'hgdaemon/ArcanistHgProxyClient.php', 'ArcanistHgProxyServer' => 'hgdaemon/ArcanistHgProxyServer.php', 'ArcanistHgServerChannel' => 'hgdaemon/ArcanistHgServerChannel.php', 'ArcanistImplicitConstructorXHPASTLinterRule' => 'lint/linter/xhpast/rules/ArcanistImplicitConstructorXHPASTLinterRule.php', 'ArcanistImplicitConstructorXHPASTLinterRuleTestCase' => 'lint/linter/xhpast/rules/__tests__/ArcanistImplicitConstructorXHPASTLinterRuleTestCase.php', 'ArcanistImplicitFallthroughXHPASTLinterRule' => 'lint/linter/xhpast/rules/ArcanistImplicitFallthroughXHPASTLinterRule.php', 'ArcanistImplicitFallthroughXHPASTLinterRuleTestCase' => 'lint/linter/xhpast/rules/__tests__/ArcanistImplicitFallthroughXHPASTLinterRuleTestCase.php', 'ArcanistImplicitVisibilityXHPASTLinterRule' => 'lint/linter/xhpast/rules/ArcanistImplicitVisibilityXHPASTLinterRule.php', 'ArcanistImplicitVisibilityXHPASTLinterRuleTestCase' => 'lint/linter/xhpast/rules/__tests__/ArcanistImplicitVisibilityXHPASTLinterRuleTestCase.php', 'ArcanistImplodeArgumentOrderXHPASTLinterRule' => 'lint/linter/xhpast/rules/ArcanistImplodeArgumentOrderXHPASTLinterRule.php', 'ArcanistImplodeArgumentOrderXHPASTLinterRuleTestCase' => 'lint/linter/xhpast/rules/__tests__/ArcanistImplodeArgumentOrderXHPASTLinterRuleTestCase.php', 'ArcanistInlineHTMLXHPASTLinterRule' => 'lint/linter/ArcanistInlineHTMLXHPASTLinterRule.php', 'ArcanistInlineHTMLXHPASTLinterRuleTestCase' => 'lint/linter/xhpast/rules/__tests__/ArcanistInlineHTMLXHPASTLinterRuleTestCase.php', 'ArcanistInnerFunctionXHPASTLinterRule' => 'lint/linter/xhpast/rules/ArcanistInnerFunctionXHPASTLinterRule.php', 'ArcanistInnerFunctionXHPASTLinterRuleTestCase' => 'lint/linter/xhpast/rules/__tests__/ArcanistInnerFunctionXHPASTLinterRuleTestCase.php', 'ArcanistInspectWorkflow' => 'workflow/ArcanistInspectWorkflow.php', 'ArcanistInstallCertificateWorkflow' => 'workflow/ArcanistInstallCertificateWorkflow.php', 'ArcanistInstanceOfOperatorXHPASTLinterRule' => 'lint/linter/xhpast/rules/ArcanistInstanceOfOperatorXHPASTLinterRule.php', 'ArcanistInstanceofOperatorXHPASTLinterRuleTestCase' => 'lint/linter/xhpast/rules/__tests__/ArcanistInstanceofOperatorXHPASTLinterRuleTestCase.php', 'ArcanistInterfaceAbstractMethodXHPASTLinterRule' => 'lint/linter/xhpast/rules/ArcanistInterfaceAbstractMethodXHPASTLinterRule.php', 'ArcanistInterfaceAbstractMethodXHPASTLinterRuleTestCase' => 'lint/linter/xhpast/rules/__tests__/ArcanistInterfaceAbstractMethodXHPASTLinterRuleTestCase.php', 'ArcanistInterfaceMethodBodyXHPASTLinterRule' => 'lint/linter/xhpast/rules/ArcanistInterfaceMethodBodyXHPASTLinterRule.php', 'ArcanistInterfaceMethodBodyXHPASTLinterRuleTestCase' => 'lint/linter/xhpast/rules/__tests__/ArcanistInterfaceMethodBodyXHPASTLinterRuleTestCase.php', 'ArcanistInvalidDefaultParameterXHPASTLinterRule' => 'lint/linter/xhpast/rules/ArcanistInvalidDefaultParameterXHPASTLinterRule.php', 'ArcanistInvalidDefaultParameterXHPASTLinterRuleTestCase' => 'lint/linter/xhpast/rules/__tests__/ArcanistInvalidDefaultParameterXHPASTLinterRuleTestCase.php', 'ArcanistInvalidModifiersXHPASTLinterRule' => 'lint/linter/xhpast/rules/ArcanistInvalidModifiersXHPASTLinterRule.php', 'ArcanistInvalidModifiersXHPASTLinterRuleTestCase' => 'lint/linter/xhpast/rules/__tests__/ArcanistInvalidModifiersXHPASTLinterRuleTestCase.php', 'ArcanistInvalidOctalNumericScalarXHPASTLinterRule' => 'lint/linter/xhpast/rules/ArcanistInvalidOctalNumericScalarXHPASTLinterRule.php', 'ArcanistInvalidOctalNumericScalarXHPASTLinterRuleTestCase' => 'lint/linter/xhpast/rules/__tests__/ArcanistInvalidOctalNumericScalarXHPASTLinterRuleTestCase.php', 'ArcanistIsAShouldBeInstanceOfXHPASTLinterRule' => 'lint/linter/xhpast/rules/ArcanistIsAShouldBeInstanceOfXHPASTLinterRule.php', 'ArcanistIsAShouldBeInstanceOfXHPASTLinterRuleTestCase' => 'lint/linter/xhpast/rules/__tests__/ArcanistIsAShouldBeInstanceOfXHPASTLinterRuleTestCase.php', 'ArcanistJSHintLinter' => 'lint/linter/ArcanistJSHintLinter.php', 'ArcanistJSHintLinterTestCase' => 'lint/linter/__tests__/ArcanistJSHintLinterTestCase.php', 'ArcanistJSONLintLinter' => 'lint/linter/ArcanistJSONLintLinter.php', 'ArcanistJSONLintRenderer' => 'lint/renderer/ArcanistJSONLintRenderer.php', 'ArcanistJSONLinter' => 'lint/linter/ArcanistJSONLinter.php', 'ArcanistJSONLinterTestCase' => 'lint/linter/__tests__/ArcanistJSONLinterTestCase.php', 'ArcanistJscsLinter' => 'lint/linter/ArcanistJscsLinter.php', 'ArcanistJscsLinterTestCase' => 'lint/linter/__tests__/ArcanistJscsLinterTestCase.php', 'ArcanistKeywordCasingXHPASTLinterRule' => 'lint/linter/xhpast/rules/ArcanistKeywordCasingXHPASTLinterRule.php', 'ArcanistKeywordCasingXHPASTLinterRuleTestCase' => 'lint/linter/xhpast/rules/__tests__/ArcanistKeywordCasingXHPASTLinterRuleTestCase.php', 'ArcanistLambdaFuncFunctionXHPASTLinterRule' => 'lint/linter/xhpast/rules/ArcanistLambdaFuncFunctionXHPASTLinterRule.php', 'ArcanistLambdaFuncFunctionXHPASTLinterRuleTestCase' => 'lint/linter/xhpast/rules/__tests__/ArcanistLambdaFuncFunctionXHPASTLinterRuleTestCase.php', - 'ArcanistLandEngine' => 'land/ArcanistLandEngine.php', + 'ArcanistLandCommit' => 'land/ArcanistLandCommit.php', + 'ArcanistLandCommitSet' => 'land/ArcanistLandCommitSet.php', + 'ArcanistLandEngine' => 'land/engine/ArcanistLandEngine.php', + 'ArcanistLandSymbol' => 'land/ArcanistLandSymbol.php', + 'ArcanistLandTarget' => 'land/ArcanistLandTarget.php', 'ArcanistLandWorkflow' => 'workflow/ArcanistLandWorkflow.php', 'ArcanistLanguageConstructParenthesesXHPASTLinterRule' => 'lint/linter/xhpast/rules/ArcanistLanguageConstructParenthesesXHPASTLinterRule.php', 'ArcanistLanguageConstructParenthesesXHPASTLinterRuleTestCase' => 'lint/linter/xhpast/rules/__tests__/ArcanistLanguageConstructParenthesesXHPASTLinterRuleTestCase.php', 'ArcanistLesscLinter' => 'lint/linter/ArcanistLesscLinter.php', 'ArcanistLesscLinterTestCase' => 'lint/linter/__tests__/ArcanistLesscLinterTestCase.php', 'ArcanistLiberateWorkflow' => 'workflow/ArcanistLiberateWorkflow.php', 'ArcanistLintEngine' => 'lint/engine/ArcanistLintEngine.php', 'ArcanistLintMessage' => 'lint/ArcanistLintMessage.php', 'ArcanistLintMessageTestCase' => 'lint/__tests__/ArcanistLintMessageTestCase.php', 'ArcanistLintPatcher' => 'lint/ArcanistLintPatcher.php', 'ArcanistLintRenderer' => 'lint/renderer/ArcanistLintRenderer.php', 'ArcanistLintResult' => 'lint/ArcanistLintResult.php', 'ArcanistLintSeverity' => 'lint/ArcanistLintSeverity.php', 'ArcanistLintWorkflow' => 'workflow/ArcanistLintWorkflow.php', 'ArcanistLinter' => 'lint/linter/ArcanistLinter.php', 'ArcanistLinterStandard' => 'lint/linter/standards/ArcanistLinterStandard.php', 'ArcanistLinterStandardTestCase' => 'lint/linter/standards/__tests__/ArcanistLinterStandardTestCase.php', 'ArcanistLinterTestCase' => 'lint/linter/__tests__/ArcanistLinterTestCase.php', 'ArcanistLintersWorkflow' => 'workflow/ArcanistLintersWorkflow.php', 'ArcanistListAssignmentXHPASTLinterRule' => 'lint/linter/xhpast/rules/ArcanistListAssignmentXHPASTLinterRule.php', 'ArcanistListAssignmentXHPASTLinterRuleTestCase' => 'lint/linter/xhpast/rules/__tests__/ArcanistListAssignmentXHPASTLinterRuleTestCase.php', 'ArcanistListConfigOption' => 'config/option/ArcanistListConfigOption.php', 'ArcanistListWorkflow' => 'workflow/ArcanistListWorkflow.php', 'ArcanistLocalConfigurationSource' => 'config/source/ArcanistLocalConfigurationSource.php', 'ArcanistLogEngine' => 'log/ArcanistLogEngine.php', 'ArcanistLogMessage' => 'log/ArcanistLogMessage.php', 'ArcanistLogicalOperatorsXHPASTLinterRule' => 'lint/linter/xhpast/rules/ArcanistLogicalOperatorsXHPASTLinterRule.php', 'ArcanistLogicalOperatorsXHPASTLinterRuleTestCase' => 'lint/linter/xhpast/rules/__tests__/ArcanistLogicalOperatorsXHPASTLinterRuleTestCase.php', + 'ArcanistLookWorkflow' => 'workflow/ArcanistLookWorkflow.php', 'ArcanistLowercaseFunctionsXHPASTLinterRule' => 'lint/linter/xhpast/rules/ArcanistLowercaseFunctionsXHPASTLinterRule.php', 'ArcanistLowercaseFunctionsXHPASTLinterRuleTestCase' => 'lint/linter/xhpast/rules/__tests__/ArcanistLowercaseFunctionsXHPASTLinterRuleTestCase.php', + 'ArcanistMarkerRef' => 'repository/marker/ArcanistMarkerRef.php', + 'ArcanistMarkersWorkflow' => 'workflow/ArcanistMarkersWorkflow.php', 'ArcanistMercurialAPI' => 'repository/api/ArcanistMercurialAPI.php', + 'ArcanistMercurialCommitGraphQuery' => 'repository/graph/query/ArcanistMercurialCommitGraphQuery.php', + 'ArcanistMercurialCommitMessageHardpointQuery' => 'query/ArcanistMercurialCommitMessageHardpointQuery.php', + 'ArcanistMercurialLandEngine' => 'land/engine/ArcanistMercurialLandEngine.php', + 'ArcanistMercurialLocalState' => 'repository/state/ArcanistMercurialLocalState.php', 'ArcanistMercurialParser' => 'repository/parser/ArcanistMercurialParser.php', 'ArcanistMercurialParserTestCase' => 'repository/parser/__tests__/ArcanistMercurialParserTestCase.php', + 'ArcanistMercurialRepositoryMarkerQuery' => 'repository/marker/ArcanistMercurialRepositoryMarkerQuery.php', + 'ArcanistMercurialRepositoryRemoteQuery' => 'repository/remote/ArcanistMercurialRepositoryRemoteQuery.php', + 'ArcanistMercurialWorkEngine' => 'work/ArcanistMercurialWorkEngine.php', 'ArcanistMercurialWorkingCopy' => 'workingcopy/ArcanistMercurialWorkingCopy.php', + 'ArcanistMercurialWorkingCopyRevisionHardpointQuery' => 'query/ArcanistMercurialWorkingCopyRevisionHardpointQuery.php', 'ArcanistMergeConflictLinter' => 'lint/linter/ArcanistMergeConflictLinter.php', 'ArcanistMergeConflictLinterTestCase' => 'lint/linter/__tests__/ArcanistMergeConflictLinterTestCase.php', 'ArcanistMessageRevisionHardpointQuery' => 'query/ArcanistMessageRevisionHardpointQuery.php', 'ArcanistMissingArgumentTerminatorException' => 'exception/ArcanistMissingArgumentTerminatorException.php', 'ArcanistMissingLinterException' => 'lint/linter/exception/ArcanistMissingLinterException.php', 'ArcanistModifierOrderingXHPASTLinterRule' => 'lint/linter/xhpast/rules/ArcanistModifierOrderingXHPASTLinterRule.php', 'ArcanistModifierOrderingXHPASTLinterRuleTestCase' => 'lint/linter/xhpast/rules/__tests__/ArcanistModifierOrderingXHPASTLinterRuleTestCase.php', + 'ArcanistMultiSourceConfigOption' => 'config/option/ArcanistMultiSourceConfigOption.php', 'ArcanistNamespaceFirstStatementXHPASTLinterRule' => 'lint/linter/xhpast/rules/ArcanistNamespaceFirstStatementXHPASTLinterRule.php', 'ArcanistNamespaceFirstStatementXHPASTLinterRuleTestCase' => 'lint/linter/xhpast/rules/__tests__/ArcanistNamespaceFirstStatementXHPASTLinterRuleTestCase.php', 'ArcanistNamingConventionsXHPASTLinterRule' => 'lint/linter/xhpast/rules/ArcanistNamingConventionsXHPASTLinterRule.php', 'ArcanistNamingConventionsXHPASTLinterRuleTestCase' => 'lint/linter/xhpast/rules/__tests__/ArcanistNamingConventionsXHPASTLinterRuleTestCase.php', 'ArcanistNestedNamespacesXHPASTLinterRule' => 'lint/linter/xhpast/rules/ArcanistNestedNamespacesXHPASTLinterRule.php', 'ArcanistNestedNamespacesXHPASTLinterRuleTestCase' => 'lint/linter/xhpast/rules/__tests__/ArcanistNestedNamespacesXHPASTLinterRuleTestCase.php', 'ArcanistNewlineAfterOpenTagXHPASTLinterRule' => 'lint/linter/xhpast/rules/ArcanistNewlineAfterOpenTagXHPASTLinterRule.php', 'ArcanistNewlineAfterOpenTagXHPASTLinterRuleTestCase' => 'lint/linter/xhpast/rules/__tests__/ArcanistNewlineAfterOpenTagXHPASTLinterRuleTestCase.php', 'ArcanistNoEffectException' => 'exception/usage/ArcanistNoEffectException.php', 'ArcanistNoEngineException' => 'exception/usage/ArcanistNoEngineException.php', 'ArcanistNoLintLinter' => 'lint/linter/ArcanistNoLintLinter.php', 'ArcanistNoLintLinterTestCase' => 'lint/linter/__tests__/ArcanistNoLintLinterTestCase.php', 'ArcanistNoParentScopeXHPASTLinterRule' => 'lint/linter/xhpast/rules/ArcanistNoParentScopeXHPASTLinterRule.php', 'ArcanistNoParentScopeXHPASTLinterRuleTestCase' => 'lint/linter/xhpast/rules/__tests__/ArcanistNoParentScopeXHPASTLinterRuleTestCase.php', 'ArcanistNoURIConduitException' => 'conduit/ArcanistNoURIConduitException.php', 'ArcanistNoneLintRenderer' => 'lint/renderer/ArcanistNoneLintRenderer.php', 'ArcanistObjectListHardpoint' => 'hardpoint/ArcanistObjectListHardpoint.php', 'ArcanistObjectOperatorSpacingXHPASTLinterRule' => 'lint/linter/xhpast/rules/ArcanistObjectOperatorSpacingXHPASTLinterRule.php', 'ArcanistObjectOperatorSpacingXHPASTLinterRuleTestCase' => 'lint/linter/xhpast/rules/__tests__/ArcanistObjectOperatorSpacingXHPASTLinterRuleTestCase.php', 'ArcanistPEP8Linter' => 'lint/linter/ArcanistPEP8Linter.php', 'ArcanistPEP8LinterTestCase' => 'lint/linter/__tests__/ArcanistPEP8LinterTestCase.php', 'ArcanistPHPCloseTagXHPASTLinterRule' => 'lint/linter/xhpast/rules/ArcanistPHPCloseTagXHPASTLinterRule.php', 'ArcanistPHPCloseTagXHPASTLinterRuleTestCase' => 'lint/linter/xhpast/rules/__tests__/ArcanistPHPCloseTagXHPASTLinterRuleTestCase.php', 'ArcanistPHPCompatibilityXHPASTLinterRule' => 'lint/linter/xhpast/rules/ArcanistPHPCompatibilityXHPASTLinterRule.php', 'ArcanistPHPCompatibilityXHPASTLinterRuleTestCase' => 'lint/linter/xhpast/rules/__tests__/ArcanistPHPCompatibilityXHPASTLinterRuleTestCase.php', 'ArcanistPHPEchoTagXHPASTLinterRule' => 'lint/linter/xhpast/rules/ArcanistPHPEchoTagXHPASTLinterRule.php', 'ArcanistPHPEchoTagXHPASTLinterRuleTestCase' => 'lint/linter/xhpast/rules/__tests__/ArcanistPHPEchoTagXHPASTLinterRuleTestCase.php', 'ArcanistPHPOpenTagXHPASTLinterRule' => 'lint/linter/xhpast/rules/ArcanistPHPOpenTagXHPASTLinterRule.php', 'ArcanistPHPOpenTagXHPASTLinterRuleTestCase' => 'lint/linter/xhpast/rules/__tests__/ArcanistPHPOpenTagXHPASTLinterRuleTestCase.php', 'ArcanistPHPShortTagXHPASTLinterRule' => 'lint/linter/xhpast/rules/ArcanistPHPShortTagXHPASTLinterRule.php', 'ArcanistPHPShortTagXHPASTLinterRuleTestCase' => 'lint/linter/xhpast/rules/__tests__/ArcanistPHPShortTagXHPASTLinterRuleTestCase.php', 'ArcanistPaamayimNekudotayimSpacingXHPASTLinterRule' => 'lint/linter/xhpast/rules/ArcanistPaamayimNekudotayimSpacingXHPASTLinterRule.php', 'ArcanistPaamayimNekudotayimSpacingXHPASTLinterRuleTestCase' => 'lint/linter/xhpast/rules/__tests__/ArcanistPaamayimNekudotayimSpacingXHPASTLinterRuleTestCase.php', 'ArcanistParentMemberReferenceXHPASTLinterRule' => 'lint/linter/xhpast/rules/ArcanistParentMemberReferenceXHPASTLinterRule.php', 'ArcanistParentMemberReferenceXHPASTLinterRuleTestCase' => 'lint/linter/xhpast/rules/__tests__/ArcanistParentMemberReferenceXHPASTLinterRuleTestCase.php', 'ArcanistParenthesesSpacingXHPASTLinterRule' => 'lint/linter/xhpast/rules/ArcanistParenthesesSpacingXHPASTLinterRule.php', 'ArcanistParenthesesSpacingXHPASTLinterRuleTestCase' => 'lint/linter/xhpast/rules/__tests__/ArcanistParenthesesSpacingXHPASTLinterRuleTestCase.php', 'ArcanistParseStrUseXHPASTLinterRule' => 'lint/linter/xhpast/rules/ArcanistParseStrUseXHPASTLinterRule.php', 'ArcanistParseStrUseXHPASTLinterRuleTestCase' => 'lint/linter/xhpast/rules/__tests__/ArcanistParseStrUseXHPASTLinterRuleTestCase.php', 'ArcanistPasteRef' => 'ref/paste/ArcanistPasteRef.php', 'ArcanistPasteSymbolRef' => 'ref/paste/ArcanistPasteSymbolRef.php', 'ArcanistPasteWorkflow' => 'workflow/ArcanistPasteWorkflow.php', 'ArcanistPatchWorkflow' => 'workflow/ArcanistPatchWorkflow.php', 'ArcanistPhpLinter' => 'lint/linter/ArcanistPhpLinter.php', 'ArcanistPhpLinterTestCase' => 'lint/linter/__tests__/ArcanistPhpLinterTestCase.php', 'ArcanistPhpcsLinter' => 'lint/linter/ArcanistPhpcsLinter.php', 'ArcanistPhpcsLinterTestCase' => 'lint/linter/__tests__/ArcanistPhpcsLinterTestCase.php', 'ArcanistPhpunitTestResultParser' => 'unit/parser/ArcanistPhpunitTestResultParser.php', 'ArcanistPhutilLibraryLinter' => 'lint/linter/ArcanistPhutilLibraryLinter.php', 'ArcanistPhutilWorkflow' => 'toolset/ArcanistPhutilWorkflow.php', 'ArcanistPhutilXHPASTLinterStandard' => 'lint/linter/standards/phutil/ArcanistPhutilXHPASTLinterStandard.php', 'ArcanistPlusOperatorOnStringsXHPASTLinterRule' => 'lint/linter/xhpast/rules/ArcanistPlusOperatorOnStringsXHPASTLinterRule.php', 'ArcanistPlusOperatorOnStringsXHPASTLinterRuleTestCase' => 'lint/linter/xhpast/rules/__tests__/ArcanistPlusOperatorOnStringsXHPASTLinterRuleTestCase.php', 'ArcanistProjectConfigurationSource' => 'config/source/ArcanistProjectConfigurationSource.php', 'ArcanistPrompt' => 'toolset/ArcanistPrompt.php', + 'ArcanistPromptResponse' => 'toolset/ArcanistPromptResponse.php', + 'ArcanistPromptsConfigOption' => 'config/option/ArcanistPromptsConfigOption.php', 'ArcanistPromptsWorkflow' => 'toolset/workflow/ArcanistPromptsWorkflow.php', 'ArcanistPublicPropertyXHPASTLinterRule' => 'lint/linter/xhpast/rules/ArcanistPublicPropertyXHPASTLinterRule.php', 'ArcanistPublicPropertyXHPASTLinterRuleTestCase' => 'lint/linter/xhpast/rules/__tests__/ArcanistPublicPropertyXHPASTLinterRuleTestCase.php', 'ArcanistPuppetLintLinter' => 'lint/linter/ArcanistPuppetLintLinter.php', 'ArcanistPuppetLintLinterTestCase' => 'lint/linter/__tests__/ArcanistPuppetLintLinterTestCase.php', 'ArcanistPyFlakesLinter' => 'lint/linter/ArcanistPyFlakesLinter.php', 'ArcanistPyFlakesLinterTestCase' => 'lint/linter/__tests__/ArcanistPyFlakesLinterTestCase.php', 'ArcanistPyLintLinter' => 'lint/linter/ArcanistPyLintLinter.php', 'ArcanistPyLintLinterTestCase' => 'lint/linter/__tests__/ArcanistPyLintLinterTestCase.php', 'ArcanistRaggedClassTreeEdgeXHPASTLinterRule' => 'lint/linter/xhpast/rules/ArcanistRaggedClassTreeEdgeXHPASTLinterRule.php', 'ArcanistRaggedClassTreeEdgeXHPASTLinterRuleTestCase' => 'lint/linter/xhpast/rules/__tests__/ArcanistRaggedClassTreeEdgeXHPASTLinterRuleTestCase.php', 'ArcanistRef' => 'ref/ArcanistRef.php', 'ArcanistRefInspector' => 'inspector/ArcanistRefInspector.php', + 'ArcanistRefView' => 'ref/ArcanistRefView.php', + 'ArcanistRemoteRef' => 'repository/remote/ArcanistRemoteRef.php', + 'ArcanistRemoteRefInspector' => 'repository/remote/ArcanistRemoteRefInspector.php', + 'ArcanistRemoteRepositoryRefsHardpointQuery' => 'repository/remote/ArcanistRemoteRepositoryRefsHardpointQuery.php', 'ArcanistRepositoryAPI' => 'repository/api/ArcanistRepositoryAPI.php', 'ArcanistRepositoryAPIMiscTestCase' => 'repository/api/__tests__/ArcanistRepositoryAPIMiscTestCase.php', 'ArcanistRepositoryAPIStateTestCase' => 'repository/api/__tests__/ArcanistRepositoryAPIStateTestCase.php', + 'ArcanistRepositoryLocalState' => 'repository/state/ArcanistRepositoryLocalState.php', + 'ArcanistRepositoryMarkerQuery' => 'repository/marker/ArcanistRepositoryMarkerQuery.php', + 'ArcanistRepositoryQuery' => 'repository/query/ArcanistRepositoryQuery.php', 'ArcanistRepositoryRef' => 'ref/ArcanistRepositoryRef.php', + 'ArcanistRepositoryRemoteQuery' => 'repository/remote/ArcanistRepositoryRemoteQuery.php', + 'ArcanistRepositoryURINormalizer' => 'repository/remote/ArcanistRepositoryURINormalizer.php', + 'ArcanistRepositoryURINormalizerTestCase' => 'repository/remote/__tests__/ArcanistRepositoryURINormalizerTestCase.php', 'ArcanistReusedAsIteratorXHPASTLinterRule' => 'lint/linter/xhpast/rules/ArcanistReusedAsIteratorXHPASTLinterRule.php', 'ArcanistReusedAsIteratorXHPASTLinterRuleTestCase' => 'lint/linter/xhpast/rules/__tests__/ArcanistReusedAsIteratorXHPASTLinterRuleTestCase.php', 'ArcanistReusedIteratorReferenceXHPASTLinterRule' => 'lint/linter/xhpast/rules/ArcanistReusedIteratorReferenceXHPASTLinterRule.php', 'ArcanistReusedIteratorReferenceXHPASTLinterRuleTestCase' => 'lint/linter/xhpast/rules/__tests__/ArcanistReusedIteratorReferenceXHPASTLinterRuleTestCase.php', 'ArcanistReusedIteratorXHPASTLinterRule' => 'lint/linter/xhpast/rules/ArcanistReusedIteratorXHPASTLinterRule.php', 'ArcanistReusedIteratorXHPASTLinterRuleTestCase' => 'lint/linter/xhpast/rules/__tests__/ArcanistReusedIteratorXHPASTLinterRuleTestCase.php', + 'ArcanistRevisionAuthorHardpointQuery' => 'ref/revision/ArcanistRevisionAuthorHardpointQuery.php', + 'ArcanistRevisionBuildableHardpointQuery' => 'ref/revision/ArcanistRevisionBuildableHardpointQuery.php', 'ArcanistRevisionCommitMessageHardpointQuery' => 'ref/revision/ArcanistRevisionCommitMessageHardpointQuery.php', + 'ArcanistRevisionParentRevisionsHardpointQuery' => 'ref/revision/ArcanistRevisionParentRevisionsHardpointQuery.php', 'ArcanistRevisionRef' => 'ref/revision/ArcanistRevisionRef.php', 'ArcanistRevisionRefSource' => 'ref/ArcanistRevisionRefSource.php', 'ArcanistRevisionSymbolRef' => 'ref/revision/ArcanistRevisionSymbolRef.php', 'ArcanistRuboCopLinter' => 'lint/linter/ArcanistRuboCopLinter.php', 'ArcanistRuboCopLinterTestCase' => 'lint/linter/__tests__/ArcanistRuboCopLinterTestCase.php', 'ArcanistRubyLinter' => 'lint/linter/ArcanistRubyLinter.php', 'ArcanistRubyLinterTestCase' => 'lint/linter/__tests__/ArcanistRubyLinterTestCase.php', 'ArcanistRuntime' => 'runtime/ArcanistRuntime.php', 'ArcanistRuntimeConfigurationSource' => 'config/source/ArcanistRuntimeConfigurationSource.php', 'ArcanistRuntimeHardpointQuery' => 'toolset/query/ArcanistRuntimeHardpointQuery.php', - 'ArcanistScalarConfigOption' => 'config/option/ArcanistScalarConfigOption.php', 'ArcanistScalarHardpoint' => 'hardpoint/ArcanistScalarHardpoint.php', 'ArcanistScriptAndRegexLinter' => 'lint/linter/ArcanistScriptAndRegexLinter.php', 'ArcanistSelfClassReferenceXHPASTLinterRule' => 'lint/linter/xhpast/rules/ArcanistSelfClassReferenceXHPASTLinterRule.php', 'ArcanistSelfClassReferenceXHPASTLinterRuleTestCase' => 'lint/linter/xhpast/rules/__tests__/ArcanistSelfClassReferenceXHPASTLinterRuleTestCase.php', 'ArcanistSelfMemberReferenceXHPASTLinterRule' => 'lint/linter/xhpast/rules/ArcanistSelfMemberReferenceXHPASTLinterRule.php', 'ArcanistSelfMemberReferenceXHPASTLinterRuleTestCase' => 'lint/linter/xhpast/rules/__tests__/ArcanistSelfMemberReferenceXHPASTLinterRuleTestCase.php', 'ArcanistSemicolonSpacingXHPASTLinterRule' => 'lint/linter/xhpast/rules/ArcanistSemicolonSpacingXHPASTLinterRule.php', 'ArcanistSemicolonSpacingXHPASTLinterRuleTestCase' => 'lint/linter/xhpast/rules/__tests__/ArcanistSemicolonSpacingXHPASTLinterRuleTestCase.php', 'ArcanistSetConfigWorkflow' => 'workflow/ArcanistSetConfigWorkflow.php', 'ArcanistSetting' => 'configuration/ArcanistSetting.php', 'ArcanistSettings' => 'configuration/ArcanistSettings.php', 'ArcanistShellCompleteWorkflow' => 'toolset/workflow/ArcanistShellCompleteWorkflow.php', + 'ArcanistSimpleCommitGraphQuery' => 'repository/graph/query/ArcanistSimpleCommitGraphQuery.php', 'ArcanistSimpleSymbolHardpointQuery' => 'ref/simple/ArcanistSimpleSymbolHardpointQuery.php', 'ArcanistSimpleSymbolRef' => 'ref/simple/ArcanistSimpleSymbolRef.php', 'ArcanistSimpleSymbolRefInspector' => 'ref/simple/ArcanistSimpleSymbolRefInspector.php', 'ArcanistSingleLintEngine' => 'lint/engine/ArcanistSingleLintEngine.php', + 'ArcanistSingleSourceConfigOption' => 'config/option/ArcanistSingleSourceConfigOption.php', 'ArcanistSlownessXHPASTLinterRule' => 'lint/linter/xhpast/rules/ArcanistSlownessXHPASTLinterRule.php', 'ArcanistSlownessXHPASTLinterRuleTestCase' => 'lint/linter/xhpast/rules/__tests__/ArcanistSlownessXHPASTLinterRuleTestCase.php', 'ArcanistSpellingLinter' => 'lint/linter/ArcanistSpellingLinter.php', 'ArcanistSpellingLinterTestCase' => 'lint/linter/__tests__/ArcanistSpellingLinterTestCase.php', 'ArcanistStaticThisXHPASTLinterRule' => 'lint/linter/xhpast/rules/ArcanistStaticThisXHPASTLinterRule.php', 'ArcanistStaticThisXHPASTLinterRuleTestCase' => 'lint/linter/xhpast/rules/__tests__/ArcanistStaticThisXHPASTLinterRuleTestCase.php', 'ArcanistStringConfigOption' => 'config/option/ArcanistStringConfigOption.php', + 'ArcanistStringListConfigOption' => 'config/option/ArcanistStringListConfigOption.php', 'ArcanistSubversionAPI' => 'repository/api/ArcanistSubversionAPI.php', 'ArcanistSubversionWorkingCopy' => 'workingcopy/ArcanistSubversionWorkingCopy.php', 'ArcanistSummaryLintRenderer' => 'lint/renderer/ArcanistSummaryLintRenderer.php', 'ArcanistSymbolEngine' => 'ref/symbol/ArcanistSymbolEngine.php', 'ArcanistSymbolRef' => 'ref/symbol/ArcanistSymbolRef.php', 'ArcanistSyntaxErrorXHPASTLinterRule' => 'lint/linter/xhpast/rules/ArcanistSyntaxErrorXHPASTLinterRule.php', 'ArcanistSystemConfigurationSource' => 'config/source/ArcanistSystemConfigurationSource.php', 'ArcanistTaskRef' => 'ref/task/ArcanistTaskRef.php', 'ArcanistTaskSymbolRef' => 'ref/task/ArcanistTaskSymbolRef.php', 'ArcanistTasksWorkflow' => 'workflow/ArcanistTasksWorkflow.php', 'ArcanistTautologicalExpressionXHPASTLinterRule' => 'lint/linter/xhpast/rules/ArcanistTautologicalExpressionXHPASTLinterRule.php', 'ArcanistTautologicalExpressionXHPASTLinterRuleTestCase' => 'lint/linter/xhpast/rules/__tests__/ArcanistTautologicalExpressionXHPASTLinterRuleTestCase.php', 'ArcanistTerminalStringInterface' => 'xsprintf/ArcanistTerminalStringInterface.php', 'ArcanistTestResultParser' => 'unit/parser/ArcanistTestResultParser.php', 'ArcanistTestXHPASTLintSwitchHook' => 'lint/linter/__tests__/ArcanistTestXHPASTLintSwitchHook.php', 'ArcanistTextLinter' => 'lint/linter/ArcanistTextLinter.php', 'ArcanistTextLinterTestCase' => 'lint/linter/__tests__/ArcanistTextLinterTestCase.php', 'ArcanistThisReassignmentXHPASTLinterRule' => 'lint/linter/xhpast/rules/ArcanistThisReassignmentXHPASTLinterRule.php', 'ArcanistThisReassignmentXHPASTLinterRuleTestCase' => 'lint/linter/xhpast/rules/__tests__/ArcanistThisReassignmentXHPASTLinterRuleTestCase.php', 'ArcanistToStringExceptionXHPASTLinterRule' => 'lint/linter/xhpast/rules/ArcanistToStringExceptionXHPASTLinterRule.php', 'ArcanistToStringExceptionXHPASTLinterRuleTestCase' => 'lint/linter/xhpast/rules/__tests__/ArcanistToStringExceptionXHPASTLinterRuleTestCase.php', 'ArcanistTodoCommentXHPASTLinterRule' => 'lint/linter/xhpast/rules/ArcanistTodoCommentXHPASTLinterRule.php', 'ArcanistTodoCommentXHPASTLinterRuleTestCase' => 'lint/linter/xhpast/rules/__tests__/ArcanistTodoCommentXHPASTLinterRuleTestCase.php', 'ArcanistTodoWorkflow' => 'workflow/ArcanistTodoWorkflow.php', 'ArcanistToolset' => 'toolset/ArcanistToolset.php', 'ArcanistUSEnglishTranslation' => 'internationalization/ArcanistUSEnglishTranslation.php', 'ArcanistUnableToParseXHPASTLinterRule' => 'lint/linter/xhpast/rules/ArcanistUnableToParseXHPASTLinterRule.php', 'ArcanistUnaryPostfixExpressionSpacingXHPASTLinterRule' => 'lint/linter/xhpast/rules/ArcanistUnaryPostfixExpressionSpacingXHPASTLinterRule.php', 'ArcanistUnaryPostfixExpressionSpacingXHPASTLinterRuleTestCase' => 'lint/linter/xhpast/rules/__tests__/ArcanistUnaryPostfixExpressionSpacingXHPASTLinterRuleTestCase.php', 'ArcanistUnaryPrefixExpressionSpacingXHPASTLinterRule' => 'lint/linter/xhpast/rules/ArcanistUnaryPrefixExpressionSpacingXHPASTLinterRule.php', 'ArcanistUnaryPrefixExpressionSpacingXHPASTLinterRuleTestCase' => 'lint/linter/xhpast/rules/__tests__/ArcanistUnaryPrefixExpressionSpacingXHPASTLinterRuleTestCase.php', 'ArcanistUndeclaredVariableXHPASTLinterRule' => 'lint/linter/xhpast/rules/ArcanistUndeclaredVariableXHPASTLinterRule.php', 'ArcanistUndeclaredVariableXHPASTLinterRuleTestCase' => 'lint/linter/xhpast/rules/__tests__/ArcanistUndeclaredVariableXHPASTLinterRuleTestCase.php', 'ArcanistUnexpectedReturnValueXHPASTLinterRule' => 'lint/linter/xhpast/rules/ArcanistUnexpectedReturnValueXHPASTLinterRule.php', 'ArcanistUnexpectedReturnValueXHPASTLinterRuleTestCase' => 'lint/linter/xhpast/rules/__tests__/ArcanistUnexpectedReturnValueXHPASTLinterRuleTestCase.php', 'ArcanistUnitConsoleRenderer' => 'unit/renderer/ArcanistUnitConsoleRenderer.php', 'ArcanistUnitRenderer' => 'unit/renderer/ArcanistUnitRenderer.php', 'ArcanistUnitTestEngine' => 'unit/engine/ArcanistUnitTestEngine.php', 'ArcanistUnitTestResult' => 'unit/ArcanistUnitTestResult.php', 'ArcanistUnitTestResultTestCase' => 'unit/__tests__/ArcanistUnitTestResultTestCase.php', 'ArcanistUnitTestableLintEngine' => 'lint/engine/ArcanistUnitTestableLintEngine.php', 'ArcanistUnitWorkflow' => 'workflow/ArcanistUnitWorkflow.php', 'ArcanistUnnecessaryFinalModifierXHPASTLinterRule' => 'lint/linter/xhpast/rules/ArcanistUnnecessaryFinalModifierXHPASTLinterRule.php', 'ArcanistUnnecessaryFinalModifierXHPASTLinterRuleTestCase' => 'lint/linter/xhpast/rules/__tests__/ArcanistUnnecessaryFinalModifierXHPASTLinterRuleTestCase.php', 'ArcanistUnnecessarySemicolonXHPASTLinterRule' => 'lint/linter/xhpast/rules/ArcanistUnnecessarySemicolonXHPASTLinterRule.php', 'ArcanistUnnecessarySymbolAliasXHPASTLinterRule' => 'lint/linter/xhpast/rules/ArcanistUnnecessarySymbolAliasXHPASTLinterRule.php', 'ArcanistUnnecessarySymbolAliasXHPASTLinterRuleTestCase' => 'lint/linter/xhpast/rules/__tests__/ArcanistUnnecessarySymbolAliasXHPASTLinterRuleTestCase.php', 'ArcanistUnsafeDynamicStringXHPASTLinterRule' => 'lint/linter/xhpast/rules/ArcanistUnsafeDynamicStringXHPASTLinterRule.php', 'ArcanistUnsafeDynamicStringXHPASTLinterRuleTestCase' => 'lint/linter/xhpast/rules/__tests__/ArcanistUnsafeDynamicStringXHPASTLinterRuleTestCase.php', 'ArcanistUpgradeWorkflow' => 'workflow/ArcanistUpgradeWorkflow.php', 'ArcanistUploadWorkflow' => 'workflow/ArcanistUploadWorkflow.php', 'ArcanistUsageException' => 'exception/ArcanistUsageException.php', 'ArcanistUseStatementNamespacePrefixXHPASTLinterRule' => 'lint/linter/xhpast/rules/ArcanistUseStatementNamespacePrefixXHPASTLinterRule.php', 'ArcanistUseStatementNamespacePrefixXHPASTLinterRuleTestCase' => 'lint/linter/xhpast/rules/__tests__/ArcanistUseStatementNamespacePrefixXHPASTLinterRuleTestCase.php', 'ArcanistUselessOverridingMethodXHPASTLinterRule' => 'lint/linter/xhpast/rules/ArcanistUselessOverridingMethodXHPASTLinterRule.php', 'ArcanistUselessOverridingMethodXHPASTLinterRuleTestCase' => 'lint/linter/xhpast/rules/__tests__/ArcanistUselessOverridingMethodXHPASTLinterRuleTestCase.php', 'ArcanistUserAbortException' => 'exception/usage/ArcanistUserAbortException.php', 'ArcanistUserConfigurationSource' => 'config/source/ArcanistUserConfigurationSource.php', 'ArcanistUserRef' => 'ref/user/ArcanistUserRef.php', 'ArcanistUserSymbolHardpointQuery' => 'ref/user/ArcanistUserSymbolHardpointQuery.php', 'ArcanistUserSymbolRef' => 'ref/user/ArcanistUserSymbolRef.php', 'ArcanistUserSymbolRefInspector' => 'ref/user/ArcanistUserSymbolRefInspector.php', 'ArcanistVariableReferenceSpacingXHPASTLinterRule' => 'lint/linter/xhpast/rules/ArcanistVariableReferenceSpacingXHPASTLinterRule.php', 'ArcanistVariableReferenceSpacingXHPASTLinterRuleTestCase' => 'lint/linter/xhpast/rules/__tests__/ArcanistVariableReferenceSpacingXHPASTLinterRuleTestCase.php', 'ArcanistVariableVariableXHPASTLinterRule' => 'lint/linter/xhpast/rules/ArcanistVariableVariableXHPASTLinterRule.php', 'ArcanistVariableVariableXHPASTLinterRuleTestCase' => 'lint/linter/xhpast/rules/__tests__/ArcanistVariableVariableXHPASTLinterRuleTestCase.php', 'ArcanistVectorHardpoint' => 'hardpoint/ArcanistVectorHardpoint.php', 'ArcanistVersionWorkflow' => 'toolset/workflow/ArcanistVersionWorkflow.php', 'ArcanistWeldWorkflow' => 'workflow/ArcanistWeldWorkflow.php', 'ArcanistWhichWorkflow' => 'workflow/ArcanistWhichWorkflow.php', 'ArcanistWildConfigOption' => 'config/option/ArcanistWildConfigOption.php', + 'ArcanistWorkEngine' => 'work/ArcanistWorkEngine.php', + 'ArcanistWorkWorkflow' => 'workflow/ArcanistWorkWorkflow.php', 'ArcanistWorkflow' => 'workflow/ArcanistWorkflow.php', 'ArcanistWorkflowArgument' => 'toolset/ArcanistWorkflowArgument.php', + 'ArcanistWorkflowEngine' => 'engine/ArcanistWorkflowEngine.php', 'ArcanistWorkflowGitHardpointQuery' => 'query/ArcanistWorkflowGitHardpointQuery.php', 'ArcanistWorkflowInformation' => 'toolset/ArcanistWorkflowInformation.php', + 'ArcanistWorkflowMercurialHardpointQuery' => 'query/ArcanistWorkflowMercurialHardpointQuery.php', 'ArcanistWorkingCopy' => 'workingcopy/ArcanistWorkingCopy.php', - 'ArcanistWorkingCopyCommitHardpointQuery' => 'query/ArcanistWorkingCopyCommitHardpointQuery.php', 'ArcanistWorkingCopyConfigurationSource' => 'config/source/ArcanistWorkingCopyConfigurationSource.php', 'ArcanistWorkingCopyIdentity' => 'workingcopyidentity/ArcanistWorkingCopyIdentity.php', 'ArcanistWorkingCopyPath' => 'workingcopy/ArcanistWorkingCopyPath.php', 'ArcanistWorkingCopyStateRef' => 'ref/ArcanistWorkingCopyStateRef.php', 'ArcanistWorkingCopyStateRefInspector' => 'inspector/ArcanistWorkingCopyStateRefInspector.php', 'ArcanistXHPASTLintNamingHook' => 'lint/linter/xhpast/ArcanistXHPASTLintNamingHook.php', 'ArcanistXHPASTLintNamingHookTestCase' => 'lint/linter/xhpast/__tests__/ArcanistXHPASTLintNamingHookTestCase.php', 'ArcanistXHPASTLintSwitchHook' => 'lint/linter/xhpast/ArcanistXHPASTLintSwitchHook.php', 'ArcanistXHPASTLinter' => 'lint/linter/ArcanistXHPASTLinter.php', 'ArcanistXHPASTLinterRule' => 'lint/linter/xhpast/ArcanistXHPASTLinterRule.php', 'ArcanistXHPASTLinterRuleTestCase' => 'lint/linter/xhpast/rules/__tests__/ArcanistXHPASTLinterRuleTestCase.php', 'ArcanistXHPASTLinterTestCase' => 'lint/linter/__tests__/ArcanistXHPASTLinterTestCase.php', 'ArcanistXMLLinter' => 'lint/linter/ArcanistXMLLinter.php', 'ArcanistXMLLinterTestCase' => 'lint/linter/__tests__/ArcanistXMLLinterTestCase.php', 'ArcanistXUnitTestResultParser' => 'unit/parser/ArcanistXUnitTestResultParser.php', 'BaseHTTPFuture' => 'future/http/BaseHTTPFuture.php', 'CSharpToolsTestEngine' => 'unit/engine/CSharpToolsTestEngine.php', 'CaseInsensitiveArray' => 'utils/CaseInsensitiveArray.php', 'CaseInsensitiveArrayTestCase' => 'utils/__tests__/CaseInsensitiveArrayTestCase.php', 'CommandException' => 'future/exec/CommandException.php', 'ConduitClient' => 'conduit/ConduitClient.php', 'ConduitClientException' => 'conduit/ConduitClientException.php', 'ConduitClientTestCase' => 'conduit/__tests__/ConduitClientTestCase.php', 'ConduitFuture' => 'conduit/ConduitFuture.php', 'ConduitSearchFuture' => 'conduit/ConduitSearchFuture.php', 'ExecFuture' => 'future/exec/ExecFuture.php', 'ExecFutureTestCase' => 'future/exec/__tests__/ExecFutureTestCase.php', 'ExecPassthruTestCase' => 'future/exec/__tests__/ExecPassthruTestCase.php', 'FileFinder' => 'filesystem/FileFinder.php', 'FileFinderTestCase' => 'filesystem/__tests__/FileFinderTestCase.php', 'FileList' => 'filesystem/FileList.php', 'Filesystem' => 'filesystem/Filesystem.php', 'FilesystemException' => 'filesystem/FilesystemException.php', 'FilesystemTestCase' => 'filesystem/__tests__/FilesystemTestCase.php', 'Future' => 'future/Future.php', 'FutureAgent' => 'conduit/FutureAgent.php', 'FutureIterator' => 'future/FutureIterator.php', 'FutureIteratorTestCase' => 'future/__tests__/FutureIteratorTestCase.php', 'FuturePool' => 'future/FuturePool.php', 'FutureProxy' => 'future/FutureProxy.php', 'HTTPFuture' => 'future/http/HTTPFuture.php', 'HTTPFutureCURLResponseStatus' => 'future/http/status/HTTPFutureCURLResponseStatus.php', 'HTTPFutureCertificateResponseStatus' => 'future/http/status/HTTPFutureCertificateResponseStatus.php', 'HTTPFutureHTTPResponseStatus' => 'future/http/status/HTTPFutureHTTPResponseStatus.php', 'HTTPFutureParseResponseStatus' => 'future/http/status/HTTPFutureParseResponseStatus.php', 'HTTPFutureResponseStatus' => 'future/http/status/HTTPFutureResponseStatus.php', 'HTTPFutureTransportResponseStatus' => 'future/http/status/HTTPFutureTransportResponseStatus.php', 'HTTPSFuture' => 'future/http/HTTPSFuture.php', 'ImmediateFuture' => 'future/ImmediateFuture.php', 'LibphutilUSEnglishTranslation' => 'internationalization/translation/LibphutilUSEnglishTranslation.php', 'LinesOfALarge' => 'filesystem/linesofalarge/LinesOfALarge.php', 'LinesOfALargeExecFuture' => 'filesystem/linesofalarge/LinesOfALargeExecFuture.php', 'LinesOfALargeExecFutureTestCase' => 'filesystem/linesofalarge/__tests__/LinesOfALargeExecFutureTestCase.php', 'LinesOfALargeFile' => 'filesystem/linesofalarge/LinesOfALargeFile.php', 'LinesOfALargeFileTestCase' => 'filesystem/linesofalarge/__tests__/LinesOfALargeFileTestCase.php', 'MFilterTestHelper' => 'utils/__tests__/MFilterTestHelper.php', 'NoseTestEngine' => 'unit/engine/NoseTestEngine.php', 'PHPASTParserTestCase' => 'parser/xhpast/__tests__/PHPASTParserTestCase.php', 'PhageAction' => 'phage/action/PhageAction.php', 'PhageAgentAction' => 'phage/action/PhageAgentAction.php', 'PhageAgentBootloader' => 'phage/bootloader/PhageAgentBootloader.php', 'PhageAgentTestCase' => 'phage/__tests__/PhageAgentTestCase.php', 'PhageExecWorkflow' => 'phage/workflow/PhageExecWorkflow.php', 'PhageExecuteAction' => 'phage/action/PhageExecuteAction.php', 'PhageLocalAction' => 'phage/action/PhageLocalAction.php', 'PhagePHPAgent' => 'phage/agent/PhagePHPAgent.php', 'PhagePHPAgentBootloader' => 'phage/bootloader/PhagePHPAgentBootloader.php', 'PhagePlanAction' => 'phage/action/PhagePlanAction.php', 'PhageToolset' => 'phage/toolset/PhageToolset.php', 'PhageWorkflow' => 'phage/workflow/PhageWorkflow.php', 'Phobject' => 'object/Phobject.php', 'PhobjectTestCase' => 'object/__tests__/PhobjectTestCase.php', 'PhpunitTestEngine' => 'unit/engine/PhpunitTestEngine.php', 'PhpunitTestEngineTestCase' => 'unit/engine/__tests__/PhpunitTestEngineTestCase.php', 'PhutilAWSCloudFormationFuture' => 'future/aws/PhutilAWSCloudFormationFuture.php', 'PhutilAWSCloudWatchFuture' => 'future/aws/PhutilAWSCloudWatchFuture.php', 'PhutilAWSEC2Future' => 'future/aws/PhutilAWSEC2Future.php', 'PhutilAWSException' => 'future/aws/PhutilAWSException.php', 'PhutilAWSFuture' => 'future/aws/PhutilAWSFuture.php', 'PhutilAWSManagementWorkflow' => 'future/aws/management/PhutilAWSManagementWorkflow.php', 'PhutilAWSS3DeleteManagementWorkflow' => 'future/aws/management/PhutilAWSS3DeleteManagementWorkflow.php', 'PhutilAWSS3Future' => 'future/aws/PhutilAWSS3Future.php', 'PhutilAWSS3GetManagementWorkflow' => 'future/aws/management/PhutilAWSS3GetManagementWorkflow.php', 'PhutilAWSS3ManagementWorkflow' => 'future/aws/management/PhutilAWSS3ManagementWorkflow.php', 'PhutilAWSS3PutManagementWorkflow' => 'future/aws/management/PhutilAWSS3PutManagementWorkflow.php', 'PhutilAWSv4Signature' => 'future/aws/PhutilAWSv4Signature.php', 'PhutilAWSv4SignatureTestCase' => 'future/aws/__tests__/PhutilAWSv4SignatureTestCase.php', 'PhutilAggregateException' => 'error/PhutilAggregateException.php', 'PhutilAllCapsEnglishLocale' => 'internationalization/locales/PhutilAllCapsEnglishLocale.php', 'PhutilArgumentParser' => 'parser/argument/PhutilArgumentParser.php', 'PhutilArgumentParserException' => 'parser/argument/exception/PhutilArgumentParserException.php', 'PhutilArgumentParserTestCase' => 'parser/argument/__tests__/PhutilArgumentParserTestCase.php', 'PhutilArgumentSpecification' => 'parser/argument/PhutilArgumentSpecification.php', 'PhutilArgumentSpecificationException' => 'parser/argument/exception/PhutilArgumentSpecificationException.php', 'PhutilArgumentSpecificationTestCase' => 'parser/argument/__tests__/PhutilArgumentSpecificationTestCase.php', 'PhutilArgumentSpellingCorrector' => 'parser/argument/PhutilArgumentSpellingCorrector.php', 'PhutilArgumentSpellingCorrectorTestCase' => 'parser/argument/__tests__/PhutilArgumentSpellingCorrectorTestCase.php', 'PhutilArgumentUsageException' => 'parser/argument/exception/PhutilArgumentUsageException.php', 'PhutilArgumentWorkflow' => 'parser/argument/workflow/PhutilArgumentWorkflow.php', 'PhutilArray' => 'utils/PhutilArray.php', 'PhutilArrayCheck' => 'utils/PhutilArrayCheck.php', 'PhutilArrayTestCase' => 'utils/__tests__/PhutilArrayTestCase.php', 'PhutilArrayWithDefaultValue' => 'utils/PhutilArrayWithDefaultValue.php', 'PhutilAsanaFuture' => 'future/asana/PhutilAsanaFuture.php', 'PhutilBacktraceSignalHandler' => 'future/exec/PhutilBacktraceSignalHandler.php', 'PhutilBallOfPHP' => 'phage/util/PhutilBallOfPHP.php', 'PhutilBinaryAnalyzer' => 'filesystem/binary/PhutilBinaryAnalyzer.php', 'PhutilBinaryAnalyzerTestCase' => 'filesystem/binary/__tests__/PhutilBinaryAnalyzerTestCase.php', 'PhutilBootloader' => 'init/lib/PhutilBootloader.php', 'PhutilBootloaderException' => 'init/lib/PhutilBootloaderException.php', 'PhutilBritishEnglishLocale' => 'internationalization/locales/PhutilBritishEnglishLocale.php', 'PhutilBufferedIterator' => 'utils/PhutilBufferedIterator.php', 'PhutilBufferedIteratorTestCase' => 'utils/__tests__/PhutilBufferedIteratorTestCase.php', 'PhutilBugtraqParser' => 'parser/PhutilBugtraqParser.php', 'PhutilBugtraqParserTestCase' => 'parser/__tests__/PhutilBugtraqParserTestCase.php', 'PhutilCIDRBlock' => 'ip/PhutilCIDRBlock.php', 'PhutilCIDRList' => 'ip/PhutilCIDRList.php', 'PhutilCallbackFilterIterator' => 'utils/PhutilCallbackFilterIterator.php', 'PhutilCallbackSignalHandler' => 'future/exec/PhutilCallbackSignalHandler.php', 'PhutilChannel' => 'channel/PhutilChannel.php', 'PhutilChannelChannel' => 'channel/PhutilChannelChannel.php', 'PhutilChannelTestCase' => 'channel/__tests__/PhutilChannelTestCase.php', 'PhutilChunkedIterator' => 'utils/PhutilChunkedIterator.php', 'PhutilChunkedIteratorTestCase' => 'utils/__tests__/PhutilChunkedIteratorTestCase.php', 'PhutilClassMapQuery' => 'symbols/PhutilClassMapQuery.php', 'PhutilCloudWatchMetric' => 'future/aws/PhutilCloudWatchMetric.php', 'PhutilCommandString' => 'xsprintf/PhutilCommandString.php', 'PhutilConsole' => 'console/PhutilConsole.php', 'PhutilConsoleBlock' => 'console/view/PhutilConsoleBlock.php', 'PhutilConsoleError' => 'console/view/PhutilConsoleError.php', 'PhutilConsoleFormatter' => 'console/PhutilConsoleFormatter.php', 'PhutilConsoleInfo' => 'console/view/PhutilConsoleInfo.php', 'PhutilConsoleList' => 'console/view/PhutilConsoleList.php', 'PhutilConsoleLogLine' => 'console/view/PhutilConsoleLogLine.php', 'PhutilConsoleMessage' => 'console/PhutilConsoleMessage.php', 'PhutilConsoleMetrics' => 'console/PhutilConsoleMetrics.php', 'PhutilConsoleMetricsSignalHandler' => 'future/exec/PhutilConsoleMetricsSignalHandler.php', 'PhutilConsoleProgressBar' => 'console/PhutilConsoleProgressBar.php', 'PhutilConsoleProgressSink' => 'progress/PhutilConsoleProgressSink.php', 'PhutilConsoleServer' => 'console/PhutilConsoleServer.php', 'PhutilConsoleServerChannel' => 'console/PhutilConsoleServerChannel.php', 'PhutilConsoleSkip' => 'console/view/PhutilConsoleSkip.php', 'PhutilConsoleStdinNotInteractiveException' => 'console/PhutilConsoleStdinNotInteractiveException.php', 'PhutilConsoleTable' => 'console/view/PhutilConsoleTable.php', 'PhutilConsoleView' => 'console/view/PhutilConsoleView.php', 'PhutilConsoleWarning' => 'console/view/PhutilConsoleWarning.php', 'PhutilConsoleWrapTestCase' => 'console/__tests__/PhutilConsoleWrapTestCase.php', 'PhutilCowsay' => 'utils/PhutilCowsay.php', 'PhutilCowsayTestCase' => 'utils/__tests__/PhutilCowsayTestCase.php', 'PhutilCsprintfTestCase' => 'xsprintf/__tests__/PhutilCsprintfTestCase.php', 'PhutilCzechLocale' => 'internationalization/locales/PhutilCzechLocale.php', 'PhutilDOMNode' => 'parser/html/PhutilDOMNode.php', 'PhutilDeferredLog' => 'filesystem/PhutilDeferredLog.php', 'PhutilDeferredLogTestCase' => 'filesystem/__tests__/PhutilDeferredLogTestCase.php', 'PhutilDiffBinaryAnalyzer' => 'filesystem/binary/PhutilDiffBinaryAnalyzer.php', 'PhutilDirectedScalarGraph' => 'utils/PhutilDirectedScalarGraph.php', 'PhutilDirectoryFixture' => 'filesystem/PhutilDirectoryFixture.php', 'PhutilDocblockParser' => 'parser/PhutilDocblockParser.php', 'PhutilDocblockParserTestCase' => 'parser/__tests__/PhutilDocblockParserTestCase.php', 'PhutilEditDistanceMatrix' => 'utils/PhutilEditDistanceMatrix.php', 'PhutilEditDistanceMatrixTestCase' => 'utils/__tests__/PhutilEditDistanceMatrixTestCase.php', 'PhutilEditorConfig' => 'parser/PhutilEditorConfig.php', 'PhutilEditorConfigTestCase' => 'parser/__tests__/PhutilEditorConfigTestCase.php', 'PhutilEmailAddress' => 'parser/PhutilEmailAddress.php', 'PhutilEmailAddressTestCase' => 'parser/__tests__/PhutilEmailAddressTestCase.php', 'PhutilEmojiLocale' => 'internationalization/locales/PhutilEmojiLocale.php', 'PhutilEnglishCanadaLocale' => 'internationalization/locales/PhutilEnglishCanadaLocale.php', 'PhutilErrorHandler' => 'error/PhutilErrorHandler.php', 'PhutilErrorHandlerTestCase' => 'error/__tests__/PhutilErrorHandlerTestCase.php', 'PhutilErrorTrap' => 'error/PhutilErrorTrap.php', 'PhutilEvent' => 'events/PhutilEvent.php', 'PhutilEventConstants' => 'events/constant/PhutilEventConstants.php', 'PhutilEventEngine' => 'events/PhutilEventEngine.php', 'PhutilEventListener' => 'events/PhutilEventListener.php', 'PhutilEventType' => 'events/constant/PhutilEventType.php', 'PhutilExampleBufferedIterator' => 'utils/PhutilExampleBufferedIterator.php', 'PhutilExecChannel' => 'channel/PhutilExecChannel.php', 'PhutilExecPassthru' => 'future/exec/PhutilExecPassthru.php', 'PhutilExecutableFuture' => 'future/exec/PhutilExecutableFuture.php', 'PhutilExecutionEnvironment' => 'utils/PhutilExecutionEnvironment.php', 'PhutilFileLock' => 'filesystem/PhutilFileLock.php', 'PhutilFileLockTestCase' => 'filesystem/__tests__/PhutilFileLockTestCase.php', 'PhutilFrenchLocale' => 'internationalization/locales/PhutilFrenchLocale.php', 'PhutilGermanLocale' => 'internationalization/locales/PhutilGermanLocale.php', 'PhutilGitBinaryAnalyzer' => 'filesystem/binary/PhutilGitBinaryAnalyzer.php', 'PhutilGitHubFuture' => 'future/github/PhutilGitHubFuture.php', 'PhutilGitHubResponse' => 'future/github/PhutilGitHubResponse.php', 'PhutilGitURI' => 'parser/PhutilGitURI.php', 'PhutilGitURITestCase' => 'parser/__tests__/PhutilGitURITestCase.php', 'PhutilHTMLParser' => 'parser/html/PhutilHTMLParser.php', 'PhutilHTMLParserTestCase' => 'parser/html/__tests__/PhutilHTMLParserTestCase.php', 'PhutilHTTPEngineExtension' => 'future/http/PhutilHTTPEngineExtension.php', 'PhutilHTTPResponse' => 'parser/http/PhutilHTTPResponse.php', 'PhutilHTTPResponseParser' => 'parser/http/PhutilHTTPResponseParser.php', 'PhutilHTTPResponseParserTestCase' => 'parser/http/__tests__/PhutilHTTPResponseParserTestCase.php', 'PhutilHashingIterator' => 'utils/PhutilHashingIterator.php', 'PhutilHashingIteratorTestCase' => 'utils/__tests__/PhutilHashingIteratorTestCase.php', 'PhutilHelpArgumentWorkflow' => 'parser/argument/workflow/PhutilHelpArgumentWorkflow.php', 'PhutilHgsprintfTestCase' => 'xsprintf/__tests__/PhutilHgsprintfTestCase.php', 'PhutilINIParserException' => 'parser/exception/PhutilINIParserException.php', 'PhutilIPAddress' => 'ip/PhutilIPAddress.php', 'PhutilIPAddressTestCase' => 'ip/__tests__/PhutilIPAddressTestCase.php', 'PhutilIPv4Address' => 'ip/PhutilIPv4Address.php', 'PhutilIPv6Address' => 'ip/PhutilIPv6Address.php', 'PhutilInteractiveEditor' => 'console/PhutilInteractiveEditor.php', 'PhutilInvalidRuleParserGeneratorException' => 'parser/generator/exception/PhutilInvalidRuleParserGeneratorException.php', 'PhutilInvalidStateException' => 'exception/PhutilInvalidStateException.php', 'PhutilInvalidStateExceptionTestCase' => 'exception/__tests__/PhutilInvalidStateExceptionTestCase.php', 'PhutilIrreducibleRuleParserGeneratorException' => 'parser/generator/exception/PhutilIrreducibleRuleParserGeneratorException.php', 'PhutilJSON' => 'parser/PhutilJSON.php', 'PhutilJSONFragmentLexer' => 'lexer/PhutilJSONFragmentLexer.php', 'PhutilJSONParser' => 'parser/PhutilJSONParser.php', 'PhutilJSONParserException' => 'parser/exception/PhutilJSONParserException.php', 'PhutilJSONParserTestCase' => 'parser/__tests__/PhutilJSONParserTestCase.php', 'PhutilJSONProtocolChannel' => 'channel/PhutilJSONProtocolChannel.php', 'PhutilJSONProtocolChannelTestCase' => 'channel/__tests__/PhutilJSONProtocolChannelTestCase.php', 'PhutilJSONTestCase' => 'parser/__tests__/PhutilJSONTestCase.php', 'PhutilJavaFragmentLexer' => 'lexer/PhutilJavaFragmentLexer.php', 'PhutilKoreanLocale' => 'internationalization/locales/PhutilKoreanLocale.php', 'PhutilLanguageGuesser' => 'parser/PhutilLanguageGuesser.php', 'PhutilLanguageGuesserTestCase' => 'parser/__tests__/PhutilLanguageGuesserTestCase.php', 'PhutilLexer' => 'lexer/PhutilLexer.php', 'PhutilLibraryConflictException' => 'init/lib/PhutilLibraryConflictException.php', 'PhutilLibraryMapBuilder' => 'moduleutils/PhutilLibraryMapBuilder.php', 'PhutilLibraryTestCase' => '__tests__/PhutilLibraryTestCase.php', 'PhutilLocale' => 'internationalization/PhutilLocale.php', 'PhutilLocaleTestCase' => 'internationalization/__tests__/PhutilLocaleTestCase.php', 'PhutilLock' => 'filesystem/PhutilLock.php', 'PhutilLockException' => 'filesystem/PhutilLockException.php', 'PhutilLogFileChannel' => 'channel/PhutilLogFileChannel.php', 'PhutilLunarPhase' => 'utils/PhutilLunarPhase.php', 'PhutilLunarPhaseTestCase' => 'utils/__tests__/PhutilLunarPhaseTestCase.php', 'PhutilMercurialBinaryAnalyzer' => 'filesystem/binary/PhutilMercurialBinaryAnalyzer.php', 'PhutilMethodNotImplementedException' => 'error/PhutilMethodNotImplementedException.php', 'PhutilMetricsChannel' => 'channel/PhutilMetricsChannel.php', 'PhutilMissingSymbolException' => 'init/lib/PhutilMissingSymbolException.php', 'PhutilModuleUtilsTestCase' => 'init/lib/__tests__/PhutilModuleUtilsTestCase.php', 'PhutilNumber' => 'internationalization/PhutilNumber.php', 'PhutilOAuth1Future' => 'future/oauth/PhutilOAuth1Future.php', 'PhutilOAuth1FutureTestCase' => 'future/oauth/__tests__/PhutilOAuth1FutureTestCase.php', 'PhutilOpaqueEnvelope' => 'error/PhutilOpaqueEnvelope.php', 'PhutilOpaqueEnvelopeKey' => 'error/PhutilOpaqueEnvelopeKey.php', 'PhutilOpaqueEnvelopeTestCase' => 'error/__tests__/PhutilOpaqueEnvelopeTestCase.php', 'PhutilPHPFragmentLexer' => 'lexer/PhutilPHPFragmentLexer.php', 'PhutilPHPFragmentLexerTestCase' => 'lexer/__tests__/PhutilPHPFragmentLexerTestCase.php', 'PhutilPHPObjectProtocolChannel' => 'channel/PhutilPHPObjectProtocolChannel.php', 'PhutilPHPObjectProtocolChannelTestCase' => 'channel/__tests__/PhutilPHPObjectProtocolChannelTestCase.php', 'PhutilParserGenerator' => 'parser/PhutilParserGenerator.php', 'PhutilParserGeneratorException' => 'parser/generator/exception/PhutilParserGeneratorException.php', 'PhutilParserGeneratorTestCase' => 'parser/__tests__/PhutilParserGeneratorTestCase.php', 'PhutilPayPalAPIFuture' => 'future/paypal/PhutilPayPalAPIFuture.php', 'PhutilPerson' => 'internationalization/PhutilPerson.php', 'PhutilPersonTest' => 'internationalization/__tests__/PhutilPersonTest.php', 'PhutilPhtTestCase' => 'internationalization/__tests__/PhutilPhtTestCase.php', 'PhutilPirateEnglishLocale' => 'internationalization/locales/PhutilPirateEnglishLocale.php', 'PhutilPortugueseBrazilLocale' => 'internationalization/locales/PhutilPortugueseBrazilLocale.php', 'PhutilPortuguesePortugalLocale' => 'internationalization/locales/PhutilPortuguesePortugalLocale.php', 'PhutilPostmarkFuture' => 'future/postmark/PhutilPostmarkFuture.php', 'PhutilPregsprintfTestCase' => 'xsprintf/__tests__/PhutilPregsprintfTestCase.php', 'PhutilProcessQuery' => 'filesystem/PhutilProcessQuery.php', 'PhutilProcessRef' => 'filesystem/PhutilProcessRef.php', 'PhutilProcessRefTestCase' => 'filesystem/__tests__/PhutilProcessRefTestCase.php', 'PhutilProgressSink' => 'progress/PhutilProgressSink.php', 'PhutilProtocolChannel' => 'channel/PhutilProtocolChannel.php', 'PhutilProxyException' => 'error/PhutilProxyException.php', 'PhutilProxyIterator' => 'utils/PhutilProxyIterator.php', 'PhutilPygmentizeBinaryAnalyzer' => 'filesystem/binary/PhutilPygmentizeBinaryAnalyzer.php', 'PhutilPythonFragmentLexer' => 'lexer/PhutilPythonFragmentLexer.php', 'PhutilQueryStringParser' => 'parser/PhutilQueryStringParser.php', 'PhutilQueryStringParserTestCase' => 'parser/__tests__/PhutilQueryStringParserTestCase.php', 'PhutilRawEnglishLocale' => 'internationalization/locales/PhutilRawEnglishLocale.php', 'PhutilReadableSerializer' => 'readableserializer/PhutilReadableSerializer.php', 'PhutilReadableSerializerTestCase' => 'readableserializer/__tests__/PhutilReadableSerializerTestCase.php', 'PhutilRope' => 'utils/PhutilRope.php', 'PhutilRopeTestCase' => 'utils/__tests__/PhutilRopeTestCase.php', 'PhutilServiceProfiler' => 'serviceprofiler/PhutilServiceProfiler.php', 'PhutilShellLexer' => 'lexer/PhutilShellLexer.php', 'PhutilShellLexerTestCase' => 'lexer/__tests__/PhutilShellLexerTestCase.php', 'PhutilSignalHandler' => 'future/exec/PhutilSignalHandler.php', 'PhutilSignalRouter' => 'future/exec/PhutilSignalRouter.php', 'PhutilSimpleOptions' => 'parser/PhutilSimpleOptions.php', 'PhutilSimpleOptionsLexer' => 'lexer/PhutilSimpleOptionsLexer.php', 'PhutilSimpleOptionsLexerTestCase' => 'lexer/__tests__/PhutilSimpleOptionsLexerTestCase.php', 'PhutilSimpleOptionsTestCase' => 'parser/__tests__/PhutilSimpleOptionsTestCase.php', 'PhutilSimplifiedChineseLocale' => 'internationalization/locales/PhutilSimplifiedChineseLocale.php', 'PhutilSlackFuture' => 'future/slack/PhutilSlackFuture.php', 'PhutilSocketChannel' => 'channel/PhutilSocketChannel.php', 'PhutilSortVector' => 'utils/PhutilSortVector.php', 'PhutilSpanishSpainLocale' => 'internationalization/locales/PhutilSpanishSpainLocale.php', 'PhutilStreamIterator' => 'utils/PhutilStreamIterator.php', 'PhutilSubversionBinaryAnalyzer' => 'filesystem/binary/PhutilSubversionBinaryAnalyzer.php', 'PhutilSymbolLoader' => 'symbols/PhutilSymbolLoader.php', 'PhutilSystem' => 'utils/PhutilSystem.php', 'PhutilSystemTestCase' => 'utils/__tests__/PhutilSystemTestCase.php', 'PhutilTerminalString' => 'xsprintf/PhutilTerminalString.php', 'PhutilTestCase' => 'unit/engine/phutil/PhutilTestCase.php', 'PhutilTestCaseTestCase' => 'unit/engine/phutil/testcase/PhutilTestCaseTestCase.php', 'PhutilTestPhobject' => 'object/__tests__/PhutilTestPhobject.php', 'PhutilTestSkippedException' => 'unit/engine/phutil/testcase/PhutilTestSkippedException.php', 'PhutilTestTerminatedException' => 'unit/engine/phutil/testcase/PhutilTestTerminatedException.php', 'PhutilTraditionalChineseLocale' => 'internationalization/locales/PhutilTraditionalChineseLocale.php', 'PhutilTranslation' => 'internationalization/PhutilTranslation.php', 'PhutilTranslationTestCase' => 'internationalization/__tests__/PhutilTranslationTestCase.php', 'PhutilTranslator' => 'internationalization/PhutilTranslator.php', 'PhutilTranslatorTestCase' => 'internationalization/__tests__/PhutilTranslatorTestCase.php', 'PhutilTsprintfTestCase' => 'xsprintf/__tests__/PhutilTsprintfTestCase.php', 'PhutilTwitchFuture' => 'future/twitch/PhutilTwitchFuture.php', 'PhutilTypeCheckException' => 'parser/exception/PhutilTypeCheckException.php', 'PhutilTypeExtraParametersException' => 'parser/exception/PhutilTypeExtraParametersException.php', 'PhutilTypeLexer' => 'lexer/PhutilTypeLexer.php', 'PhutilTypeMissingParametersException' => 'parser/exception/PhutilTypeMissingParametersException.php', 'PhutilTypeSpec' => 'parser/PhutilTypeSpec.php', 'PhutilTypeSpecTestCase' => 'parser/__tests__/PhutilTypeSpecTestCase.php', 'PhutilURI' => 'parser/PhutilURI.php', 'PhutilURITestCase' => 'parser/__tests__/PhutilURITestCase.php', 'PhutilUSEnglishLocale' => 'internationalization/locales/PhutilUSEnglishLocale.php', 'PhutilUTF8StringTruncator' => 'utils/PhutilUTF8StringTruncator.php', 'PhutilUTF8TestCase' => 'utils/__tests__/PhutilUTF8TestCase.php', 'PhutilUnitTestEngine' => 'unit/engine/PhutilUnitTestEngine.php', 'PhutilUnitTestEngineTestCase' => 'unit/engine/__tests__/PhutilUnitTestEngineTestCase.php', 'PhutilUnknownSymbolParserGeneratorException' => 'parser/generator/exception/PhutilUnknownSymbolParserGeneratorException.php', 'PhutilUnreachableRuleParserGeneratorException' => 'parser/generator/exception/PhutilUnreachableRuleParserGeneratorException.php', 'PhutilUnreachableTerminalParserGeneratorException' => 'parser/generator/exception/PhutilUnreachableTerminalParserGeneratorException.php', 'PhutilUrisprintfTestCase' => 'xsprintf/__tests__/PhutilUrisprintfTestCase.php', 'PhutilUtilsTestCase' => 'utils/__tests__/PhutilUtilsTestCase.php', 'PhutilVeryWowEnglishLocale' => 'internationalization/locales/PhutilVeryWowEnglishLocale.php', 'PhutilWordPressFuture' => 'future/wordpress/PhutilWordPressFuture.php', 'PhutilXHPASTBinary' => 'parser/xhpast/bin/PhutilXHPASTBinary.php', 'PytestTestEngine' => 'unit/engine/PytestTestEngine.php', 'TempFile' => 'filesystem/TempFile.php', 'TestAbstractDirectedGraph' => 'utils/__tests__/TestAbstractDirectedGraph.php', 'XHPASTNode' => 'parser/xhpast/api/XHPASTNode.php', 'XHPASTNodeTestCase' => 'parser/xhpast/api/__tests__/XHPASTNodeTestCase.php', 'XHPASTSyntaxErrorException' => 'parser/xhpast/api/XHPASTSyntaxErrorException.php', 'XHPASTToken' => 'parser/xhpast/api/XHPASTToken.php', 'XHPASTTree' => 'parser/xhpast/api/XHPASTTree.php', 'XHPASTTreeTestCase' => 'parser/xhpast/api/__tests__/XHPASTTreeTestCase.php', 'XUnitTestEngine' => 'unit/engine/XUnitTestEngine.php', 'XUnitTestResultParserTestCase' => 'unit/parser/__tests__/XUnitTestResultParserTestCase.php', 'XsprintfUnknownConversionException' => 'xsprintf/exception/XsprintfUnknownConversionException.php', ), 'function' => array( '__phutil_autoload' => 'init/init-library.php', 'array_fuse' => 'utils/utils.php', 'array_interleave' => 'utils/utils.php', 'array_mergev' => 'utils/utils.php', 'array_select_keys' => 'utils/utils.php', 'assert_instances_of' => 'utils/utils.php', 'assert_same_keys' => 'utils/utils.php', 'assert_stringlike' => 'utils/utils.php', 'coalesce' => 'utils/utils.php', 'csprintf' => 'xsprintf/csprintf.php', 'exec_manual' => 'future/exec/execx.php', 'execx' => 'future/exec/execx.php', 'head' => 'utils/utils.php', 'head_key' => 'utils/utils.php', 'hgsprintf' => 'xsprintf/hgsprintf.php', 'id' => 'utils/utils.php', 'idx' => 'utils/utils.php', 'idxv' => 'utils/utils.php', 'ifilter' => 'utils/utils.php', 'igroup' => 'utils/utils.php', 'ipull' => 'utils/utils.php', 'isort' => 'utils/utils.php', 'jsprintf' => 'xsprintf/jsprintf.php', 'last' => 'utils/utils.php', 'last_key' => 'utils/utils.php', 'ldap_sprintf' => 'xsprintf/ldapsprintf.php', 'mfilter' => 'utils/utils.php', 'mgroup' => 'utils/utils.php', 'mpull' => 'utils/utils.php', 'msort' => 'utils/utils.php', 'msortv' => 'utils/utils.php', + 'msortv_internal' => 'utils/utils.php', + 'msortv_natural' => 'utils/utils.php', 'newv' => 'utils/utils.php', 'nonempty' => 'utils/utils.php', 'phlog' => 'error/phlog.php', 'pht' => 'internationalization/pht.php', 'phutil_build_http_querystring' => 'utils/utils.php', 'phutil_build_http_querystring_from_pairs' => 'utils/utils.php', 'phutil_censor_credentials' => 'utils/utils.php', 'phutil_console_confirm' => 'console/format.php', 'phutil_console_format' => 'console/format.php', 'phutil_console_get_terminal_width' => 'console/format.php', 'phutil_console_prompt' => 'console/format.php', 'phutil_console_require_tty' => 'console/format.php', 'phutil_console_select' => 'console/format.php', 'phutil_console_wrap' => 'console/format.php', 'phutil_count' => 'internationalization/pht.php', 'phutil_date_format' => 'utils/viewutils.php', 'phutil_decode_mime_header' => 'utils/utils.php', 'phutil_deprecated' => 'init/lib/moduleutils.php', 'phutil_describe_type' => 'utils/utils.php', 'phutil_encode_log' => 'utils/utils.php', 'phutil_error_listener_example' => 'error/phlog.php', 'phutil_escape_uri' => 'utils/utils.php', 'phutil_escape_uri_path_component' => 'utils/utils.php', 'phutil_fnmatch' => 'utils/utils.php', 'phutil_format_bytes' => 'utils/viewutils.php', 'phutil_format_relative_time' => 'utils/viewutils.php', 'phutil_format_relative_time_detailed' => 'utils/viewutils.php', 'phutil_format_units_generic' => 'utils/viewutils.php', 'phutil_fwrite_nonblocking_stream' => 'utils/utils.php', 'phutil_get_current_library_name' => 'init/lib/moduleutils.php', 'phutil_get_library_name_for_root' => 'init/lib/moduleutils.php', 'phutil_get_library_root' => 'init/lib/moduleutils.php', 'phutil_get_library_root_for_path' => 'init/lib/moduleutils.php', 'phutil_get_signal_name' => 'future/exec/execx.php', 'phutil_get_system_locale' => 'utils/utf8.php', 'phutil_glue' => 'utils/utils.php', 'phutil_hashes_are_identical' => 'utils/utils.php', 'phutil_http_parameter_pair' => 'utils/utils.php', 'phutil_ini_decode' => 'utils/utils.php', 'phutil_is_hiphop_runtime' => 'utils/utils.php', 'phutil_is_interactive' => 'utils/utils.php', 'phutil_is_natural_list' => 'utils/utils.php', 'phutil_is_noninteractive' => 'utils/utils.php', 'phutil_is_system_locale_available' => 'utils/utf8.php', 'phutil_is_utf8' => 'utils/utf8.php', 'phutil_is_utf8_slowly' => 'utils/utf8.php', 'phutil_is_utf8_with_only_bmp_characters' => 'utils/utf8.php', 'phutil_is_windows' => 'utils/utils.php', 'phutil_json_decode' => 'utils/utils.php', 'phutil_json_encode' => 'utils/utils.php', 'phutil_load_library' => 'init/lib/moduleutils.php', 'phutil_loggable_string' => 'utils/utils.php', 'phutil_microseconds_since' => 'utils/utils.php', 'phutil_parse_bytes' => 'utils/viewutils.php', + 'phutil_partition' => 'utils/utils.php', 'phutil_passthru' => 'future/exec/execx.php', 'phutil_person' => 'internationalization/pht.php', 'phutil_register_library' => 'init/lib/core.php', 'phutil_register_library_map' => 'init/lib/core.php', 'phutil_set_system_locale' => 'utils/utf8.php', 'phutil_split_lines' => 'utils/utils.php', 'phutil_string_cast' => 'utils/utils.php', 'phutil_unescape_uri_path_component' => 'utils/utils.php', 'phutil_units' => 'utils/utils.php', 'phutil_utf8_console_strlen' => 'utils/utf8.php', 'phutil_utf8_convert' => 'utils/utf8.php', 'phutil_utf8_encode_codepoint' => 'utils/utf8.php', 'phutil_utf8_hard_wrap' => 'utils/utf8.php', 'phutil_utf8_hard_wrap_html' => 'utils/utf8.php', 'phutil_utf8_is_cjk' => 'utils/utf8.php', 'phutil_utf8_is_combining_character' => 'utils/utf8.php', 'phutil_utf8_strlen' => 'utils/utf8.php', 'phutil_utf8_strtolower' => 'utils/utf8.php', 'phutil_utf8_strtoupper' => 'utils/utf8.php', 'phutil_utf8_strtr' => 'utils/utf8.php', 'phutil_utf8_ucwords' => 'utils/utf8.php', 'phutil_utf8ize' => 'utils/utf8.php', 'phutil_utf8v' => 'utils/utf8.php', 'phutil_utf8v_codepoints' => 'utils/utf8.php', 'phutil_utf8v_combine_characters' => 'utils/utf8.php', 'phutil_utf8v_combined' => 'utils/utf8.php', 'phutil_validate_json' => 'utils/utils.php', 'phutil_var_export' => 'utils/utils.php', 'ppull' => 'utils/utils.php', 'pregsprintf' => 'xsprintf/pregsprintf.php', 'tsprintf' => 'xsprintf/tsprintf.php', 'urisprintf' => 'xsprintf/urisprintf.php', 'vcsprintf' => 'xsprintf/csprintf.php', 'vjsprintf' => 'xsprintf/jsprintf.php', 'vurisprintf' => 'xsprintf/urisprintf.php', 'xhp_parser_node_constants' => 'parser/xhpast/parser_nodes.php', 'xhpast_parser_token_constants' => 'parser/xhpast/parser_tokens.php', 'xsprintf' => 'xsprintf/xsprintf.php', 'xsprintf_callback_example' => 'xsprintf/xsprintf.php', 'xsprintf_command' => 'xsprintf/csprintf.php', 'xsprintf_javascript' => 'xsprintf/jsprintf.php', 'xsprintf_ldap' => 'xsprintf/ldapsprintf.php', 'xsprintf_mercurial' => 'xsprintf/hgsprintf.php', 'xsprintf_regex' => 'xsprintf/pregsprintf.php', 'xsprintf_terminal' => 'xsprintf/tsprintf.php', 'xsprintf_uri' => 'xsprintf/urisprintf.php', ), 'xmap' => array( 'AASTNode' => 'Phobject', 'AASTNodeList' => array( 'Phobject', 'Countable', 'Iterator', ), 'AASTToken' => 'Phobject', 'AASTTree' => 'Phobject', 'AbstractDirectedGraph' => 'Phobject', 'AbstractDirectedGraphTestCase' => 'PhutilTestCase', 'ArcanistAbstractMethodBodyXHPASTLinterRule' => 'ArcanistXHPASTLinterRule', 'ArcanistAbstractMethodBodyXHPASTLinterRuleTestCase' => 'ArcanistXHPASTLinterRuleTestCase', 'ArcanistAbstractPrivateMethodXHPASTLinterRule' => 'ArcanistXHPASTLinterRule', 'ArcanistAbstractPrivateMethodXHPASTLinterRuleTestCase' => 'ArcanistXHPASTLinterRuleTestCase', 'ArcanistAlias' => 'Phobject', 'ArcanistAliasEffect' => 'Phobject', 'ArcanistAliasEngine' => 'Phobject', 'ArcanistAliasFunctionXHPASTLinterRule' => 'ArcanistXHPASTLinterRule', 'ArcanistAliasFunctionXHPASTLinterRuleTestCase' => 'ArcanistXHPASTLinterRuleTestCase', 'ArcanistAliasWorkflow' => 'ArcanistWorkflow', - 'ArcanistAliasesConfigOption' => 'ArcanistListConfigOption', + 'ArcanistAliasesConfigOption' => 'ArcanistMultiSourceConfigOption', 'ArcanistAmendWorkflow' => 'ArcanistArcWorkflow', 'ArcanistAnoidWorkflow' => 'ArcanistArcWorkflow', 'ArcanistArcConfigurationEngineExtension' => 'ArcanistConfigurationEngineExtension', 'ArcanistArcToolset' => 'ArcanistToolset', 'ArcanistArcWorkflow' => 'ArcanistWorkflow', 'ArcanistArrayCombineXHPASTLinterRule' => 'ArcanistXHPASTLinterRule', 'ArcanistArrayCombineXHPASTLinterRuleTestCase' => 'ArcanistXHPASTLinterRuleTestCase', 'ArcanistArrayIndexSpacingXHPASTLinterRule' => 'ArcanistXHPASTLinterRule', 'ArcanistArrayIndexSpacingXHPASTLinterRuleTestCase' => 'ArcanistXHPASTLinterRuleTestCase', 'ArcanistArraySeparatorXHPASTLinterRule' => 'ArcanistXHPASTLinterRule', 'ArcanistArraySeparatorXHPASTLinterRuleTestCase' => 'ArcanistXHPASTLinterRuleTestCase', 'ArcanistArrayValueXHPASTLinterRule' => 'ArcanistXHPASTLinterRule', 'ArcanistArrayValueXHPASTLinterRuleTestCase' => 'ArcanistXHPASTLinterRuleTestCase', 'ArcanistBaseCommitParser' => 'Phobject', 'ArcanistBaseCommitParserTestCase' => 'PhutilTestCase', 'ArcanistBaseXHPASTLinter' => 'ArcanistFutureLinter', 'ArcanistBinaryExpressionSpacingXHPASTLinterRule' => 'ArcanistXHPASTLinterRule', 'ArcanistBinaryExpressionSpacingXHPASTLinterRuleTestCase' => 'ArcanistXHPASTLinterRuleTestCase', 'ArcanistBinaryNumericScalarCasingXHPASTLinterRule' => 'ArcanistXHPASTLinterRule', 'ArcanistBinaryNumericScalarCasingXHPASTLinterRuleTestCase' => 'ArcanistXHPASTLinterRuleTestCase', 'ArcanistBlacklistedFunctionXHPASTLinterRule' => 'ArcanistXHPASTLinterRule', 'ArcanistBlacklistedFunctionXHPASTLinterRuleTestCase' => 'ArcanistXHPASTLinterRuleTestCase', 'ArcanistBlindlyTrustHTTPEngineExtension' => 'PhutilHTTPEngineExtension', - 'ArcanistBookmarkWorkflow' => 'ArcanistFeatureBaseWorkflow', + 'ArcanistBookmarksWorkflow' => 'ArcanistMarkersWorkflow', + 'ArcanistBoolConfigOption' => 'ArcanistSingleSourceConfigOption', 'ArcanistBraceFormattingXHPASTLinterRule' => 'ArcanistXHPASTLinterRule', 'ArcanistBraceFormattingXHPASTLinterRuleTestCase' => 'ArcanistXHPASTLinterRuleTestCase', - 'ArcanistBranchRef' => 'ArcanistRef', - 'ArcanistBranchWorkflow' => 'ArcanistFeatureBaseWorkflow', + 'ArcanistBranchesWorkflow' => 'ArcanistMarkersWorkflow', 'ArcanistBrowseCommitHardpointQuery' => 'ArcanistRuntimeHardpointQuery', 'ArcanistBrowseCommitURIHardpointQuery' => 'ArcanistBrowseURIHardpointQuery', 'ArcanistBrowseObjectNameURIHardpointQuery' => 'ArcanistBrowseURIHardpointQuery', 'ArcanistBrowsePathURIHardpointQuery' => 'ArcanistBrowseURIHardpointQuery', 'ArcanistBrowseRef' => 'ArcanistRef', 'ArcanistBrowseRefInspector' => 'ArcanistRefInspector', 'ArcanistBrowseRevisionURIHardpointQuery' => 'ArcanistBrowseURIHardpointQuery', 'ArcanistBrowseURIHardpointQuery' => 'ArcanistRuntimeHardpointQuery', 'ArcanistBrowseURIRef' => 'ArcanistRef', 'ArcanistBrowseWorkflow' => 'ArcanistArcWorkflow', - 'ArcanistBuildPlanRef' => 'Phobject', - 'ArcanistBuildRef' => 'Phobject', + 'ArcanistBuildBuildplanHardpointQuery' => 'ArcanistRuntimeHardpointQuery', + 'ArcanistBuildPlanRef' => 'ArcanistRef', + 'ArcanistBuildPlanSymbolRef' => 'ArcanistSimpleSymbolRef', + 'ArcanistBuildRef' => 'ArcanistRef', + 'ArcanistBuildSymbolRef' => 'ArcanistSimpleSymbolRef', + 'ArcanistBuildableBuildsHardpointQuery' => 'ArcanistRuntimeHardpointQuery', + 'ArcanistBuildableRef' => 'ArcanistRef', + 'ArcanistBuildableSymbolRef' => 'ArcanistSimpleSymbolRef', 'ArcanistBundle' => 'Phobject', 'ArcanistBundleTestCase' => 'PhutilTestCase', 'ArcanistCSSLintLinter' => 'ArcanistExternalLinter', 'ArcanistCSSLintLinterTestCase' => 'ArcanistExternalLinterTestCase', 'ArcanistCSharpLinter' => 'ArcanistLinter', 'ArcanistCallConduitWorkflow' => 'ArcanistArcWorkflow', 'ArcanistCallParenthesesXHPASTLinterRule' => 'ArcanistXHPASTLinterRule', 'ArcanistCallParenthesesXHPASTLinterRuleTestCase' => 'ArcanistXHPASTLinterRuleTestCase', 'ArcanistCallTimePassByReferenceXHPASTLinterRule' => 'ArcanistXHPASTLinterRule', 'ArcanistCallTimePassByReferenceXHPASTLinterRuleTestCase' => 'ArcanistXHPASTLinterRuleTestCase', 'ArcanistCapabilityNotSupportedException' => 'Exception', 'ArcanistCastSpacingXHPASTLinterRule' => 'ArcanistXHPASTLinterRule', 'ArcanistCastSpacingXHPASTLinterRuleTestCase' => 'ArcanistXHPASTLinterRuleTestCase', 'ArcanistCheckstyleXMLLintRenderer' => 'ArcanistLintRenderer', 'ArcanistChmodLinter' => 'ArcanistLinter', 'ArcanistChmodLinterTestCase' => 'ArcanistLinterTestCase', 'ArcanistClassExtendsObjectXHPASTLinterRule' => 'ArcanistXHPASTLinterRule', 'ArcanistClassExtendsObjectXHPASTLinterRuleTestCase' => 'ArcanistXHPASTLinterRuleTestCase', 'ArcanistClassFilenameMismatchXHPASTLinterRule' => 'ArcanistXHPASTLinterRule', 'ArcanistClassMustBeDeclaredAbstractXHPASTLinterRule' => 'ArcanistXHPASTLinterRule', 'ArcanistClassMustBeDeclaredAbstractXHPASTLinterRuleTestCase' => 'ArcanistXHPASTLinterRuleTestCase', 'ArcanistClassNameLiteralXHPASTLinterRule' => 'ArcanistXHPASTLinterRule', 'ArcanistClassNameLiteralXHPASTLinterRuleTestCase' => 'ArcanistXHPASTLinterRuleTestCase', 'ArcanistCloseRevisionWorkflow' => 'ArcanistWorkflow', 'ArcanistClosureLinter' => 'ArcanistExternalLinter', 'ArcanistClosureLinterTestCase' => 'ArcanistExternalLinterTestCase', 'ArcanistCoffeeLintLinter' => 'ArcanistExternalLinter', 'ArcanistCoffeeLintLinterTestCase' => 'ArcanistExternalLinterTestCase', 'ArcanistCommand' => 'Phobject', 'ArcanistCommentRemover' => 'Phobject', 'ArcanistCommentRemoverTestCase' => 'PhutilTestCase', 'ArcanistCommentSpacingXHPASTLinterRule' => 'ArcanistXHPASTLinterRule', 'ArcanistCommentStyleXHPASTLinterRule' => 'ArcanistXHPASTLinterRule', 'ArcanistCommentStyleXHPASTLinterRuleTestCase' => 'ArcanistXHPASTLinterRuleTestCase', + 'ArcanistCommitGraph' => 'Phobject', + 'ArcanistCommitGraphPartition' => 'Phobject', + 'ArcanistCommitGraphPartitionQuery' => 'Phobject', + 'ArcanistCommitGraphQuery' => 'Phobject', + 'ArcanistCommitGraphSet' => 'Phobject', + 'ArcanistCommitGraphSetQuery' => 'Phobject', + 'ArcanistCommitGraphSetTreeView' => 'Phobject', + 'ArcanistCommitGraphSetView' => 'Phobject', + 'ArcanistCommitGraphTestCase' => 'PhutilTestCase', + 'ArcanistCommitNode' => 'Phobject', 'ArcanistCommitRef' => 'ArcanistRef', 'ArcanistCommitSymbolRef' => 'ArcanistSymbolRef', 'ArcanistCommitSymbolRefInspector' => 'ArcanistRefInspector', 'ArcanistCommitUpstreamHardpointQuery' => 'ArcanistRuntimeHardpointQuery', 'ArcanistCommitWorkflow' => 'ArcanistWorkflow', 'ArcanistCompilerLintRenderer' => 'ArcanistLintRenderer', 'ArcanistComposerLinter' => 'ArcanistLinter', 'ArcanistComprehensiveLintEngine' => 'ArcanistLintEngine', 'ArcanistConcatenationOperatorXHPASTLinterRule' => 'ArcanistXHPASTLinterRule', 'ArcanistConcatenationOperatorXHPASTLinterRuleTestCase' => 'ArcanistXHPASTLinterRuleTestCase', - 'ArcanistConduitCall' => 'Phobject', + 'ArcanistConduitAuthenticationException' => 'Exception', + 'ArcanistConduitCallFuture' => 'FutureProxy', 'ArcanistConduitEngine' => 'Phobject', 'ArcanistConduitException' => 'Exception', 'ArcanistConfigOption' => 'Phobject', 'ArcanistConfiguration' => 'Phobject', 'ArcanistConfigurationDrivenLintEngine' => 'ArcanistLintEngine', 'ArcanistConfigurationDrivenUnitTestEngine' => 'ArcanistUnitTestEngine', 'ArcanistConfigurationEngine' => 'Phobject', 'ArcanistConfigurationEngineExtension' => 'Phobject', 'ArcanistConfigurationManager' => 'Phobject', 'ArcanistConfigurationSource' => 'Phobject', 'ArcanistConfigurationSourceList' => 'Phobject', 'ArcanistConfigurationSourceValue' => 'Phobject', 'ArcanistConsoleLintRenderer' => 'ArcanistLintRenderer', 'ArcanistConsoleLintRendererTestCase' => 'PhutilTestCase', 'ArcanistConstructorParenthesesXHPASTLinterRule' => 'ArcanistXHPASTLinterRule', 'ArcanistConstructorParenthesesXHPASTLinterRuleTestCase' => 'ArcanistXHPASTLinterRuleTestCase', 'ArcanistContinueInsideSwitchXHPASTLinterRule' => 'ArcanistXHPASTLinterRule', 'ArcanistContinueInsideSwitchXHPASTLinterRuleTestCase' => 'ArcanistXHPASTLinterRuleTestCase', 'ArcanistControlStatementSpacingXHPASTLinterRule' => 'ArcanistXHPASTLinterRule', 'ArcanistControlStatementSpacingXHPASTLinterRuleTestCase' => 'ArcanistXHPASTLinterRuleTestCase', 'ArcanistCoverWorkflow' => 'ArcanistWorkflow', 'ArcanistCppcheckLinter' => 'ArcanistExternalLinter', 'ArcanistCppcheckLinterTestCase' => 'ArcanistExternalLinterTestCase', 'ArcanistCpplintLinter' => 'ArcanistExternalLinter', 'ArcanistCpplintLinterTestCase' => 'ArcanistExternalLinterTestCase', 'ArcanistCurlyBraceArrayIndexXHPASTLinterRule' => 'ArcanistXHPASTLinterRule', 'ArcanistCurlyBraceArrayIndexXHPASTLinterRuleTestCase' => 'ArcanistXHPASTLinterRuleTestCase', 'ArcanistDeclarationParenthesesXHPASTLinterRule' => 'ArcanistXHPASTLinterRule', 'ArcanistDeclarationParenthesesXHPASTLinterRuleTestCase' => 'ArcanistXHPASTLinterRuleTestCase', 'ArcanistDefaultParametersXHPASTLinterRule' => 'ArcanistXHPASTLinterRule', 'ArcanistDefaultParametersXHPASTLinterRuleTestCase' => 'ArcanistXHPASTLinterRuleTestCase', 'ArcanistDefaultsConfigurationSource' => 'ArcanistDictionaryConfigurationSource', 'ArcanistDeprecationXHPASTLinterRule' => 'ArcanistXHPASTLinterRule', 'ArcanistDeprecationXHPASTLinterRuleTestCase' => 'ArcanistXHPASTLinterRuleTestCase', 'ArcanistDictionaryConfigurationSource' => 'ArcanistConfigurationSource', 'ArcanistDiffByteSizeException' => 'Exception', 'ArcanistDiffChange' => 'Phobject', 'ArcanistDiffChangeType' => 'Phobject', 'ArcanistDiffHunk' => 'Phobject', 'ArcanistDiffParser' => 'Phobject', 'ArcanistDiffParserTestCase' => 'PhutilTestCase', 'ArcanistDiffUtils' => 'Phobject', 'ArcanistDiffUtilsTestCase' => 'PhutilTestCase', 'ArcanistDiffVectorNode' => 'Phobject', 'ArcanistDiffVectorTree' => 'Phobject', 'ArcanistDiffWorkflow' => 'ArcanistWorkflow', 'ArcanistDifferentialCommitMessage' => 'Phobject', 'ArcanistDifferentialCommitMessageParserException' => 'Exception', 'ArcanistDifferentialDependencyGraph' => 'AbstractDirectedGraph', 'ArcanistDifferentialRevisionHash' => 'Phobject', 'ArcanistDifferentialRevisionStatus' => 'Phobject', - 'ArcanistDisplayRef' => array( - 'Phobject', - 'ArcanistTerminalStringInterface', - ), 'ArcanistDoubleQuoteXHPASTLinterRule' => 'ArcanistXHPASTLinterRule', 'ArcanistDoubleQuoteXHPASTLinterRuleTestCase' => 'ArcanistXHPASTLinterRuleTestCase', 'ArcanistDownloadWorkflow' => 'ArcanistArcWorkflow', 'ArcanistDuplicateKeysInArrayXHPASTLinterRule' => 'ArcanistXHPASTLinterRule', 'ArcanistDuplicateKeysInArrayXHPASTLinterRuleTestCase' => 'ArcanistXHPASTLinterRuleTestCase', 'ArcanistDuplicateSwitchCaseXHPASTLinterRule' => 'ArcanistXHPASTLinterRule', 'ArcanistDuplicateSwitchCaseXHPASTLinterRuleTestCase' => 'ArcanistXHPASTLinterRuleTestCase', 'ArcanistDynamicDefineXHPASTLinterRule' => 'ArcanistXHPASTLinterRule', 'ArcanistDynamicDefineXHPASTLinterRuleTestCase' => 'ArcanistXHPASTLinterRuleTestCase', 'ArcanistElseIfUsageXHPASTLinterRule' => 'ArcanistXHPASTLinterRule', 'ArcanistElseIfUsageXHPASTLinterRuleTestCase' => 'ArcanistXHPASTLinterRuleTestCase', 'ArcanistEmptyFileXHPASTLinterRule' => 'ArcanistXHPASTLinterRule', 'ArcanistEmptyStatementXHPASTLinterRule' => 'ArcanistXHPASTLinterRule', 'ArcanistEmptyStatementXHPASTLinterRuleTestCase' => 'ArcanistXHPASTLinterRuleTestCase', 'ArcanistEventType' => 'PhutilEventType', 'ArcanistExitExpressionXHPASTLinterRule' => 'ArcanistXHPASTLinterRule', 'ArcanistExitExpressionXHPASTLinterRuleTestCase' => 'ArcanistXHPASTLinterRuleTestCase', 'ArcanistExportWorkflow' => 'ArcanistWorkflow', 'ArcanistExternalLinter' => 'ArcanistFutureLinter', 'ArcanistExternalLinterTestCase' => 'ArcanistLinterTestCase', 'ArcanistExtractUseXHPASTLinterRule' => 'ArcanistXHPASTLinterRule', 'ArcanistExtractUseXHPASTLinterRuleTestCase' => 'ArcanistXHPASTLinterRuleTestCase', - 'ArcanistFeatureBaseWorkflow' => 'ArcanistArcWorkflow', - 'ArcanistFeatureWorkflow' => 'ArcanistFeatureBaseWorkflow', 'ArcanistFileConfigurationSource' => 'ArcanistFilesystemConfigurationSource', 'ArcanistFileDataRef' => 'Phobject', - 'ArcanistFileRef' => array( - 'ArcanistRef', - 'ArcanistDisplayRefInterface', - ), + 'ArcanistFileRef' => 'ArcanistRef', 'ArcanistFileSymbolRef' => 'ArcanistSimpleSymbolRef', 'ArcanistFileUploader' => 'Phobject', 'ArcanistFilenameLinter' => 'ArcanistLinter', 'ArcanistFilenameLinterTestCase' => 'ArcanistLinterTestCase', 'ArcanistFilesystemAPI' => 'ArcanistRepositoryAPI', 'ArcanistFilesystemConfigurationSource' => 'ArcanistDictionaryConfigurationSource', 'ArcanistFilesystemWorkingCopy' => 'ArcanistWorkingCopy', 'ArcanistFlake8Linter' => 'ArcanistExternalLinter', 'ArcanistFlake8LinterTestCase' => 'ArcanistExternalLinterTestCase', 'ArcanistFormattedStringXHPASTLinterRule' => 'ArcanistXHPASTLinterRule', 'ArcanistFormattedStringXHPASTLinterRuleTestCase' => 'ArcanistXHPASTLinterRuleTestCase', 'ArcanistFunctionCallShouldBeTypeCastXHPASTLinterRule' => 'ArcanistXHPASTLinterRule', 'ArcanistFunctionCallShouldBeTypeCastXHPASTLinterRuleTestCase' => 'ArcanistXHPASTLinterRuleTestCase', 'ArcanistFutureLinter' => 'ArcanistLinter', 'ArcanistGeneratedLinter' => 'ArcanistLinter', 'ArcanistGeneratedLinterTestCase' => 'ArcanistLinterTestCase', 'ArcanistGetConfigWorkflow' => 'ArcanistWorkflow', 'ArcanistGitAPI' => 'ArcanistRepositoryAPI', + 'ArcanistGitCommitGraphQuery' => 'ArcanistCommitGraphQuery', 'ArcanistGitCommitMessageHardpointQuery' => 'ArcanistWorkflowGitHardpointQuery', 'ArcanistGitCommitSymbolCommitHardpointQuery' => 'ArcanistWorkflowGitHardpointQuery', 'ArcanistGitLandEngine' => 'ArcanistLandEngine', + 'ArcanistGitLocalState' => 'ArcanistRepositoryLocalState', + 'ArcanistGitRawCommit' => 'Phobject', + 'ArcanistGitRawCommitTestCase' => 'PhutilTestCase', + 'ArcanistGitRepositoryMarkerQuery' => 'ArcanistRepositoryMarkerQuery', + 'ArcanistGitRepositoryRemoteQuery' => 'ArcanistRepositoryRemoteQuery', 'ArcanistGitUpstreamPath' => 'Phobject', + 'ArcanistGitWorkEngine' => 'ArcanistWorkEngine', 'ArcanistGitWorkingCopy' => 'ArcanistWorkingCopy', 'ArcanistGitWorkingCopyRevisionHardpointQuery' => 'ArcanistWorkflowGitHardpointQuery', 'ArcanistGlobalVariableXHPASTLinterRule' => 'ArcanistXHPASTLinterRule', 'ArcanistGlobalVariableXHPASTLinterRuleTestCase' => 'ArcanistXHPASTLinterRuleTestCase', 'ArcanistGoLintLinter' => 'ArcanistExternalLinter', 'ArcanistGoLintLinterTestCase' => 'ArcanistExternalLinterTestCase', 'ArcanistGoTestResultParser' => 'ArcanistTestResultParser', 'ArcanistGoTestResultParserTestCase' => 'PhutilTestCase', + 'ArcanistGridCell' => 'Phobject', + 'ArcanistGridColumn' => 'Phobject', + 'ArcanistGridRow' => 'Phobject', + 'ArcanistGridView' => 'Phobject', 'ArcanistHLintLinter' => 'ArcanistExternalLinter', 'ArcanistHLintLinterTestCase' => 'ArcanistExternalLinterTestCase', 'ArcanistHardpoint' => 'Phobject', 'ArcanistHardpointEngine' => 'Phobject', 'ArcanistHardpointFutureList' => 'Phobject', 'ArcanistHardpointList' => 'Phobject', 'ArcanistHardpointObject' => 'Phobject', 'ArcanistHardpointQuery' => 'Phobject', 'ArcanistHardpointRequest' => 'Phobject', 'ArcanistHardpointRequestList' => 'Phobject', 'ArcanistHardpointTask' => 'Phobject', 'ArcanistHardpointTaskResult' => 'Phobject', 'ArcanistHelpWorkflow' => 'ArcanistWorkflow', 'ArcanistHexadecimalNumericScalarCasingXHPASTLinterRule' => 'ArcanistXHPASTLinterRule', 'ArcanistHexadecimalNumericScalarCasingXHPASTLinterRuleTestCase' => 'ArcanistXHPASTLinterRuleTestCase', 'ArcanistHgClientChannel' => 'PhutilProtocolChannel', 'ArcanistHgProxyClient' => 'Phobject', 'ArcanistHgProxyServer' => 'Phobject', 'ArcanistHgServerChannel' => 'PhutilProtocolChannel', 'ArcanistImplicitConstructorXHPASTLinterRule' => 'ArcanistXHPASTLinterRule', 'ArcanistImplicitConstructorXHPASTLinterRuleTestCase' => 'ArcanistXHPASTLinterRuleTestCase', 'ArcanistImplicitFallthroughXHPASTLinterRule' => 'ArcanistXHPASTLinterRule', 'ArcanistImplicitFallthroughXHPASTLinterRuleTestCase' => 'ArcanistXHPASTLinterRuleTestCase', 'ArcanistImplicitVisibilityXHPASTLinterRule' => 'ArcanistXHPASTLinterRule', 'ArcanistImplicitVisibilityXHPASTLinterRuleTestCase' => 'ArcanistXHPASTLinterRuleTestCase', 'ArcanistImplodeArgumentOrderXHPASTLinterRule' => 'ArcanistXHPASTLinterRule', 'ArcanistImplodeArgumentOrderXHPASTLinterRuleTestCase' => 'ArcanistXHPASTLinterRuleTestCase', 'ArcanistInlineHTMLXHPASTLinterRule' => 'ArcanistXHPASTLinterRule', 'ArcanistInlineHTMLXHPASTLinterRuleTestCase' => 'ArcanistXHPASTLinterRuleTestCase', 'ArcanistInnerFunctionXHPASTLinterRule' => 'ArcanistXHPASTLinterRule', 'ArcanistInnerFunctionXHPASTLinterRuleTestCase' => 'ArcanistXHPASTLinterRuleTestCase', 'ArcanistInspectWorkflow' => 'ArcanistArcWorkflow', 'ArcanistInstallCertificateWorkflow' => 'ArcanistWorkflow', 'ArcanistInstanceOfOperatorXHPASTLinterRule' => 'ArcanistXHPASTLinterRule', 'ArcanistInstanceofOperatorXHPASTLinterRuleTestCase' => 'ArcanistXHPASTLinterRuleTestCase', 'ArcanistInterfaceAbstractMethodXHPASTLinterRule' => 'ArcanistXHPASTLinterRule', 'ArcanistInterfaceAbstractMethodXHPASTLinterRuleTestCase' => 'ArcanistXHPASTLinterRuleTestCase', 'ArcanistInterfaceMethodBodyXHPASTLinterRule' => 'ArcanistXHPASTLinterRule', 'ArcanistInterfaceMethodBodyXHPASTLinterRuleTestCase' => 'ArcanistXHPASTLinterRuleTestCase', 'ArcanistInvalidDefaultParameterXHPASTLinterRule' => 'ArcanistXHPASTLinterRule', 'ArcanistInvalidDefaultParameterXHPASTLinterRuleTestCase' => 'ArcanistXHPASTLinterRuleTestCase', 'ArcanistInvalidModifiersXHPASTLinterRule' => 'ArcanistXHPASTLinterRule', 'ArcanistInvalidModifiersXHPASTLinterRuleTestCase' => 'ArcanistXHPASTLinterRuleTestCase', 'ArcanistInvalidOctalNumericScalarXHPASTLinterRule' => 'ArcanistXHPASTLinterRule', 'ArcanistInvalidOctalNumericScalarXHPASTLinterRuleTestCase' => 'ArcanistXHPASTLinterRuleTestCase', 'ArcanistIsAShouldBeInstanceOfXHPASTLinterRule' => 'ArcanistXHPASTLinterRule', 'ArcanistIsAShouldBeInstanceOfXHPASTLinterRuleTestCase' => 'ArcanistXHPASTLinterRuleTestCase', 'ArcanistJSHintLinter' => 'ArcanistExternalLinter', 'ArcanistJSHintLinterTestCase' => 'ArcanistExternalLinterTestCase', 'ArcanistJSONLintLinter' => 'ArcanistExternalLinter', 'ArcanistJSONLintRenderer' => 'ArcanistLintRenderer', 'ArcanistJSONLinter' => 'ArcanistLinter', 'ArcanistJSONLinterTestCase' => 'ArcanistLinterTestCase', 'ArcanistJscsLinter' => 'ArcanistExternalLinter', 'ArcanistJscsLinterTestCase' => 'ArcanistExternalLinterTestCase', 'ArcanistKeywordCasingXHPASTLinterRule' => 'ArcanistXHPASTLinterRule', 'ArcanistKeywordCasingXHPASTLinterRuleTestCase' => 'ArcanistXHPASTLinterRuleTestCase', 'ArcanistLambdaFuncFunctionXHPASTLinterRule' => 'ArcanistXHPASTLinterRule', 'ArcanistLambdaFuncFunctionXHPASTLinterRuleTestCase' => 'ArcanistXHPASTLinterRuleTestCase', - 'ArcanistLandEngine' => 'Phobject', - 'ArcanistLandWorkflow' => 'ArcanistWorkflow', + 'ArcanistLandCommit' => 'Phobject', + 'ArcanistLandCommitSet' => 'Phobject', + 'ArcanistLandEngine' => 'ArcanistWorkflowEngine', + 'ArcanistLandSymbol' => 'Phobject', + 'ArcanistLandTarget' => 'Phobject', + 'ArcanistLandWorkflow' => 'ArcanistArcWorkflow', 'ArcanistLanguageConstructParenthesesXHPASTLinterRule' => 'ArcanistXHPASTLinterRule', 'ArcanistLanguageConstructParenthesesXHPASTLinterRuleTestCase' => 'ArcanistXHPASTLinterRuleTestCase', 'ArcanistLesscLinter' => 'ArcanistExternalLinter', 'ArcanistLesscLinterTestCase' => 'ArcanistExternalLinterTestCase', 'ArcanistLiberateWorkflow' => 'ArcanistArcWorkflow', 'ArcanistLintEngine' => 'Phobject', 'ArcanistLintMessage' => 'Phobject', 'ArcanistLintMessageTestCase' => 'PhutilTestCase', 'ArcanistLintPatcher' => 'Phobject', 'ArcanistLintRenderer' => 'Phobject', 'ArcanistLintResult' => 'Phobject', 'ArcanistLintSeverity' => 'Phobject', 'ArcanistLintWorkflow' => 'ArcanistWorkflow', 'ArcanistLinter' => 'Phobject', 'ArcanistLinterStandard' => 'Phobject', 'ArcanistLinterStandardTestCase' => 'PhutilTestCase', 'ArcanistLinterTestCase' => 'PhutilTestCase', 'ArcanistLintersWorkflow' => 'ArcanistWorkflow', 'ArcanistListAssignmentXHPASTLinterRule' => 'ArcanistXHPASTLinterRule', 'ArcanistListAssignmentXHPASTLinterRuleTestCase' => 'ArcanistXHPASTLinterRuleTestCase', - 'ArcanistListConfigOption' => 'ArcanistConfigOption', + 'ArcanistListConfigOption' => 'ArcanistSingleSourceConfigOption', 'ArcanistListWorkflow' => 'ArcanistWorkflow', 'ArcanistLocalConfigurationSource' => 'ArcanistWorkingCopyConfigurationSource', 'ArcanistLogEngine' => 'Phobject', 'ArcanistLogMessage' => 'Phobject', 'ArcanistLogicalOperatorsXHPASTLinterRule' => 'ArcanistXHPASTLinterRule', 'ArcanistLogicalOperatorsXHPASTLinterRuleTestCase' => 'ArcanistXHPASTLinterRuleTestCase', + 'ArcanistLookWorkflow' => 'ArcanistArcWorkflow', 'ArcanistLowercaseFunctionsXHPASTLinterRule' => 'ArcanistXHPASTLinterRule', 'ArcanistLowercaseFunctionsXHPASTLinterRuleTestCase' => 'ArcanistXHPASTLinterRuleTestCase', + 'ArcanistMarkerRef' => 'ArcanistRef', + 'ArcanistMarkersWorkflow' => 'ArcanistArcWorkflow', 'ArcanistMercurialAPI' => 'ArcanistRepositoryAPI', + 'ArcanistMercurialCommitGraphQuery' => 'ArcanistCommitGraphQuery', + 'ArcanistMercurialCommitMessageHardpointQuery' => 'ArcanistWorkflowMercurialHardpointQuery', + 'ArcanistMercurialLandEngine' => 'ArcanistLandEngine', + 'ArcanistMercurialLocalState' => 'ArcanistRepositoryLocalState', 'ArcanistMercurialParser' => 'Phobject', 'ArcanistMercurialParserTestCase' => 'PhutilTestCase', + 'ArcanistMercurialRepositoryMarkerQuery' => 'ArcanistRepositoryMarkerQuery', + 'ArcanistMercurialRepositoryRemoteQuery' => 'ArcanistRepositoryRemoteQuery', + 'ArcanistMercurialWorkEngine' => 'ArcanistWorkEngine', 'ArcanistMercurialWorkingCopy' => 'ArcanistWorkingCopy', + 'ArcanistMercurialWorkingCopyRevisionHardpointQuery' => 'ArcanistWorkflowMercurialHardpointQuery', 'ArcanistMergeConflictLinter' => 'ArcanistLinter', 'ArcanistMergeConflictLinterTestCase' => 'ArcanistLinterTestCase', 'ArcanistMessageRevisionHardpointQuery' => 'ArcanistRuntimeHardpointQuery', 'ArcanistMissingArgumentTerminatorException' => 'Exception', 'ArcanistMissingLinterException' => 'Exception', 'ArcanistModifierOrderingXHPASTLinterRule' => 'ArcanistXHPASTLinterRule', 'ArcanistModifierOrderingXHPASTLinterRuleTestCase' => 'ArcanistXHPASTLinterRuleTestCase', + 'ArcanistMultiSourceConfigOption' => 'ArcanistConfigOption', 'ArcanistNamespaceFirstStatementXHPASTLinterRule' => 'ArcanistXHPASTLinterRule', 'ArcanistNamespaceFirstStatementXHPASTLinterRuleTestCase' => 'ArcanistXHPASTLinterRuleTestCase', 'ArcanistNamingConventionsXHPASTLinterRule' => 'ArcanistXHPASTLinterRule', 'ArcanistNamingConventionsXHPASTLinterRuleTestCase' => 'ArcanistXHPASTLinterRuleTestCase', 'ArcanistNestedNamespacesXHPASTLinterRule' => 'ArcanistXHPASTLinterRule', 'ArcanistNestedNamespacesXHPASTLinterRuleTestCase' => 'ArcanistXHPASTLinterRuleTestCase', 'ArcanistNewlineAfterOpenTagXHPASTLinterRule' => 'ArcanistXHPASTLinterRule', 'ArcanistNewlineAfterOpenTagXHPASTLinterRuleTestCase' => 'ArcanistXHPASTLinterRuleTestCase', 'ArcanistNoEffectException' => 'ArcanistUsageException', 'ArcanistNoEngineException' => 'ArcanistUsageException', 'ArcanistNoLintLinter' => 'ArcanistLinter', 'ArcanistNoLintLinterTestCase' => 'ArcanistLinterTestCase', 'ArcanistNoParentScopeXHPASTLinterRule' => 'ArcanistXHPASTLinterRule', 'ArcanistNoParentScopeXHPASTLinterRuleTestCase' => 'ArcanistXHPASTLinterRuleTestCase', 'ArcanistNoURIConduitException' => 'ArcanistConduitException', 'ArcanistNoneLintRenderer' => 'ArcanistLintRenderer', 'ArcanistObjectListHardpoint' => 'ArcanistHardpoint', 'ArcanistObjectOperatorSpacingXHPASTLinterRule' => 'ArcanistXHPASTLinterRule', 'ArcanistObjectOperatorSpacingXHPASTLinterRuleTestCase' => 'ArcanistXHPASTLinterRuleTestCase', 'ArcanistPEP8Linter' => 'ArcanistExternalLinter', 'ArcanistPEP8LinterTestCase' => 'ArcanistExternalLinterTestCase', 'ArcanistPHPCloseTagXHPASTLinterRule' => 'ArcanistXHPASTLinterRule', 'ArcanistPHPCloseTagXHPASTLinterRuleTestCase' => 'ArcanistXHPASTLinterRuleTestCase', 'ArcanistPHPCompatibilityXHPASTLinterRule' => 'ArcanistXHPASTLinterRule', 'ArcanistPHPCompatibilityXHPASTLinterRuleTestCase' => 'ArcanistXHPASTLinterRuleTestCase', 'ArcanistPHPEchoTagXHPASTLinterRule' => 'ArcanistXHPASTLinterRule', 'ArcanistPHPEchoTagXHPASTLinterRuleTestCase' => 'ArcanistXHPASTLinterRuleTestCase', 'ArcanistPHPOpenTagXHPASTLinterRule' => 'ArcanistXHPASTLinterRule', 'ArcanistPHPOpenTagXHPASTLinterRuleTestCase' => 'ArcanistXHPASTLinterRuleTestCase', 'ArcanistPHPShortTagXHPASTLinterRule' => 'ArcanistXHPASTLinterRule', 'ArcanistPHPShortTagXHPASTLinterRuleTestCase' => 'ArcanistXHPASTLinterRuleTestCase', 'ArcanistPaamayimNekudotayimSpacingXHPASTLinterRule' => 'ArcanistXHPASTLinterRule', 'ArcanistPaamayimNekudotayimSpacingXHPASTLinterRuleTestCase' => 'ArcanistXHPASTLinterRuleTestCase', 'ArcanistParentMemberReferenceXHPASTLinterRule' => 'ArcanistXHPASTLinterRule', 'ArcanistParentMemberReferenceXHPASTLinterRuleTestCase' => 'ArcanistXHPASTLinterRuleTestCase', 'ArcanistParenthesesSpacingXHPASTLinterRule' => 'ArcanistXHPASTLinterRule', 'ArcanistParenthesesSpacingXHPASTLinterRuleTestCase' => 'ArcanistXHPASTLinterRuleTestCase', 'ArcanistParseStrUseXHPASTLinterRule' => 'ArcanistXHPASTLinterRule', 'ArcanistParseStrUseXHPASTLinterRuleTestCase' => 'ArcanistXHPASTLinterRuleTestCase', - 'ArcanistPasteRef' => array( - 'ArcanistRef', - 'ArcanistDisplayRefInterface', - ), + 'ArcanistPasteRef' => 'ArcanistRef', 'ArcanistPasteSymbolRef' => 'ArcanistSimpleSymbolRef', 'ArcanistPasteWorkflow' => 'ArcanistArcWorkflow', 'ArcanistPatchWorkflow' => 'ArcanistWorkflow', 'ArcanistPhpLinter' => 'ArcanistExternalLinter', 'ArcanistPhpLinterTestCase' => 'ArcanistExternalLinterTestCase', 'ArcanistPhpcsLinter' => 'ArcanistExternalLinter', 'ArcanistPhpcsLinterTestCase' => 'ArcanistExternalLinterTestCase', 'ArcanistPhpunitTestResultParser' => 'ArcanistTestResultParser', 'ArcanistPhutilLibraryLinter' => 'ArcanistLinter', 'ArcanistPhutilWorkflow' => 'PhutilArgumentWorkflow', 'ArcanistPhutilXHPASTLinterStandard' => 'ArcanistLinterStandard', 'ArcanistPlusOperatorOnStringsXHPASTLinterRule' => 'ArcanistXHPASTLinterRule', 'ArcanistPlusOperatorOnStringsXHPASTLinterRuleTestCase' => 'ArcanistXHPASTLinterRuleTestCase', 'ArcanistProjectConfigurationSource' => 'ArcanistWorkingCopyConfigurationSource', 'ArcanistPrompt' => 'Phobject', + 'ArcanistPromptResponse' => 'Phobject', + 'ArcanistPromptsConfigOption' => 'ArcanistMultiSourceConfigOption', 'ArcanistPromptsWorkflow' => 'ArcanistWorkflow', 'ArcanistPublicPropertyXHPASTLinterRule' => 'ArcanistXHPASTLinterRule', 'ArcanistPublicPropertyXHPASTLinterRuleTestCase' => 'ArcanistXHPASTLinterRuleTestCase', 'ArcanistPuppetLintLinter' => 'ArcanistExternalLinter', 'ArcanistPuppetLintLinterTestCase' => 'ArcanistExternalLinterTestCase', 'ArcanistPyFlakesLinter' => 'ArcanistExternalLinter', 'ArcanistPyFlakesLinterTestCase' => 'ArcanistExternalLinterTestCase', 'ArcanistPyLintLinter' => 'ArcanistExternalLinter', 'ArcanistPyLintLinterTestCase' => 'ArcanistExternalLinterTestCase', 'ArcanistRaggedClassTreeEdgeXHPASTLinterRule' => 'ArcanistXHPASTLinterRule', 'ArcanistRaggedClassTreeEdgeXHPASTLinterRuleTestCase' => 'ArcanistXHPASTLinterRuleTestCase', 'ArcanistRef' => 'ArcanistHardpointObject', 'ArcanistRefInspector' => 'Phobject', + 'ArcanistRefView' => array( + 'Phobject', + 'ArcanistTerminalStringInterface', + ), + 'ArcanistRemoteRef' => 'ArcanistRef', + 'ArcanistRemoteRefInspector' => 'ArcanistRefInspector', + 'ArcanistRemoteRepositoryRefsHardpointQuery' => 'ArcanistRuntimeHardpointQuery', 'ArcanistRepositoryAPI' => 'Phobject', 'ArcanistRepositoryAPIMiscTestCase' => 'PhutilTestCase', 'ArcanistRepositoryAPIStateTestCase' => 'PhutilTestCase', + 'ArcanistRepositoryLocalState' => 'Phobject', + 'ArcanistRepositoryMarkerQuery' => 'ArcanistRepositoryQuery', + 'ArcanistRepositoryQuery' => 'Phobject', 'ArcanistRepositoryRef' => 'ArcanistRef', + 'ArcanistRepositoryRemoteQuery' => 'ArcanistRepositoryQuery', + 'ArcanistRepositoryURINormalizer' => 'Phobject', + 'ArcanistRepositoryURINormalizerTestCase' => 'PhutilTestCase', 'ArcanistReusedAsIteratorXHPASTLinterRule' => 'ArcanistXHPASTLinterRule', 'ArcanistReusedAsIteratorXHPASTLinterRuleTestCase' => 'ArcanistXHPASTLinterRuleTestCase', 'ArcanistReusedIteratorReferenceXHPASTLinterRule' => 'ArcanistXHPASTLinterRule', 'ArcanistReusedIteratorReferenceXHPASTLinterRuleTestCase' => 'ArcanistXHPASTLinterRuleTestCase', 'ArcanistReusedIteratorXHPASTLinterRule' => 'ArcanistXHPASTLinterRule', 'ArcanistReusedIteratorXHPASTLinterRuleTestCase' => 'ArcanistXHPASTLinterRuleTestCase', + 'ArcanistRevisionAuthorHardpointQuery' => 'ArcanistRuntimeHardpointQuery', + 'ArcanistRevisionBuildableHardpointQuery' => 'ArcanistRuntimeHardpointQuery', 'ArcanistRevisionCommitMessageHardpointQuery' => 'ArcanistRuntimeHardpointQuery', - 'ArcanistRevisionRef' => array( - 'ArcanistRef', - 'ArcanistDisplayRefInterface', - ), + 'ArcanistRevisionParentRevisionsHardpointQuery' => 'ArcanistRuntimeHardpointQuery', + 'ArcanistRevisionRef' => 'ArcanistRef', 'ArcanistRevisionRefSource' => 'Phobject', 'ArcanistRevisionSymbolRef' => 'ArcanistSimpleSymbolRef', 'ArcanistRuboCopLinter' => 'ArcanistExternalLinter', 'ArcanistRuboCopLinterTestCase' => 'ArcanistExternalLinterTestCase', 'ArcanistRubyLinter' => 'ArcanistExternalLinter', 'ArcanistRubyLinterTestCase' => 'ArcanistExternalLinterTestCase', 'ArcanistRuntimeConfigurationSource' => 'ArcanistDictionaryConfigurationSource', 'ArcanistRuntimeHardpointQuery' => 'ArcanistHardpointQuery', - 'ArcanistScalarConfigOption' => 'ArcanistConfigOption', 'ArcanistScalarHardpoint' => 'ArcanistHardpoint', 'ArcanistScriptAndRegexLinter' => 'ArcanistLinter', 'ArcanistSelfClassReferenceXHPASTLinterRule' => 'ArcanistXHPASTLinterRule', 'ArcanistSelfClassReferenceXHPASTLinterRuleTestCase' => 'ArcanistXHPASTLinterRuleTestCase', 'ArcanistSelfMemberReferenceXHPASTLinterRule' => 'ArcanistXHPASTLinterRule', 'ArcanistSelfMemberReferenceXHPASTLinterRuleTestCase' => 'ArcanistXHPASTLinterRuleTestCase', 'ArcanistSemicolonSpacingXHPASTLinterRule' => 'ArcanistXHPASTLinterRule', 'ArcanistSemicolonSpacingXHPASTLinterRuleTestCase' => 'ArcanistXHPASTLinterRuleTestCase', 'ArcanistSetConfigWorkflow' => 'ArcanistWorkflow', 'ArcanistSetting' => 'Phobject', 'ArcanistSettings' => 'Phobject', 'ArcanistShellCompleteWorkflow' => 'ArcanistWorkflow', + 'ArcanistSimpleCommitGraphQuery' => 'ArcanistCommitGraphQuery', 'ArcanistSimpleSymbolHardpointQuery' => 'ArcanistRuntimeHardpointQuery', 'ArcanistSimpleSymbolRef' => 'ArcanistSymbolRef', 'ArcanistSimpleSymbolRefInspector' => 'ArcanistRefInspector', 'ArcanistSingleLintEngine' => 'ArcanistLintEngine', + 'ArcanistSingleSourceConfigOption' => 'ArcanistConfigOption', 'ArcanistSlownessXHPASTLinterRule' => 'ArcanistXHPASTLinterRule', 'ArcanistSlownessXHPASTLinterRuleTestCase' => 'ArcanistXHPASTLinterRuleTestCase', 'ArcanistSpellingLinter' => 'ArcanistLinter', 'ArcanistSpellingLinterTestCase' => 'ArcanistLinterTestCase', 'ArcanistStaticThisXHPASTLinterRule' => 'ArcanistXHPASTLinterRule', 'ArcanistStaticThisXHPASTLinterRuleTestCase' => 'ArcanistXHPASTLinterRuleTestCase', - 'ArcanistStringConfigOption' => 'ArcanistScalarConfigOption', + 'ArcanistStringConfigOption' => 'ArcanistSingleSourceConfigOption', + 'ArcanistStringListConfigOption' => 'ArcanistListConfigOption', 'ArcanistSubversionAPI' => 'ArcanistRepositoryAPI', 'ArcanistSubversionWorkingCopy' => 'ArcanistWorkingCopy', 'ArcanistSummaryLintRenderer' => 'ArcanistLintRenderer', 'ArcanistSymbolEngine' => 'Phobject', 'ArcanistSymbolRef' => 'ArcanistRef', 'ArcanistSyntaxErrorXHPASTLinterRule' => 'ArcanistXHPASTLinterRule', 'ArcanistSystemConfigurationSource' => 'ArcanistFilesystemConfigurationSource', - 'ArcanistTaskRef' => array( - 'ArcanistRef', - 'ArcanistDisplayRefInterface', - ), + 'ArcanistTaskRef' => 'ArcanistRef', 'ArcanistTaskSymbolRef' => 'ArcanistSimpleSymbolRef', 'ArcanistTasksWorkflow' => 'ArcanistWorkflow', 'ArcanistTautologicalExpressionXHPASTLinterRule' => 'ArcanistXHPASTLinterRule', 'ArcanistTautologicalExpressionXHPASTLinterRuleTestCase' => 'ArcanistXHPASTLinterRuleTestCase', 'ArcanistTestResultParser' => 'Phobject', 'ArcanistTestXHPASTLintSwitchHook' => 'ArcanistXHPASTLintSwitchHook', 'ArcanistTextLinter' => 'ArcanistLinter', 'ArcanistTextLinterTestCase' => 'ArcanistLinterTestCase', 'ArcanistThisReassignmentXHPASTLinterRule' => 'ArcanistXHPASTLinterRule', 'ArcanistThisReassignmentXHPASTLinterRuleTestCase' => 'ArcanistXHPASTLinterRuleTestCase', 'ArcanistToStringExceptionXHPASTLinterRule' => 'ArcanistXHPASTLinterRule', 'ArcanistToStringExceptionXHPASTLinterRuleTestCase' => 'ArcanistXHPASTLinterRuleTestCase', 'ArcanistTodoCommentXHPASTLinterRule' => 'ArcanistXHPASTLinterRule', 'ArcanistTodoCommentXHPASTLinterRuleTestCase' => 'ArcanistXHPASTLinterRuleTestCase', 'ArcanistTodoWorkflow' => 'ArcanistWorkflow', 'ArcanistToolset' => 'Phobject', 'ArcanistUSEnglishTranslation' => 'PhutilTranslation', 'ArcanistUnableToParseXHPASTLinterRule' => 'ArcanistXHPASTLinterRule', 'ArcanistUnaryPostfixExpressionSpacingXHPASTLinterRule' => 'ArcanistXHPASTLinterRule', 'ArcanistUnaryPostfixExpressionSpacingXHPASTLinterRuleTestCase' => 'ArcanistXHPASTLinterRuleTestCase', 'ArcanistUnaryPrefixExpressionSpacingXHPASTLinterRule' => 'ArcanistXHPASTLinterRule', 'ArcanistUnaryPrefixExpressionSpacingXHPASTLinterRuleTestCase' => 'ArcanistXHPASTLinterRuleTestCase', 'ArcanistUndeclaredVariableXHPASTLinterRule' => 'ArcanistXHPASTLinterRule', 'ArcanistUndeclaredVariableXHPASTLinterRuleTestCase' => 'ArcanistXHPASTLinterRuleTestCase', 'ArcanistUnexpectedReturnValueXHPASTLinterRule' => 'ArcanistXHPASTLinterRule', 'ArcanistUnexpectedReturnValueXHPASTLinterRuleTestCase' => 'ArcanistXHPASTLinterRuleTestCase', 'ArcanistUnitConsoleRenderer' => 'ArcanistUnitRenderer', 'ArcanistUnitRenderer' => 'Phobject', 'ArcanistUnitTestEngine' => 'Phobject', 'ArcanistUnitTestResult' => 'Phobject', 'ArcanistUnitTestResultTestCase' => 'PhutilTestCase', 'ArcanistUnitTestableLintEngine' => 'ArcanistLintEngine', 'ArcanistUnitWorkflow' => 'ArcanistWorkflow', 'ArcanistUnnecessaryFinalModifierXHPASTLinterRule' => 'ArcanistXHPASTLinterRule', 'ArcanistUnnecessaryFinalModifierXHPASTLinterRuleTestCase' => 'ArcanistXHPASTLinterRuleTestCase', 'ArcanistUnnecessarySemicolonXHPASTLinterRule' => 'ArcanistXHPASTLinterRule', 'ArcanistUnnecessarySymbolAliasXHPASTLinterRule' => 'ArcanistXHPASTLinterRule', 'ArcanistUnnecessarySymbolAliasXHPASTLinterRuleTestCase' => 'ArcanistXHPASTLinterRuleTestCase', 'ArcanistUnsafeDynamicStringXHPASTLinterRule' => 'ArcanistXHPASTLinterRule', 'ArcanistUnsafeDynamicStringXHPASTLinterRuleTestCase' => 'ArcanistXHPASTLinterRuleTestCase', 'ArcanistUpgradeWorkflow' => 'ArcanistArcWorkflow', 'ArcanistUploadWorkflow' => 'ArcanistArcWorkflow', 'ArcanistUsageException' => 'Exception', 'ArcanistUseStatementNamespacePrefixXHPASTLinterRule' => 'ArcanistXHPASTLinterRule', 'ArcanistUseStatementNamespacePrefixXHPASTLinterRuleTestCase' => 'ArcanistXHPASTLinterRuleTestCase', 'ArcanistUselessOverridingMethodXHPASTLinterRule' => 'ArcanistXHPASTLinterRule', 'ArcanistUselessOverridingMethodXHPASTLinterRuleTestCase' => 'ArcanistXHPASTLinterRuleTestCase', 'ArcanistUserAbortException' => 'ArcanistUsageException', 'ArcanistUserConfigurationSource' => 'ArcanistFilesystemConfigurationSource', - 'ArcanistUserRef' => array( - 'ArcanistRef', - 'ArcanistDisplayRefInterface', - ), + 'ArcanistUserRef' => 'ArcanistRef', 'ArcanistUserSymbolHardpointQuery' => 'ArcanistRuntimeHardpointQuery', 'ArcanistUserSymbolRef' => 'ArcanistSymbolRef', 'ArcanistUserSymbolRefInspector' => 'ArcanistRefInspector', 'ArcanistVariableReferenceSpacingXHPASTLinterRule' => 'ArcanistXHPASTLinterRule', 'ArcanistVariableReferenceSpacingXHPASTLinterRuleTestCase' => 'ArcanistXHPASTLinterRuleTestCase', 'ArcanistVariableVariableXHPASTLinterRule' => 'ArcanistXHPASTLinterRule', 'ArcanistVariableVariableXHPASTLinterRuleTestCase' => 'ArcanistXHPASTLinterRuleTestCase', 'ArcanistVectorHardpoint' => 'ArcanistHardpoint', 'ArcanistVersionWorkflow' => 'ArcanistWorkflow', 'ArcanistWeldWorkflow' => 'ArcanistArcWorkflow', 'ArcanistWhichWorkflow' => 'ArcanistWorkflow', 'ArcanistWildConfigOption' => 'ArcanistConfigOption', + 'ArcanistWorkEngine' => 'ArcanistWorkflowEngine', + 'ArcanistWorkWorkflow' => 'ArcanistArcWorkflow', 'ArcanistWorkflow' => 'Phobject', 'ArcanistWorkflowArgument' => 'Phobject', + 'ArcanistWorkflowEngine' => 'Phobject', 'ArcanistWorkflowGitHardpointQuery' => 'ArcanistRuntimeHardpointQuery', 'ArcanistWorkflowInformation' => 'Phobject', + 'ArcanistWorkflowMercurialHardpointQuery' => 'ArcanistRuntimeHardpointQuery', 'ArcanistWorkingCopy' => 'Phobject', - 'ArcanistWorkingCopyCommitHardpointQuery' => 'ArcanistRuntimeHardpointQuery', 'ArcanistWorkingCopyConfigurationSource' => 'ArcanistFilesystemConfigurationSource', 'ArcanistWorkingCopyIdentity' => 'Phobject', 'ArcanistWorkingCopyPath' => 'Phobject', 'ArcanistWorkingCopyStateRef' => 'ArcanistRef', 'ArcanistWorkingCopyStateRefInspector' => 'ArcanistRefInspector', 'ArcanistXHPASTLintNamingHook' => 'Phobject', 'ArcanistXHPASTLintNamingHookTestCase' => 'PhutilTestCase', 'ArcanistXHPASTLintSwitchHook' => 'Phobject', 'ArcanistXHPASTLinter' => 'ArcanistBaseXHPASTLinter', 'ArcanistXHPASTLinterRule' => 'Phobject', 'ArcanistXHPASTLinterRuleTestCase' => 'ArcanistLinterTestCase', 'ArcanistXHPASTLinterTestCase' => 'ArcanistLinterTestCase', 'ArcanistXMLLinter' => 'ArcanistLinter', 'ArcanistXMLLinterTestCase' => 'ArcanistLinterTestCase', 'ArcanistXUnitTestResultParser' => 'Phobject', 'BaseHTTPFuture' => 'Future', 'CSharpToolsTestEngine' => 'XUnitTestEngine', 'CaseInsensitiveArray' => 'PhutilArray', 'CaseInsensitiveArrayTestCase' => 'PhutilTestCase', 'CommandException' => 'Exception', 'ConduitClient' => 'Phobject', 'ConduitClientException' => 'Exception', 'ConduitClientTestCase' => 'PhutilTestCase', 'ConduitFuture' => 'FutureProxy', 'ConduitSearchFuture' => 'FutureAgent', 'ExecFuture' => 'PhutilExecutableFuture', 'ExecFutureTestCase' => 'PhutilTestCase', 'ExecPassthruTestCase' => 'PhutilTestCase', 'FileFinder' => 'Phobject', 'FileFinderTestCase' => 'PhutilTestCase', 'FileList' => 'Phobject', 'Filesystem' => 'Phobject', 'FilesystemException' => 'Exception', 'FilesystemTestCase' => 'PhutilTestCase', 'Future' => 'Phobject', 'FutureAgent' => 'Future', 'FutureIterator' => array( 'Phobject', 'Iterator', ), 'FutureIteratorTestCase' => 'PhutilTestCase', 'FuturePool' => 'Phobject', 'FutureProxy' => 'Future', 'HTTPFuture' => 'BaseHTTPFuture', 'HTTPFutureCURLResponseStatus' => 'HTTPFutureResponseStatus', 'HTTPFutureCertificateResponseStatus' => 'HTTPFutureResponseStatus', 'HTTPFutureHTTPResponseStatus' => 'HTTPFutureResponseStatus', 'HTTPFutureParseResponseStatus' => 'HTTPFutureResponseStatus', 'HTTPFutureResponseStatus' => 'Exception', 'HTTPFutureTransportResponseStatus' => 'HTTPFutureResponseStatus', 'HTTPSFuture' => 'BaseHTTPFuture', 'ImmediateFuture' => 'Future', 'LibphutilUSEnglishTranslation' => 'PhutilTranslation', 'LinesOfALarge' => array( 'Phobject', 'Iterator', ), 'LinesOfALargeExecFuture' => 'LinesOfALarge', 'LinesOfALargeExecFutureTestCase' => 'PhutilTestCase', 'LinesOfALargeFile' => 'LinesOfALarge', 'LinesOfALargeFileTestCase' => 'PhutilTestCase', 'MFilterTestHelper' => 'Phobject', 'NoseTestEngine' => 'ArcanistUnitTestEngine', 'PHPASTParserTestCase' => 'PhutilTestCase', 'PhageAction' => 'Phobject', 'PhageAgentAction' => 'PhageAction', 'PhageAgentBootloader' => 'Phobject', 'PhageAgentTestCase' => 'PhutilTestCase', 'PhageExecWorkflow' => 'PhageWorkflow', 'PhageExecuteAction' => 'PhageAction', 'PhageLocalAction' => 'PhageAgentAction', 'PhagePHPAgent' => 'Phobject', 'PhagePHPAgentBootloader' => 'PhageAgentBootloader', 'PhagePlanAction' => 'PhageAction', 'PhageToolset' => 'ArcanistToolset', 'PhageWorkflow' => 'ArcanistWorkflow', 'Phobject' => 'Iterator', 'PhobjectTestCase' => 'PhutilTestCase', 'PhpunitTestEngine' => 'ArcanistUnitTestEngine', 'PhpunitTestEngineTestCase' => 'PhutilTestCase', 'PhutilAWSCloudFormationFuture' => 'PhutilAWSFuture', 'PhutilAWSCloudWatchFuture' => 'PhutilAWSFuture', 'PhutilAWSEC2Future' => 'PhutilAWSFuture', 'PhutilAWSException' => 'Exception', 'PhutilAWSFuture' => 'FutureProxy', 'PhutilAWSManagementWorkflow' => 'PhutilArgumentWorkflow', 'PhutilAWSS3DeleteManagementWorkflow' => 'PhutilAWSS3ManagementWorkflow', 'PhutilAWSS3Future' => 'PhutilAWSFuture', 'PhutilAWSS3GetManagementWorkflow' => 'PhutilAWSS3ManagementWorkflow', 'PhutilAWSS3ManagementWorkflow' => 'PhutilAWSManagementWorkflow', 'PhutilAWSS3PutManagementWorkflow' => 'PhutilAWSS3ManagementWorkflow', 'PhutilAWSv4Signature' => 'Phobject', 'PhutilAWSv4SignatureTestCase' => 'PhutilTestCase', 'PhutilAggregateException' => 'Exception', 'PhutilAllCapsEnglishLocale' => 'PhutilLocale', 'PhutilArgumentParser' => 'Phobject', 'PhutilArgumentParserException' => 'Exception', 'PhutilArgumentParserTestCase' => 'PhutilTestCase', 'PhutilArgumentSpecification' => 'Phobject', 'PhutilArgumentSpecificationException' => 'PhutilArgumentParserException', 'PhutilArgumentSpecificationTestCase' => 'PhutilTestCase', 'PhutilArgumentSpellingCorrector' => 'Phobject', 'PhutilArgumentSpellingCorrectorTestCase' => 'PhutilTestCase', 'PhutilArgumentUsageException' => 'PhutilArgumentParserException', 'PhutilArgumentWorkflow' => 'Phobject', 'PhutilArray' => array( 'Phobject', 'Countable', 'ArrayAccess', 'Iterator', ), 'PhutilArrayCheck' => 'Phobject', 'PhutilArrayTestCase' => 'PhutilTestCase', 'PhutilArrayWithDefaultValue' => 'PhutilArray', 'PhutilAsanaFuture' => 'FutureProxy', 'PhutilBacktraceSignalHandler' => 'PhutilSignalHandler', 'PhutilBallOfPHP' => 'Phobject', 'PhutilBinaryAnalyzer' => 'Phobject', 'PhutilBinaryAnalyzerTestCase' => 'PhutilTestCase', 'PhutilBootloaderException' => 'Exception', 'PhutilBritishEnglishLocale' => 'PhutilLocale', 'PhutilBufferedIterator' => array( 'Phobject', 'Iterator', ), 'PhutilBufferedIteratorTestCase' => 'PhutilTestCase', 'PhutilBugtraqParser' => 'Phobject', 'PhutilBugtraqParserTestCase' => 'PhutilTestCase', 'PhutilCIDRBlock' => 'Phobject', 'PhutilCIDRList' => 'Phobject', 'PhutilCallbackFilterIterator' => 'FilterIterator', 'PhutilCallbackSignalHandler' => 'PhutilSignalHandler', 'PhutilChannel' => 'Phobject', 'PhutilChannelChannel' => 'PhutilChannel', 'PhutilChannelTestCase' => 'PhutilTestCase', 'PhutilChunkedIterator' => array( 'Phobject', 'Iterator', ), 'PhutilChunkedIteratorTestCase' => 'PhutilTestCase', 'PhutilClassMapQuery' => 'Phobject', 'PhutilCloudWatchMetric' => 'Phobject', 'PhutilCommandString' => 'Phobject', 'PhutilConsole' => 'Phobject', 'PhutilConsoleBlock' => 'PhutilConsoleView', 'PhutilConsoleError' => 'PhutilConsoleLogLine', 'PhutilConsoleFormatter' => 'Phobject', 'PhutilConsoleInfo' => 'PhutilConsoleLogLine', 'PhutilConsoleList' => 'PhutilConsoleView', 'PhutilConsoleLogLine' => 'PhutilConsoleView', 'PhutilConsoleMessage' => 'Phobject', 'PhutilConsoleMetrics' => 'Phobject', 'PhutilConsoleMetricsSignalHandler' => 'PhutilSignalHandler', 'PhutilConsoleProgressBar' => 'Phobject', 'PhutilConsoleProgressSink' => 'PhutilProgressSink', 'PhutilConsoleServer' => 'Phobject', 'PhutilConsoleServerChannel' => 'PhutilChannelChannel', 'PhutilConsoleSkip' => 'PhutilConsoleLogLine', 'PhutilConsoleStdinNotInteractiveException' => 'Exception', 'PhutilConsoleTable' => 'PhutilConsoleView', 'PhutilConsoleView' => 'Phobject', 'PhutilConsoleWarning' => 'PhutilConsoleLogLine', 'PhutilConsoleWrapTestCase' => 'PhutilTestCase', 'PhutilCowsay' => 'Phobject', 'PhutilCowsayTestCase' => 'PhutilTestCase', 'PhutilCsprintfTestCase' => 'PhutilTestCase', 'PhutilCzechLocale' => 'PhutilLocale', 'PhutilDOMNode' => 'Phobject', 'PhutilDeferredLog' => 'Phobject', 'PhutilDeferredLogTestCase' => 'PhutilTestCase', 'PhutilDiffBinaryAnalyzer' => 'PhutilBinaryAnalyzer', 'PhutilDirectedScalarGraph' => 'AbstractDirectedGraph', 'PhutilDirectoryFixture' => 'Phobject', 'PhutilDocblockParser' => 'Phobject', 'PhutilDocblockParserTestCase' => 'PhutilTestCase', 'PhutilEditDistanceMatrix' => 'Phobject', 'PhutilEditDistanceMatrixTestCase' => 'PhutilTestCase', 'PhutilEditorConfig' => 'Phobject', 'PhutilEditorConfigTestCase' => 'PhutilTestCase', 'PhutilEmailAddress' => 'Phobject', 'PhutilEmailAddressTestCase' => 'PhutilTestCase', 'PhutilEmojiLocale' => 'PhutilLocale', 'PhutilEnglishCanadaLocale' => 'PhutilLocale', 'PhutilErrorHandler' => 'Phobject', 'PhutilErrorHandlerTestCase' => 'PhutilTestCase', 'PhutilErrorTrap' => 'Phobject', 'PhutilEvent' => 'Phobject', 'PhutilEventConstants' => 'Phobject', 'PhutilEventEngine' => 'Phobject', 'PhutilEventListener' => 'Phobject', 'PhutilEventType' => 'PhutilEventConstants', 'PhutilExampleBufferedIterator' => 'PhutilBufferedIterator', 'PhutilExecChannel' => 'PhutilChannel', 'PhutilExecPassthru' => 'PhutilExecutableFuture', 'PhutilExecutableFuture' => 'Future', 'PhutilExecutionEnvironment' => 'Phobject', 'PhutilFileLock' => 'PhutilLock', 'PhutilFileLockTestCase' => 'PhutilTestCase', 'PhutilFrenchLocale' => 'PhutilLocale', 'PhutilGermanLocale' => 'PhutilLocale', 'PhutilGitBinaryAnalyzer' => 'PhutilBinaryAnalyzer', 'PhutilGitHubFuture' => 'FutureProxy', 'PhutilGitHubResponse' => 'Phobject', 'PhutilGitURI' => 'Phobject', 'PhutilGitURITestCase' => 'PhutilTestCase', 'PhutilHTMLParser' => 'Phobject', 'PhutilHTMLParserTestCase' => 'PhutilTestCase', 'PhutilHTTPEngineExtension' => 'Phobject', 'PhutilHTTPResponse' => 'Phobject', 'PhutilHTTPResponseParser' => 'Phobject', 'PhutilHTTPResponseParserTestCase' => 'PhutilTestCase', 'PhutilHashingIterator' => array( 'PhutilProxyIterator', 'Iterator', ), 'PhutilHashingIteratorTestCase' => 'PhutilTestCase', 'PhutilHelpArgumentWorkflow' => 'PhutilArgumentWorkflow', 'PhutilHgsprintfTestCase' => 'PhutilTestCase', 'PhutilINIParserException' => 'Exception', 'PhutilIPAddress' => 'Phobject', 'PhutilIPAddressTestCase' => 'PhutilTestCase', 'PhutilIPv4Address' => 'PhutilIPAddress', 'PhutilIPv6Address' => 'PhutilIPAddress', 'PhutilInteractiveEditor' => 'Phobject', 'PhutilInvalidRuleParserGeneratorException' => 'PhutilParserGeneratorException', 'PhutilInvalidStateException' => 'Exception', 'PhutilInvalidStateExceptionTestCase' => 'PhutilTestCase', 'PhutilIrreducibleRuleParserGeneratorException' => 'PhutilParserGeneratorException', 'PhutilJSON' => 'Phobject', 'PhutilJSONFragmentLexer' => 'PhutilLexer', 'PhutilJSONParser' => 'Phobject', 'PhutilJSONParserException' => 'Exception', 'PhutilJSONParserTestCase' => 'PhutilTestCase', 'PhutilJSONProtocolChannel' => 'PhutilProtocolChannel', 'PhutilJSONProtocolChannelTestCase' => 'PhutilTestCase', 'PhutilJSONTestCase' => 'PhutilTestCase', 'PhutilJavaFragmentLexer' => 'PhutilLexer', 'PhutilKoreanLocale' => 'PhutilLocale', 'PhutilLanguageGuesser' => 'Phobject', 'PhutilLanguageGuesserTestCase' => 'PhutilTestCase', 'PhutilLexer' => 'Phobject', 'PhutilLibraryConflictException' => 'Exception', 'PhutilLibraryMapBuilder' => 'Phobject', 'PhutilLibraryTestCase' => 'PhutilTestCase', 'PhutilLocale' => 'Phobject', 'PhutilLocaleTestCase' => 'PhutilTestCase', 'PhutilLock' => 'Phobject', 'PhutilLockException' => 'Exception', 'PhutilLogFileChannel' => 'PhutilChannelChannel', 'PhutilLunarPhase' => 'Phobject', 'PhutilLunarPhaseTestCase' => 'PhutilTestCase', 'PhutilMercurialBinaryAnalyzer' => 'PhutilBinaryAnalyzer', 'PhutilMethodNotImplementedException' => 'Exception', 'PhutilMetricsChannel' => 'PhutilChannelChannel', 'PhutilMissingSymbolException' => 'Exception', 'PhutilModuleUtilsTestCase' => 'PhutilTestCase', 'PhutilNumber' => 'Phobject', 'PhutilOAuth1Future' => 'FutureProxy', 'PhutilOAuth1FutureTestCase' => 'PhutilTestCase', 'PhutilOpaqueEnvelope' => 'Phobject', 'PhutilOpaqueEnvelopeKey' => 'Phobject', 'PhutilOpaqueEnvelopeTestCase' => 'PhutilTestCase', 'PhutilPHPFragmentLexer' => 'PhutilLexer', 'PhutilPHPFragmentLexerTestCase' => 'PhutilTestCase', 'PhutilPHPObjectProtocolChannel' => 'PhutilProtocolChannel', 'PhutilPHPObjectProtocolChannelTestCase' => 'PhutilTestCase', 'PhutilParserGenerator' => 'Phobject', 'PhutilParserGeneratorException' => 'Exception', 'PhutilParserGeneratorTestCase' => 'PhutilTestCase', 'PhutilPayPalAPIFuture' => 'FutureProxy', 'PhutilPersonTest' => array( 'Phobject', 'PhutilPerson', ), 'PhutilPhtTestCase' => 'PhutilTestCase', 'PhutilPirateEnglishLocale' => 'PhutilLocale', 'PhutilPortugueseBrazilLocale' => 'PhutilLocale', 'PhutilPortuguesePortugalLocale' => 'PhutilLocale', 'PhutilPostmarkFuture' => 'FutureProxy', 'PhutilPregsprintfTestCase' => 'PhutilTestCase', 'PhutilProcessQuery' => 'Phobject', 'PhutilProcessRef' => 'Phobject', 'PhutilProcessRefTestCase' => 'PhutilTestCase', 'PhutilProgressSink' => 'Phobject', 'PhutilProtocolChannel' => 'PhutilChannelChannel', 'PhutilProxyException' => 'Exception', 'PhutilProxyIterator' => array( 'Phobject', 'Iterator', ), 'PhutilPygmentizeBinaryAnalyzer' => 'PhutilBinaryAnalyzer', 'PhutilPythonFragmentLexer' => 'PhutilLexer', 'PhutilQueryStringParser' => 'Phobject', 'PhutilQueryStringParserTestCase' => 'PhutilTestCase', 'PhutilRawEnglishLocale' => 'PhutilLocale', 'PhutilReadableSerializer' => 'Phobject', 'PhutilReadableSerializerTestCase' => 'PhutilTestCase', 'PhutilRope' => 'Phobject', 'PhutilRopeTestCase' => 'PhutilTestCase', 'PhutilServiceProfiler' => 'Phobject', 'PhutilShellLexer' => 'PhutilLexer', 'PhutilShellLexerTestCase' => 'PhutilTestCase', 'PhutilSignalHandler' => 'Phobject', 'PhutilSignalRouter' => 'Phobject', 'PhutilSimpleOptions' => 'Phobject', 'PhutilSimpleOptionsLexer' => 'PhutilLexer', 'PhutilSimpleOptionsLexerTestCase' => 'PhutilTestCase', 'PhutilSimpleOptionsTestCase' => 'PhutilTestCase', 'PhutilSimplifiedChineseLocale' => 'PhutilLocale', 'PhutilSlackFuture' => 'FutureProxy', 'PhutilSocketChannel' => 'PhutilChannel', 'PhutilSortVector' => 'Phobject', 'PhutilSpanishSpainLocale' => 'PhutilLocale', 'PhutilStreamIterator' => array( 'Phobject', 'Iterator', ), 'PhutilSubversionBinaryAnalyzer' => 'PhutilBinaryAnalyzer', 'PhutilSystem' => 'Phobject', 'PhutilSystemTestCase' => 'PhutilTestCase', 'PhutilTerminalString' => 'Phobject', 'PhutilTestCase' => 'Phobject', 'PhutilTestCaseTestCase' => 'PhutilTestCase', 'PhutilTestPhobject' => 'Phobject', 'PhutilTestSkippedException' => 'Exception', 'PhutilTestTerminatedException' => 'Exception', 'PhutilTraditionalChineseLocale' => 'PhutilLocale', 'PhutilTranslation' => 'Phobject', 'PhutilTranslationTestCase' => 'PhutilTestCase', 'PhutilTranslator' => 'Phobject', 'PhutilTranslatorTestCase' => 'PhutilTestCase', 'PhutilTsprintfTestCase' => 'PhutilTestCase', 'PhutilTwitchFuture' => 'FutureProxy', 'PhutilTypeCheckException' => 'Exception', 'PhutilTypeExtraParametersException' => 'Exception', 'PhutilTypeLexer' => 'PhutilLexer', 'PhutilTypeMissingParametersException' => 'Exception', 'PhutilTypeSpec' => 'Phobject', 'PhutilTypeSpecTestCase' => 'PhutilTestCase', 'PhutilURI' => 'Phobject', 'PhutilURITestCase' => 'PhutilTestCase', 'PhutilUSEnglishLocale' => 'PhutilLocale', 'PhutilUTF8StringTruncator' => 'Phobject', 'PhutilUTF8TestCase' => 'PhutilTestCase', 'PhutilUnitTestEngine' => 'ArcanistUnitTestEngine', 'PhutilUnitTestEngineTestCase' => 'PhutilTestCase', 'PhutilUnknownSymbolParserGeneratorException' => 'PhutilParserGeneratorException', 'PhutilUnreachableRuleParserGeneratorException' => 'PhutilParserGeneratorException', 'PhutilUnreachableTerminalParserGeneratorException' => 'PhutilParserGeneratorException', 'PhutilUrisprintfTestCase' => 'PhutilTestCase', 'PhutilUtilsTestCase' => 'PhutilTestCase', 'PhutilVeryWowEnglishLocale' => 'PhutilLocale', 'PhutilWordPressFuture' => 'FutureProxy', 'PhutilXHPASTBinary' => 'Phobject', 'PytestTestEngine' => 'ArcanistUnitTestEngine', 'TempFile' => 'Phobject', 'TestAbstractDirectedGraph' => 'AbstractDirectedGraph', 'XHPASTNode' => 'AASTNode', 'XHPASTNodeTestCase' => 'PhutilTestCase', 'XHPASTSyntaxErrorException' => 'Exception', 'XHPASTToken' => 'AASTToken', 'XHPASTTree' => 'AASTTree', 'XHPASTTreeTestCase' => 'PhutilTestCase', 'XUnitTestEngine' => 'ArcanistUnitTestEngine', 'XUnitTestResultParserTestCase' => 'PhutilTestCase', 'XsprintfUnknownConversionException' => 'InvalidArgumentException', ), )); diff --git a/src/conduit/ArcanistConduitCall.php b/src/conduit/ArcanistConduitCall.php deleted file mode 100644 index bd25ff19..00000000 --- a/src/conduit/ArcanistConduitCall.php +++ /dev/null @@ -1,153 +0,0 @@ -key = $key; - return $this; - } - - public function getKey() { - return $this->key; - } - - public function setEngine(ArcanistConduitEngine $engine) { - $this->engine = $engine; - return $this; - } - - public function getEngine() { - return $this->engine; - } - - public function setMethod($method) { - $this->method = $method; - return $this; - } - - public function getMethod() { - return $this->method; - } - - public function setParameters(array $parameters) { - $this->parameters = $parameters; - return $this; - } - - public function getParameters() { - return $this->parameters; - } - - private function newFuture() { - if ($this->future) { - throw new Exception( - pht( - 'Call has previously generated a future. Create a '. - 'new call object for each API method invocation.')); - } - - $method = $this->getMethod(); - $parameters = $this->getParameters(); - $future = $this->getEngine()->newFuture($this); - $this->future = $future; - - return $this->future; - } - - public function resolve() { - if (!$this->future) { - $this->newFuture(); - } - - return $this->resolveFuture(); - } - - private function resolveFuture() { - $future = $this->future; - - try { - $result = $future->resolve(); - } catch (ConduitClientException $ex) { - switch ($ex->getErrorCode()) { - case 'ERR-INVALID-SESSION': - if (!$this->getEngine()->getConduitToken()) { - $this->raiseLoginRequired(); - } - break; - case 'ERR-INVALID-AUTH': - $this->raiseInvalidAuth(); - break; - } - - throw $ex; - } - - return $result; - } - - private function raiseLoginRequired() { - $conduit_uri = $this->getEngine()->getConduitURI(); - $conduit_uri = new PhutilURI($conduit_uri); - $conduit_uri->setPath('/'); - - $conduit_domain = $conduit_uri->getDomain(); - - $block = id(new PhutilConsoleBlock()) - ->addParagraph( - tsprintf( - '** %s **', - pht('LOGIN REQUIRED'))) - ->addParagraph( - pht( - 'You are trying to connect to a server ("%s") that you do not '. - 'have any stored credentials for, but the command you are '. - 'running requires authentication.', - $conduit_domain)) - ->addParagraph( - pht( - 'To login and save credentials for this server, run this '. - 'command:')) - ->addParagraph( - tsprintf( - " $ arc install-certificate %s\n", - $conduit_uri)); - - throw new ArcanistUsageException($block->drawConsoleString()); - } - - private function raiseInvalidAuth() { - $conduit_uri = $this->getEngine()->getConduitURI(); - $conduit_uri = new PhutilURI($conduit_uri); - $conduit_uri->setPath('/'); - - $conduit_domain = $conduit_uri->getDomain(); - - $block = id(new PhutilConsoleBlock()) - ->addParagraph( - tsprintf( - '** %s **', - pht('INVALID CREDENTIALS'))) - ->addParagraph( - pht( - 'Your stored credentials for this server ("%s") are not valid.', - $conduit_domain)) - ->addParagraph( - pht( - 'To login and save valid credentials for this server, run this '. - 'command:')) - ->addParagraph( - tsprintf( - " $ arc install-certificate %s\n", - $conduit_uri)); - - throw new ArcanistUsageException($block->drawConsoleString()); - } - -} diff --git a/src/conduit/ArcanistConduitCallFuture.php b/src/conduit/ArcanistConduitCallFuture.php new file mode 100644 index 00000000..187b4c27 --- /dev/null +++ b/src/conduit/ArcanistConduitCallFuture.php @@ -0,0 +1,116 @@ +engine = $engine; + return $this; + } + + public function getEngine() { + return $this->engine; + } + + private function raiseLoginRequired() { + $conduit_domain = $this->getConduitDomain(); + + $message = array( + tsprintf( + "\n\n%W\n\n", + pht( + 'You are trying to connect to a server ("%s") that you do not '. + 'have any stored credentials for, but the command you are '. + 'running requires authentication.', + $conduit_domain)), + tsprintf( + "%W\n\n", + pht( + 'To log in and save credentials for this server, run this '. + 'command:')), + tsprintf( + '%>', + $this->getInstallCommand()), + ); + + $this->raiseException( + pht('Conduit API login required.'), + pht('LOGIN REQUIRED'), + $message); + } + + private function raiseInvalidAuth() { + $conduit_domain = $this->getConduitDomain(); + + $message = array( + tsprintf( + "\n\n%W\n\n", + pht( + 'Your stored credentials for the server you are trying to connect '. + 'to ("%s") are not valid.', + $conduit_domain)), + tsprintf( + "%W\n\n", + pht( + 'To log in and save valid credentials for this server, run this '. + 'command:')), + tsprintf( + '%>', + $this->getInstallCommand()), + ); + + $this->raiseException( + pht('Invalid Conduit API credentials.'), + pht('INVALID CREDENTIALS'), + $message); + } + + protected function didReceiveResult($result) { + return $result; + } + + protected function didReceiveException($exception) { + switch ($exception->getErrorCode()) { + case 'ERR-INVALID-SESSION': + if (!$this->getEngine()->getConduitToken()) { + $this->raiseLoginRequired(); + } + break; + case 'ERR-INVALID-AUTH': + $this->raiseInvalidAuth(); + break; + } + + throw $exception; + } + + private function getInstallCommand() { + $conduit_uri = $this->getConduitURI(); + + return csprintf( + 'arc install-certificate %s', + $conduit_uri); + } + + private function getConduitURI() { + $conduit_uri = $this->getEngine()->getConduitURI(); + $conduit_uri = new PhutilURI($conduit_uri); + $conduit_uri->setPath('/'); + + return $conduit_uri; + } + + private function getConduitDomain() { + $conduit_uri = $this->getConduitURI(); + return $conduit_uri->getDomain(); + } + + private function raiseException($summary, $title, $body) { + throw id(new ArcanistConduitAuthenticationException($summary)) + ->setTitle($title) + ->setBody($body); + } + +} diff --git a/src/conduit/ArcanistConduitEngine.php b/src/conduit/ArcanistConduitEngine.php index c9171245..cd2b411e 100644 --- a/src/conduit/ArcanistConduitEngine.php +++ b/src/conduit/ArcanistConduitEngine.php @@ -1,118 +1,107 @@ conduitURI !== null); } public function setConduitURI($conduit_uri) { $this->conduitURI = $conduit_uri; return $this; } public function getConduitURI() { return $this->conduitURI; } public function setConduitToken($conduit_token) { $this->conduitToken = $conduit_token; return $this; } public function getConduitToken() { return $this->conduitToken; } public function setConduitTimeout($conduit_timeout) { $this->conduitTimeout = $conduit_timeout; return $this; } public function getConduitTimeout() { return $this->conduitTimeout; } - public function newCall($method, array $parameters) { + public function newFuture($method, array $parameters) { if ($this->conduitURI == null && $this->client === null) { $this->raiseURIException(); } - return id(new ArcanistConduitCall()) - ->setEngine($this) - ->setMethod($method) - ->setParameters($parameters); - } - - public function resolveCall($method, array $parameters) { - return $this->newCall($method, $parameters)->resolve(); - } - - public function newFuture(ArcanistConduitCall $call) { - $method = $call->getMethod(); - $parameters = $call->getParameters(); - $future = $this->getClient()->callMethod($method, $parameters); - return $future; + $call_future = id(new ArcanistConduitCallFuture($future)) + ->setEngine($this); + + return $call_future; } private function getClient() { if (!$this->client) { $conduit_uri = $this->getConduitURI(); $client = new ConduitClient($conduit_uri); $timeout = $this->getConduitTimeout(); if ($timeout) { $client->setTimeout($timeout); } $token = $this->getConduitToken(); if ($token) { $client->setConduitToken($this->getConduitToken()); } $this->client = $client; } return $this->client; } private function raiseURIException() { $list = id(new PhutilConsoleList()) ->addItem( pht( 'Run in a working copy with "phabricator.uri" set in ".arcconfig".')) ->addItem( pht( 'Set a default URI with `arc set-config phabricator.uri `.')) ->addItem( pht( 'Specify a URI explicitly with `--config phabricator.uri=`.')); $block = id(new PhutilConsoleBlock()) ->addParagraph( pht( 'This command needs to communicate with Phabricator, but no '. 'Phabricator URI is configured.')) ->addList($list); throw new ArcanistUsageException($block->drawConsoleString()); } public static function newConduitEngineFromConduitClient( ConduitClient $client) { $engine = new self(); $engine->client = $client; return $engine; } } diff --git a/src/conduit/ConduitFuture.php b/src/conduit/ConduitFuture.php index 20c6906a..351d2d11 100644 --- a/src/conduit/ConduitFuture.php +++ b/src/conduit/ConduitFuture.php @@ -1,90 +1,79 @@ client = $client; $this->conduitMethod = $method; return $this; } - public function isReady() { - if ($this->profilerCallID === null) { - $profiler = PhutilServiceProfiler::getInstance(); - - $this->profilerCallID = $profiler->beginServiceCall( - array( - 'type' => 'conduit', - 'method' => $this->conduitMethod, - 'size' => $this->getProxiedFuture()->getHTTPRequestByteLength(), - )); - } + protected function getServiceProfilerStartParameters() { + return array( + 'type' => 'conduit', + 'method' => $this->conduitMethod, + 'size' => $this->getProxiedFuture()->getHTTPRequestByteLength(), + ); + } - return parent::isReady(); + protected function getServiceProfilerResultParameters() { + return array(); } protected function didReceiveResult($result) { - if ($this->profilerCallID !== null) { - $profiler = PhutilServiceProfiler::getInstance(); - $profiler->endServiceCall( - $this->profilerCallID, - array()); - } - list($status, $body, $headers) = $result; if ($status->isError()) { throw $status; } $capabilities = array(); foreach ($headers as $header) { list($name, $value) = $header; if (!strcasecmp($name, 'X-Conduit-Capabilities')) { $capabilities = explode(' ', $value); break; } } if ($capabilities) { $this->client->enableCapabilities($capabilities); } $raw = $body; $shield = 'for(;;);'; if (!strncmp($raw, $shield, strlen($shield))) { $raw = substr($raw, strlen($shield)); } $data = null; try { $data = phutil_json_decode($raw); } catch (PhutilJSONParserException $ex) { throw new PhutilProxyException( pht( 'Host returned HTTP/200, but invalid JSON data in response to '. 'a Conduit method call.'), $ex); } if ($data['error_code']) { throw new ConduitClientException( $data['error_code'], $data['error_info']); } $result = $data['result']; $result = $this->client->didReceiveResponse( $this->conduitMethod, $result); return $result; } } diff --git a/src/conduit/ConduitSearchFuture.php b/src/conduit/ConduitSearchFuture.php index 03c394d9..f4d9564e 100644 --- a/src/conduit/ConduitSearchFuture.php +++ b/src/conduit/ConduitSearchFuture.php @@ -1,117 +1,116 @@ conduitEngine = $conduit_engine; return $this; } public function getConduitEngine() { return $this->conduitEngine; } public function setMethod($method) { $this->method = $method; return $this; } public function getMethod() { return $this->method; } public function setConstraints(array $constraints) { $this->constraints = $constraints; return $this; } public function getConstraints() { return $this->constraints; } public function setAttachments(array $attachments) { $this->attachments = $attachments; return $this; } public function getAttachments() { return $this->attachments; } public function isReady() { if ($this->hasResult()) { return true; } $futures = $this->getFutures(); $future = head($futures); if (!$future) { $future = $this->newFuture(); } if (!$future->isReady()) { $this->setFutures(array($future)); return false; } else { $this->setFutures(array()); } $result = $future->resolve(); foreach ($this->readResults($result) as $object) { $this->objects[] = $object; } $cursor = idxv($result, array('cursor', 'after')); if ($cursor === null) { $this->setResult($this->objects); return true; } $this->cursor = $cursor; $future = $this->newFuture(); $this->setFutures(array($future)); return false; } private function newFuture() { $engine = $this->getConduitEngine(); $method = $this->getMethod(); $constraints = $this->getConstraints(); $parameters = array( 'constraints' => $constraints, ); if ($this->attachments) { $parameters['attachments'] = $this->attachments; } if ($this->cursor !== null) { $parameters['after'] = (string)$this->cursor; } - $conduit_call = $engine->newCall($method, $parameters); - $conduit_future = $engine->newFuture($conduit_call); + $conduit_future = $engine->newFuture($method, $parameters); return $conduit_future; } private function readResults(array $data) { return idx($data, 'data'); } } diff --git a/src/conduit/FutureAgent.php b/src/conduit/FutureAgent.php index 6000c6b2..3b84ab88 100644 --- a/src/conduit/FutureAgent.php +++ b/src/conduit/FutureAgent.php @@ -1,38 +1,45 @@ futures = $futures; } final protected function getFutures() { return $this->futures; } final public function getReadSockets() { $sockets = array(); foreach ($this->getFutures() as $future) { foreach ($future->getReadSockets() as $read_socket) { $sockets[] = $read_socket; } } return $sockets; } final public function getWriteSockets() { $sockets = array(); foreach ($this->getFutures() as $future) { foreach ($future->getWriteSockets() as $read_socket) { $sockets[] = $read_socket; } } return $sockets; } + protected function getServiceProfilerStartParameters() { + // At least today, the agent construct doesn't add anything interesting + // to the trace and the underlying futures always show up in the trace + // themselves. + return null; + } + } diff --git a/src/config/arc/ArcanistArcConfigurationEngineExtension.php b/src/config/arc/ArcanistArcConfigurationEngineExtension.php index a7219be8..d40159d5 100644 --- a/src/config/arc/ArcanistArcConfigurationEngineExtension.php +++ b/src/config/arc/ArcanistArcConfigurationEngineExtension.php @@ -1,151 +1,164 @@ array( 'type' => 'list', 'legacy' => 'phutil_libraries', 'help' => pht( 'A list of paths to phutil libraries that should be loaded at '. 'startup. This can be used to make classes available, like lint '. 'or unit test engines.'), 'default' => array(), 'example' => '["/var/arc/customlib/src"]', ), - - 'arc.feature.start.default' => array( - 'type' => 'string', - 'help' => pht( - 'The name of the default branch to create the new feature branch '. - 'off of.'), - 'example' => '"develop"', - ), - 'arc.land.onto.default' => array( - 'type' => 'string', - 'help' => pht( - 'The name of the default branch to land changes onto when '. - '`%s` is run.', - 'arc land'), - 'example' => '"develop"', - ), - - 'arc.autostash' => array( - 'type' => 'bool', - 'help' => pht( - 'Whether %s should permit the automatic stashing of changes in the '. - 'working directory when requiring a clean working copy. This option '. - 'should only be used when users understand how to restore their '. - 'working directory from the local stash if an Arcanist operation '. - 'causes an unrecoverable error.', - 'arc'), - 'default' => false, - 'example' => 'false', - ), - - 'history.immutable' => array( - 'type' => 'bool', - 'legacy' => 'immutable_history', - 'help' => pht( - 'If true, %s will never change repository history (e.g., through '. - 'amending or rebasing). Defaults to true in Mercurial and false in '. - 'Git. This setting has no effect in Subversion.', - 'arc'), - 'example' => 'false', - ), 'editor' => array( 'type' => 'string', 'help' => pht( 'Command to use to invoke an interactive editor, like `%s` or `%s`. '. 'This setting overrides the %s environmental variable.', 'nano', 'vim', 'EDITOR'), 'example' => '"nano"', ), 'https.cabundle' => array( 'type' => 'string', 'help' => pht( "Path to a custom CA bundle file to be used for arcanist's cURL ". "calls. This is used primarily when your conduit endpoint is ". "behind HTTPS signed by your organization's internal CA."), 'example' => 'support/yourca.pem', ), 'browser' => array( 'type' => 'string', 'help' => pht('Command to use to invoke a web browser.'), 'example' => '"gnome-www-browser"', ), */ return array( id(new ArcanistStringConfigOption()) ->setKey('base') ->setSummary(pht('Ruleset for selecting commit ranges.')) ->setHelp( pht( 'Base commit ruleset to invoke when determining the start of a '. 'commit range. See "Arcanist User Guide: Commit Ranges" for '. 'details.')) ->setExamples( array( 'arc:amended, arc:prompt', )), id(new ArcanistStringConfigOption()) ->setKey('repository') ->setAliases( array( 'repository.callsign', )) ->setSummary(pht('Repository for the current working copy.')) ->setHelp( pht( 'Associate the working copy with a specific Phabricator '. - 'repository. Normally, `arc` can figure this association out on '. - 'its own, but if your setup is unusual you can use this option '. - 'to tell it what the desired value is.')) + 'repository. Normally, Arcanist can figure this association '. + 'out on its own, but if your setup is unusual you can use '. + 'this option to tell it what the desired value is.')) ->setExamples( array( 'libexample', 'XYZ', 'R123', '123', )), id(new ArcanistStringConfigOption()) ->setKey('phabricator.uri') ->setAliases( array( 'conduit_uri', 'default', )) ->setSummary(pht('Phabricator install to connect to.')) ->setHelp( pht( 'Associates this working copy with a specific installation of '. 'Phabricator.')) ->setExamples( array( 'https://phabricator.mycompany.com/', )), id(new ArcanistAliasesConfigOption()) ->setKey(self::KEY_ALIASES) ->setDefaultValue(array()) ->setSummary(pht('List of command aliases.')) ->setHelp( pht( 'Configured command aliases. Use the "alias" workflow to define '. 'aliases.')), + id(new ArcanistPromptsConfigOption()) + ->setKey(self::KEY_PROMPTS) + ->setDefaultValue(array()) + ->setSummary(pht('List of prompt responses.')) + ->setHelp( + pht( + 'Configured prompt aliases. Use the "prompts" workflow to '. + 'show prompts and responses.')), + id(new ArcanistStringListConfigOption()) + ->setKey('arc.land.onto') + ->setDefaultValue(array()) + ->setSummary(pht('Default list of "onto" refs for "arc land".')) + ->setHelp( + pht( + 'Specifies the default behavior when "arc land" is run with '. + 'no "--onto" flag.')) + ->setExamples( + array( + '["master"]', + )), + id(new ArcanistStringListConfigOption()) + ->setKey('pager') + ->setDefaultValue(array()) + ->setSummary(pht('Default pager command.')) + ->setHelp( + pht( + 'Specify the pager command to use when displaying '. + 'documentation.')) + ->setExamples( + array( + '["less", "-R", "--"]', + )), + id(new ArcanistStringConfigOption()) + ->setKey('arc.land.onto-remote') + ->setSummary(pht('Default list of "onto" remote for "arc land".')) + ->setHelp( + pht( + 'Specifies the default behavior when "arc land" is run with '. + 'no "--onto-remote" flag.')) + ->setExamples( + array( + 'origin', + )), + id(new ArcanistStringConfigOption()) + ->setKey('arc.land.strategy') + ->setSummary( + pht( + 'Configure a default merge strategy for "arc land".')) + ->setHelp( + pht( + 'Specifies the default behavior when "arc land" is run with '. + 'no "--strategy" flag.')), ); } } diff --git a/src/config/option/ArcanistAliasesConfigOption.php b/src/config/option/ArcanistAliasesConfigOption.php index 536650dc..6859ebde 100644 --- a/src/config/option/ArcanistAliasesConfigOption.php +++ b/src/config/option/ArcanistAliasesConfigOption.php @@ -1,47 +1,47 @@ '; } public function getValueFromStorageValue($value) { if (!is_array($value)) { throw new Exception(pht('Expected a list or dictionary!')); } $aliases = array(); foreach ($value as $key => $spec) { $aliases[] = ArcanistAlias::newFromConfig($key, $spec); } return $aliases; } protected function didReadStorageValueList(array $list) { assert_instances_of($list, 'ArcanistConfigurationSourceValue'); $results = array(); foreach ($list as $spec) { $source = $spec->getConfigurationSource(); $value = $spec->getValue(); $value->setConfigurationSource($source); $results[] = $value; } return $results; } public function getDisplayValueFromValue($value) { return pht('Use the "alias" workflow to review aliases.'); } public function getStorageValueFromValue($value) { return mpull($value, 'getStorageDictionary'); } } diff --git a/src/config/option/ArcanistBoolConfigOption.php b/src/config/option/ArcanistBoolConfigOption.php new file mode 100644 index 00000000..74f18a78 --- /dev/null +++ b/src/config/option/ArcanistBoolConfigOption.php @@ -0,0 +1,35 @@ +getConfigurationSource(); - $storage_value = $this->getStorageValueFromSourceValue($source_value); - - $items = $this->getValueFromStorageValue($storage_value); - foreach ($items as $item) { - $result_list[] = new ArcanistConfigurationSourceValue( - $source, - $item); - } + extends ArcanistSingleSourceConfigOption { + + final public function getStorageValueFromStringValue($value) { + try { + $json_value = phutil_json_decode($value); + } catch (PhutilJSONParserException $ex) { + throw new PhutilArgumentUsageException( + pht( + 'Value "%s" is not valid, specify a JSON list: %s', + $value, + $ex->getMessage())); } - $result_list = $this->didReadStorageValueList($result_list); + if (!is_array($json_value) || !phutil_is_natural_list($json_value)) { + throw new PhutilArgumentUsageException( + pht( + 'Value "%s" is not valid: expected a list, got "%s".', + $value, + phutil_describe_type($json_value))); + } + + foreach ($json_value as $idx => $item) { + $this->validateListItem($idx, $item); + } + + return $json_value; + } + + final public function getValueFromStorageValue($value) { + if (!is_array($value)) { + throw new Exception(pht('Expected a list!')); + } - return $result_list; + if (!phutil_is_natural_list($value)) { + throw new Exception(pht('Expected a natural list!')); + } + + foreach ($value as $idx => $item) { + $this->validateListItem($idx, $item); + } + + return $value; } - protected function didReadStorageValueList(array $list) { - assert_instances_of($list, 'ArcanistConfigurationSourceValue'); - return mpull($list, 'getValue'); + public function getDisplayValueFromValue($value) { + return json_encode($value); } + public function getStorageValueFromValue($value) { + return $value; + } + + abstract protected function validateListItem($idx, $item); + } diff --git a/src/config/option/ArcanistListConfigOption.php b/src/config/option/ArcanistMultiSourceConfigOption.php similarity index 94% copy from src/config/option/ArcanistListConfigOption.php copy to src/config/option/ArcanistMultiSourceConfigOption.php index f8d5edf0..822b4ab3 100644 --- a/src/config/option/ArcanistListConfigOption.php +++ b/src/config/option/ArcanistMultiSourceConfigOption.php @@ -1,32 +1,32 @@ getConfigurationSource(); $storage_value = $this->getStorageValueFromSourceValue($source_value); $items = $this->getValueFromStorageValue($storage_value); foreach ($items as $item) { $result_list[] = new ArcanistConfigurationSourceValue( $source, $item); } } $result_list = $this->didReadStorageValueList($result_list); return $result_list; } protected function didReadStorageValueList(array $list) { assert_instances_of($list, 'ArcanistConfigurationSourceValue'); return mpull($list, 'getValue'); } } diff --git a/src/config/option/ArcanistAliasesConfigOption.php b/src/config/option/ArcanistPromptsConfigOption.php similarity index 57% copy from src/config/option/ArcanistAliasesConfigOption.php copy to src/config/option/ArcanistPromptsConfigOption.php index 536650dc..99a0935a 100644 --- a/src/config/option/ArcanistAliasesConfigOption.php +++ b/src/config/option/ArcanistPromptsConfigOption.php @@ -1,47 +1,51 @@ '; + return 'map'; } public function getValueFromStorageValue($value) { if (!is_array($value)) { - throw new Exception(pht('Expected a list or dictionary!')); + throw new Exception(pht('Expected a list!')); } - $aliases = array(); - foreach ($value as $key => $spec) { - $aliases[] = ArcanistAlias::newFromConfig($key, $spec); + if (!phutil_is_natural_list($value)) { + throw new Exception(pht('Expected a natural list!')); } - return $aliases; + $responses = array(); + foreach ($value as $spec) { + $responses[] = ArcanistPromptResponse::newFromConfig($spec); + } + + return $responses; } protected function didReadStorageValueList(array $list) { assert_instances_of($list, 'ArcanistConfigurationSourceValue'); $results = array(); foreach ($list as $spec) { $source = $spec->getConfigurationSource(); $value = $spec->getValue(); $value->setConfigurationSource($source); $results[] = $value; } return $results; } public function getDisplayValueFromValue($value) { - return pht('Use the "alias" workflow to review aliases.'); + return pht('Use the "prompts" workflow to review prompt responses.'); } public function getStorageValueFromValue($value) { return mpull($value, 'getStorageDictionary'); } } diff --git a/src/config/option/ArcanistScalarConfigOption.php b/src/config/option/ArcanistSingleSourceConfigOption.php similarity index 89% rename from src/config/option/ArcanistScalarConfigOption.php rename to src/config/option/ArcanistSingleSourceConfigOption.php index 9ccda1e6..058bc5c6 100644 --- a/src/config/option/ArcanistScalarConfigOption.php +++ b/src/config/option/ArcanistSingleSourceConfigOption.php @@ -1,19 +1,19 @@ getStorageValueFromSourceValue($source_value); return $this->getValueFromStorageValue($storage_value); } public function getValueFromStorageValue($value) { return $value; } } diff --git a/src/config/option/ArcanistStringConfigOption.php b/src/config/option/ArcanistStringConfigOption.php index a6da4627..ba68845e 100644 --- a/src/config/option/ArcanistStringConfigOption.php +++ b/src/config/option/ArcanistStringConfigOption.php @@ -1,22 +1,22 @@ '; + } + + protected function validateListItem($idx, $item) { + if (!is_string($item)) { + throw new PhutilArgumentUsageException( + pht( + 'Expected a string (at index "%s"), found "%s".', + $idx, + phutil_describe_type($item))); + } + } + +} diff --git a/src/config/source/ArcanistConfigurationSource.php b/src/config/source/ArcanistConfigurationSource.php index 0ede49e1..ed3f6666 100644 --- a/src/config/source/ArcanistConfigurationSource.php +++ b/src/config/source/ArcanistConfigurationSource.php @@ -1,39 +1,40 @@ getLogEngine()->writeWarning( pht('UNKNOWN CONFIGURATION'), pht( 'Ignoring unrecognized configuration option ("%s") from source: %s.', $key, $this->getSourceDisplayName())); } } diff --git a/src/config/source/ArcanistLocalConfigurationSource.php b/src/config/source/ArcanistLocalConfigurationSource.php index f5c94944..3994cc23 100644 --- a/src/config/source/ArcanistLocalConfigurationSource.php +++ b/src/config/source/ArcanistLocalConfigurationSource.php @@ -1,10 +1,18 @@ buildAllWorkflows(), $command); if (!$workflow) { return null; } return clone $workflow; } public function buildAllWorkflows() { return id(new PhutilClassMapQuery()) ->setAncestorClass('ArcanistWorkflow') ->setUniqueMethod('getWorkflowName') ->execute(); } final public function isValidWorkflow($workflow) { return (bool)$this->buildWorkflow($workflow); } public function willRunWorkflow($command, ArcanistWorkflow $workflow) { // This is a hook. } public function didRunWorkflow($command, ArcanistWorkflow $workflow, $err) { // This is a hook. } public function didAbortWorkflow($command, $workflow, Exception $ex) { // This is a hook. } public function getCustomArgumentsForCommand($command) { return array(); } final public function selectWorkflow( &$command, array &$args, ArcanistConfigurationManager $configuration_manager, PhutilConsole $console) { // First, try to build a workflow with the exact name provided. We always // pick an exact match, and do not allow aliases to override it. $workflow = $this->buildWorkflow($command); if ($workflow) { return $workflow; } $all = array_keys($this->buildAllWorkflows()); // We haven't found a real command or an alias, so try to locate a command // by unique prefix. $prefixes = $this->expandCommandPrefix($command, $all); if (count($prefixes) == 1) { $command = head($prefixes); return $this->buildWorkflow($command); } else if (count($prefixes) > 1) { $this->raiseUnknownCommand($command, $prefixes); } // We haven't found a real command, alias, or unique prefix. Try similar // spellings. $corrected = PhutilArgumentSpellingCorrector::newCommandCorrector() ->correctSpelling($command, $all); if (count($corrected) == 1) { $console->writeErr( pht( "(Assuming '%s' is the British spelling of '%s'.)", $command, head($corrected))."\n"); $command = head($corrected); return $this->buildWorkflow($command); } else if (count($corrected) > 1) { $this->raiseUnknownCommand($command, $corrected); } $this->raiseUnknownCommand($command); } private function raiseUnknownCommand($command, array $maybe = array()) { $message = pht("Unknown command '%s'. Try '%s'.", $command, 'arc help'); if ($maybe) { $message .= "\n\n".pht('Did you mean:')."\n"; sort($maybe); foreach ($maybe as $other) { $message .= " ".$other."\n"; } } throw new ArcanistUsageException($message); } private function expandCommandPrefix($command, array $options) { $is_prefix = array(); foreach ($options as $option) { if (strncmp($option, $command, strlen($command)) == 0) { $is_prefix[$option] = true; } } return array_keys($is_prefix); } } diff --git a/src/configuration/ArcanistSettings.php b/src/configuration/ArcanistSettings.php index 496d3771..67081703 100644 --- a/src/configuration/ArcanistSettings.php +++ b/src/configuration/ArcanistSettings.php @@ -1,322 +1,315 @@ array( 'type' => 'string', 'help' => pht( 'The URI of a Phabricator install to connect to by default, if '. '%s is run in a project without a Phabricator URI or run outside '. 'of a project.', 'arc'), 'example' => '"http://phabricator.example.com/"', ), 'base' => array( 'type' => 'string', 'help' => pht( 'Base commit ruleset to invoke when determining the start of a '. 'commit range. See "Arcanist User Guide: Commit Ranges" for '. 'details.'), 'example' => '"arc:amended, arc:prompt"', ), 'load' => array( 'type' => 'list', 'legacy' => 'phutil_libraries', 'help' => pht( 'A list of paths to phutil libraries that should be loaded at '. 'startup. This can be used to make classes available, like lint '. 'or unit test engines.'), 'default' => array(), 'example' => '["/var/arc/customlib/src"]', ), 'repository.callsign' => array( 'type' => 'string', 'example' => '"X"', 'help' => pht( 'Associate the working copy with a specific Phabricator repository. '. 'Normally, %s can figure this association out on its own, but if '. 'your setup is unusual you can use this option to tell it what the '. 'desired value is.', 'arc'), ), 'phabricator.uri' => array( 'type' => 'string', 'legacy' => 'conduit_uri', 'example' => '"https://phabricator.mycompany.com/"', 'help' => pht( 'Associates this working copy with a specific installation of '. 'Phabricator.'), ), 'lint.engine' => array( 'type' => 'string', 'legacy' => 'lint_engine', 'help' => pht( 'The name of a default lint engine to use, if no lint engine is '. 'specified by the current project.'), 'example' => '"ExampleLintEngine"', ), 'unit.engine' => array( 'type' => 'string', 'legacy' => 'unit_engine', 'help' => pht( 'The name of a default unit test engine to use, if no unit test '. 'engine is specified by the current project.'), 'example' => '"ExampleUnitTestEngine"', ), - 'arc.feature.start.default' => array( - 'type' => 'string', - 'help' => pht( - 'The name of the default branch to create the new feature branch '. - 'off of.'), - 'example' => '"develop"', - ), 'arc.land.onto.default' => array( 'type' => 'string', 'help' => pht( 'The name of the default branch to land changes onto when '. '`%s` is run.', 'arc land'), 'example' => '"develop"', ), 'history.immutable' => array( 'type' => 'bool', 'legacy' => 'immutable_history', 'help' => pht( 'If true, %s will never change repository history (e.g., through '. 'amending or rebasing). Defaults to true in Mercurial and false in '. 'Git. This setting has no effect in Subversion.', 'arc'), 'example' => 'false', ), 'editor' => array( 'type' => 'string', 'help' => pht( 'Command to use to invoke an interactive editor, like `%s` or `%s`. '. 'This setting overrides the %s environmental variable.', 'nano', 'vim', 'EDITOR'), 'example' => '"nano"', ), 'https.cabundle' => array( 'type' => 'string', 'help' => pht( "Path to a custom CA bundle file to be used for arcanist's cURL ". "calls. This is used primarily when your conduit endpoint is ". "behind HTTPS signed by your organization's internal CA."), 'example' => 'support/yourca.pem', ), 'browser' => array( 'type' => 'string', 'help' => pht('Command to use to invoke a web browser.'), 'example' => '"gnome-www-browser"', ), 'events.listeners' => array( 'type' => 'list', 'help' => pht('List of event listener classes to install at startup.'), 'default' => array(), 'example' => '["ExampleEventListener"]', ), 'arc.autostash' => array( 'type' => 'bool', 'help' => pht( 'Whether %s should permit the automatic stashing of changes in the '. 'working directory when requiring a clean working copy. This option '. 'should only be used when users understand how to restore their '. 'working directory from the local stash if an Arcanist operation '. 'causes an unrecoverable error.', 'arc'), 'default' => false, 'example' => 'false', ), 'aliases' => array( 'type' => 'aliases', 'help' => pht( 'Configured command aliases. Use "arc alias" to define aliases.'), ), ); $settings = ArcanistSetting::getAllSettings(); foreach ($settings as $key => $setting) { $settings[$key] = $setting->getLegacyDictionary(); } $results = $settings + $legacy_builtins; ksort($results); return $results; } private function getOption($key) { return idx($this->getOptions(), $key, array()); } public function getAllKeys() { return array_keys($this->getOptions()); } public function getHelp($key) { return idx($this->getOption($key), 'help'); } public function getExample($key) { return idx($this->getOption($key), 'example'); } public function getType($key) { return idx($this->getOption($key), 'type', 'wild'); } public function getLegacyName($key) { return idx($this->getOption($key), 'legacy'); } public function getDefaultSettings() { $defaults = array(); foreach ($this->getOptions() as $key => $option) { if (array_key_exists('default', $option)) { $defaults[$key] = $option['default']; } } return $defaults; } public function willWriteValue($key, $value) { $type = $this->getType($key); switch ($type) { case 'bool': if (strtolower($value) === 'false' || strtolower($value) === 'no' || strtolower($value) === 'off' || $value === '' || $value === '0' || $value === 0 || $value === false) { $value = false; } else if (strtolower($value) === 'true' || strtolower($value) === 'yes' || strtolower($value) === 'on' || $value === '1' || $value === 1 || $value === true) { $value = true; } else { throw new ArcanistUsageException( pht( "Type of setting '%s' must be boolean, like 'true' or 'false'.", $key)); } break; case 'list': if (is_array($value)) { break; } if (is_string($value)) { $list = json_decode($value, true); if (is_array($list)) { $value = $list; break; } } throw new ArcanistUsageException( pht( "Type of setting '%s' must be list. You can specify a list ". "in JSON, like: %s", $key, '["apple", "banana", "cherry"]')); case 'string': if (!is_scalar($value)) { throw new ArcanistUsageException( pht( "Type of setting '%s' must be string.", $key)); } $value = (string)$value; break; case 'wild': break; case 'aliases': throw new Exception( pht( 'Use "arc alias" to configure aliases, not "arc set-config".')); break; } return $value; } public function willReadValue($key, $value) { $type = $this->getType($key); switch ($type) { case 'string': if (!is_string($value)) { throw new ArcanistUsageException( pht( "Type of setting '%s' must be string.", $key)); } break; case 'bool': if ($value !== true && $value !== false) { throw new ArcanistUsageException( pht( "Type of setting '%s' must be boolean.", $key)); } break; case 'list': if (!is_array($value)) { throw new ArcanistUsageException( pht( "Type of setting '%s' must be list.", $key)); } break; case 'wild': case 'aliases': break; } return $value; } public function formatConfigValueForDisplay($key, $value) { if ($value === false) { return 'false'; } if ($value === true) { return 'true'; } if ($value === null) { return 'null'; } if (is_string($value)) { return '"'.$value.'"'; } if (is_array($value)) { // TODO: Both json_encode() and PhutilJSON do a bad job with one-liners. // PhutilJSON splits them across a bunch of lines, while json_encode() // escapes all kinds of stuff like "/". It would be nice if PhutilJSON // had a mode for pretty one-liners. $value = json_encode($value); // json_encode() unnecessarily escapes "/" to prevent "" stuff, // optimistically unescape it for display to improve readability. $value = preg_replace('@(?key = $key; + return $this; + } + + public function getKey() { + return $this->key; + } + + public function setContent($content) { + $this->content = $content; + return $this; + } + + public function getContent() { + return $this->content; + } + + public function getContentDisplayWidth() { + $lines = $this->getContentDisplayLines(); + + $width = 0; + foreach ($lines as $line) { + $width = max($width, phutil_utf8_console_strlen($line)); + } + + return $width; + } + + public function getContentDisplayLines() { + $content = $this->getContent(); + $content = tsprintf('%B', $content); + $content = phutil_string_cast($content); + + $lines = phutil_split_lines($content, false); + + $result = array(); + foreach ($lines as $line) { + $result[] = tsprintf('%R', $line); + } + + return $result; + } + + +} diff --git a/src/console/grid/ArcanistGridColumn.php b/src/console/grid/ArcanistGridColumn.php new file mode 100644 index 00000000..1c63575c --- /dev/null +++ b/src/console/grid/ArcanistGridColumn.php @@ -0,0 +1,51 @@ +key = $key; + return $this; + } + + public function getKey() { + return $this->key; + } + + public function setAlignment($alignment) { + $this->alignment = $alignment; + return $this; + } + + public function getAlignment() { + return $this->alignment; + } + + public function setDisplayWidth($display_width) { + $this->displayWidth = $display_width; + return $this; + } + + public function getDisplayWidth() { + return $this->displayWidth; + } + + public function setMinimumWidth($minimum_width) { + $this->minimumWidth = $minimum_width; + return $this; + } + + public function getMinimumWidth() { + return $this->minimumWidth; + } + +} diff --git a/src/console/grid/ArcanistGridRow.php b/src/console/grid/ArcanistGridRow.php new file mode 100644 index 00000000..0f24e4ef --- /dev/null +++ b/src/console/grid/ArcanistGridRow.php @@ -0,0 +1,40 @@ +setInstancesOf('ArcanistGridCell') + ->setUniqueMethod('getKey') + ->setContext($this, 'setCells') + ->checkValue($cells); + + $this->cells = $cells; + + return $this; + } + + public function getCells() { + return $this->cells; + } + + public function hasCell($key) { + return isset($this->cells[$key]); + } + + public function getCell($key) { + if (!isset($this->cells[$key])) { + throw new Exception( + pht( + 'Row has no cell "%s".\n', + $key)); + } + + return $this->cells[$key]; + } + + +} diff --git a/src/console/grid/ArcanistGridView.php b/src/console/grid/ArcanistGridView.php new file mode 100644 index 00000000..5391ea9c --- /dev/null +++ b/src/console/grid/ArcanistGridView.php @@ -0,0 +1,295 @@ +columns = mpull($columns, null, 'getKey'); + return $this; + } + + public function getColumns() { + return $this->columns; + } + + public function newColumn($key) { + $column = id(new ArcanistGridColumn()) + ->setKey($key); + + $this->columns[$key] = $column; + + return $column; + } + + public function newRow(array $cells) { + assert_instances_of($cells, 'ArcanistGridCell'); + + $row = id(new ArcanistGridRow()) + ->setCells($cells); + + $this->rows[] = $row; + + return $row; + } + + public function drawGrid() { + $columns = $this->getColumns(); + if (!$columns) { + throw new Exception( + pht( + 'Can not draw a grid with no columns!')); + } + + $rows = array(); + foreach ($this->rows as $row) { + $rows[] = $this->drawRow($row); + } + + $rows = phutil_glue($rows, tsprintf("\n")); + + return tsprintf("%s\n", $rows); + } + + private function getDisplayWidth($display_key) { + if (!isset($this->displayWidths[$display_key])) { + $flexible_columns = array(); + + $columns = $this->getColumns(); + foreach ($columns as $key => $column) { + $width = $column->getDisplayWidth(); + + if ($width === null) { + $width = 1; + foreach ($this->getRows() as $row) { + if (!$row->hasCell($key)) { + continue; + } + + $cell = $row->getCell($key); + $width = max($width, $cell->getContentDisplayWidth()); + } + } + + if ($column->getMinimumWidth() !== null) { + $flexible_columns[] = $key; + } + + $this->displayWidths[$key] = $width; + } + + $available_width = phutil_console_get_terminal_width(); + + // Adjust the available width to account for cell spacing. + $available_width -= (2 * (count($columns) - 1)); + + while (true) { + $total_width = array_sum($this->displayWidths); + + if ($total_width <= $available_width) { + break; + } + + if (!$flexible_columns) { + break; + } + + // NOTE: This is very unsophisticated, and just shortcuts us to a + // reasonable result when only one column is flexible. + + foreach ($flexible_columns as $flexible_key) { + $column = $columns[$flexible_key]; + + $need_width = ($total_width - $available_width); + $old_width = $this->displayWidths[$flexible_key]; + $new_width = ($old_width - $need_width); + + $new_width = max($new_width, $column->getMinimumWidth()); + + $this->displayWidths[$flexible_key] = $new_width; + + $flexible_columns = array(); + break; + } + } + } + + return $this->displayWidths[$display_key]; + } + + public function getColumn($key) { + if (!isset($this->columns[$key])) { + throw new Exception( + pht( + 'Grid has no column "%s".', + $key)); + } + + return $this->columns[$key]; + } + + public function getRows() { + return $this->rows; + } + + private function drawRow(ArcanistGridRow $row) { + $columns = $this->getColumns(); + + $cells = $row->getCells(); + + $out = array(); + $widths = array(); + foreach ($columns as $column_key => $column) { + $display_width = $this->getDisplayWidth($column_key); + + $cell = idx($cells, $column_key); + if ($cell) { + $content = $cell->getContentDisplayLines(); + } else { + $content = array(''); + } + + foreach ($content as $line_key => $line) { + $line_width = phutil_utf8_console_strlen($line); + + if ($line_width === $display_width) { + continue; + } + + if ($line_width < $display_width) { + $line = $this->padContentLineToWidth( + $line, + $line_width, + $display_width, + $column->getAlignment()); + } else if ($line_width > $display_width) { + $line = $this->truncateContentLineToWidth( + $line, + $line_width, + $display_width, + $column->getAlignment()); + } + + $content[$line_key] = $line; + } + + $out[] = $content; + $widths[] = $display_width; + } + + return $this->drawRowLayout($out, $widths); + } + + private function drawRowLayout(array $raw_cells, array $display_widths) { + $line_count = 0; + foreach ($raw_cells as $key => $cells) { + $raw_cells[$key] = array_values($cells); + $line_count = max($line_count, count($cells)); + } + + $line_head = ''; + $cell_separator = ' '; + $line_tail = ''; + + $out = array(); + $cell_count = count($raw_cells); + for ($ii = 0; $ii < $line_count; $ii++) { + $line = array(); + for ($jj = 0; $jj < $cell_count; $jj++) { + if (isset($raw_cells[$jj][$ii])) { + $raw_line = $raw_cells[$jj][$ii]; + } else { + $display_width = $display_widths[$jj]; + $raw_line = str_repeat(' ', $display_width); + } + $line[] = $raw_line; + } + + $line = array( + $line_head, + phutil_glue($line, $cell_separator), + $line_tail, + ); + + $out[] = $line; + } + + $out = phutil_glue($out, tsprintf("\n")); + + return $out; + } + + private function padContentLineToWidth( + $line, + $src_width, + $dst_width, + $alignment) { + + $delta = ($dst_width - $src_width); + + switch ($alignment) { + case ArcanistGridColumn::ALIGNMENT_LEFT: + $head = null; + $tail = str_repeat(' ', $delta); + break; + case ArcanistGridColumn::ALIGNMENT_CENTER: + $head_delta = (int)floor($delta / 2); + $tail_delta = (int)ceil($delta / 2); + + if ($head_delta) { + $head = str_repeat(' ', $head_delta); + } else { + $head = null; + } + + if ($tail_delta) { + $tail = str_repeat(' ', $tail_delta); + } else { + $tail = null; + } + break; + case ArcanistGridColumn::ALIGNMENT_RIGHT: + $head = str_repeat(' ', $delta); + $tail = null; + break; + default: + throw new Exception( + pht( + 'Unknown column alignment "%s".', + $alignment)); + } + + $result = array(); + + if ($head !== null) { + $result[] = $head; + } + + $result[] = $line; + + if ($tail !== null) { + $result[] = $tail; + } + + return $result; + } + + private function truncateContentLineToWidth( + $line, + $src_width, + $dst_width, + $alignment) { + + $line = phutil_string_cast($line); + + return id(new PhutilUTF8StringTruncator()) + ->setMaximumGlyphs($dst_width) + ->truncateString($line); + } + +} diff --git a/src/console/view/PhutilConsoleTable.php b/src/console/view/PhutilConsoleTable.php index 7c29f991..392ff813 100644 --- a/src/console/view/PhutilConsoleTable.php +++ b/src/console/view/PhutilConsoleTable.php @@ -1,296 +1,306 @@ addColumn('id', array('title' => 'ID', 'align' => 'right')) * ->addColumn('name', array('title' => 'Username', 'align' => 'center')) * ->addColumn('email', array('title' => 'Email Address')) * * ->addRow(array( * 'id' => 12345, * 'name' => 'alicoln', * 'email' => 'abraham@lincoln.com', * )) * ->addRow(array( * 'id' => 99999999, * 'name' => 'jbloggs', * 'email' => 'joe@bloggs.com', * )) * * ->setBorders(true) * ->draw(); */ final class PhutilConsoleTable extends PhutilConsoleView { private $columns = array(); private $data = array(); private $widths = array(); private $borders = false; private $padding = 1; private $showHeader = true; const ALIGN_LEFT = 'left'; const ALIGN_CENTER = 'center'; const ALIGN_RIGHT = 'right'; /* -( Configuration )------------------------------------------------------ */ public function setBorders($borders) { $this->borders = $borders; return $this; } public function setPadding($padding) { $this->padding = $padding; return $this; } public function setShowHeader($show_header) { $this->showHeader = $show_header; return $this; } /* -( Data )--------------------------------------------------------------- */ - public function addColumn($key, array $column) { + public function addColumn($key, array $column = array()) { PhutilTypeSpec::checkMap($column, array( - 'title' => 'string', + 'title' => 'optional string', 'align' => 'optional string', )); $this->columns[$key] = $column; return $this; } public function addColumns(array $columns) { foreach ($columns as $key => $column) { $this->addColumn($key, $column); } return $this; } public function addRow(array $data) { $this->data[] = $data; foreach ($data as $key => $value) { $this->widths[$key] = max( idx($this->widths, $key, 0), phutil_utf8_console_strlen($value)); } return $this; } + public function drawRows(array $rows) { + $this->data = array(); + $this->widths = array(); + + foreach ($rows as $row) { + $this->addRow($row); + } + + return $this->draw(); + } /* -( Drawing )------------------------------------------------------------ */ protected function drawView() { return $this->drawLines( array_merge( $this->getHeader(), $this->getBody(), $this->getFooter())); } private function getHeader() { $output = array(); if ($this->borders) { $output[] = $this->formatSeparator('='); } if (!$this->showHeader) { return $output; } $columns = array(); foreach ($this->columns as $key => $column) { $title = tsprintf('**%s**', $column['title']); if ($this->shouldAddSpacing($key, $column)) { $title = $this->alignString( $title, $this->getWidth($key), idx($column, 'align', self::ALIGN_LEFT)); } $columns[] = $title; } $output[] = $this->formatRow($columns); if ($this->borders) { $output[] = $this->formatSeparator('='); } return $output; } private function getBody() { $output = array(); foreach ($this->data as $data) { $columns = array(); foreach ($this->columns as $key => $column) { if (!$this->shouldAddSpacing($key, $column)) { $columns[] = idx($data, $key, ''); } else { $columns[] = $this->alignString( idx($data, $key, ''), $this->getWidth($key), idx($column, 'align', self::ALIGN_LEFT)); } } $output[] = $this->formatRow($columns); } return $output; } private function getFooter() { $output = array(); if ($this->borders) { $columns = array(); foreach ($this->getColumns() as $column) { $columns[] = str_repeat('=', $this->getWidth($column)); } $output[] = array( '+', $this->implode('+', $columns), '+', ); } return $output; } /* -( Internals )---------------------------------------------------------- */ /** * Returns if the specified column should have spacing added. * * @return bool */ private function shouldAddSpacing($key, $column) { if (!$this->borders) { if (last_key($this->columns) === $key) { if (idx($column, 'align', self::ALIGN_LEFT) === self::ALIGN_LEFT) { // Don't add extra spaces to this column since it's the last column, // left aligned, and we're not showing borders. This prevents // unnecessary empty lines from appearing when the extra spaces // wrap around the terminal. return false; } } } return true; } /** * Returns the column IDs. * * @return list */ protected function getColumns() { return array_keys($this->columns); } /** * Get the width of a specific column, including padding. * * @param string * @return int */ protected function getWidth($key) { $width = max( idx($this->widths, $key), phutil_utf8_console_strlen( idx(idx($this->columns, $key, array()), 'title', ''))); return $width + 2 * $this->padding; } protected function alignString($string, $width, $align) { $num_padding = $width - (2 * $this->padding) - phutil_utf8_console_strlen($string); switch ($align) { case self::ALIGN_LEFT: $num_left_padding = 0; $num_right_padding = $num_padding; break; case self::ALIGN_CENTER: $num_left_padding = (int)($num_padding / 2); $num_right_padding = $num_padding - $num_left_padding; break; case self::ALIGN_RIGHT: $num_left_padding = $num_padding; $num_right_padding = 0; break; } $left_padding = str_repeat(' ', $num_left_padding); $right_padding = str_repeat(' ', $num_right_padding); return array( $left_padding, $string, $right_padding, ); } /** * Format cells into an entire row. * * @param list * @return string */ protected function formatRow(array $columns) { $padding = str_repeat(' ', $this->padding); if ($this->borders) { $separator = $padding.'|'.$padding; return array( '|'.$padding, $this->implode($separator, $columns), $padding.'|', ); } else { return $this->implode($padding, $columns); } } protected function formatSeparator($string) { $columns = array(); if ($this->borders) { $separator = '+'; } else { $separator = ''; } foreach ($this->getColumns() as $column) { $columns[] = str_repeat($string, $this->getWidth($column)); } return array( $separator, $this->implode($separator, $columns), $separator, ); } } diff --git a/src/engine/ArcanistWorkflowEngine.php b/src/engine/ArcanistWorkflowEngine.php new file mode 100644 index 00000000..726ba65b --- /dev/null +++ b/src/engine/ArcanistWorkflowEngine.php @@ -0,0 +1,48 @@ +viewer = $viewer; + return $this; + } + + final public function getViewer() { + return $this->viewer; + } + + final public function setWorkflow(ArcanistWorkflow $workflow) { + $this->workflow = $workflow; + return $this; + } + + final public function getWorkflow() { + return $this->workflow; + } + + final public function setRepositoryAPI( + ArcanistRepositoryAPI $repository_api) { + $this->repositoryAPI = $repository_api; + return $this; + } + + final public function getRepositoryAPI() { + return $this->repositoryAPI; + } + + final public function setLogEngine(ArcanistLogEngine $log_engine) { + $this->logEngine = $log_engine; + return $this; + } + + final public function getLogEngine() { + return $this->logEngine; + } + +} diff --git a/src/exception/ArcanistConduitAuthenticationException.php b/src/exception/ArcanistConduitAuthenticationException.php new file mode 100644 index 00000000..72345453 --- /dev/null +++ b/src/exception/ArcanistConduitAuthenticationException.php @@ -0,0 +1,27 @@ +title = $title; + return $this; + } + + public function getTitle() { + return $this->title; + } + + public function setBody($body) { + $this->body = $body; + return $this; + } + + public function getBody() { + return $this->body; + } + +} diff --git a/src/future/Future.php b/src/future/Future.php index 3c371bc4..8078aacc 100644 --- a/src/future/Future.php +++ b/src/future/Future.php @@ -1,253 +1,257 @@ resolve()" is no longer '. 'supported. Update the caller so it no longer passes a '. 'timeout.')); } if (!$this->hasResult() && !$this->hasException()) { $graph = new FutureIterator(array($this)); $graph->resolveAll(); } if ($this->hasException()) { throw $this->getException(); } return $this->getResult(); } final public function startFuture() { if ($this->hasStarted) { throw new Exception( pht( 'Future has already started; futures can not start more '. 'than once.')); } $this->hasStarted = true; $this->startServiceProfiler(); - $this->isReady(); + $this->updateFuture(); } final public function updateFuture() { if ($this->hasException()) { return; } if ($this->hasResult()) { return; } try { $this->isReady(); } catch (Exception $ex) { $this->setException($ex); } catch (Throwable $ex) { $this->setException($ex); } } final public function endFuture() { if (!$this->hasException() && !$this->hasResult()) { throw new Exception( pht( 'Trying to end a future which has no exception and no result. '. 'Futures must resolve before they can be ended.')); } if ($this->hasEnded) { throw new Exception( pht( 'Future has already ended; futures can not end more '. 'than once.')); } $this->hasEnded = true; $this->endServiceProfiler(); } private function startServiceProfiler() { // NOTE: This is a soft dependency so that we don't need to build the // ServiceProfiler into the Phage agent. Normally, this class is always // available. if (!class_exists('PhutilServiceProfiler')) { return; } $params = $this->getServiceProfilerStartParameters(); + if ($params === null) { + return; + } + $profiler = PhutilServiceProfiler::getInstance(); $call_id = $profiler->beginServiceCall($params); $this->serviceProfilerCallID = $call_id; } private function endServiceProfiler() { $call_id = $this->serviceProfilerCallID; if ($call_id === null) { return; } $params = $this->getServiceProfilerResultParameters(); $profiler = PhutilServiceProfiler::getInstance(); $profiler->endServiceCall($call_id, $params); } protected function getServiceProfilerStartParameters() { return array(); } protected function getServiceProfilerResultParameters() { return array(); } /** * Retrieve a list of sockets which we can wait to become readable while * a future is resolving. If your future has sockets which can be * `select()`ed, return them here (or in @{method:getWriteSockets}) to make * the resolve loop do a `select()`. If you do not return sockets in either * case, you'll get a busy wait. * * @return list A list of sockets which we expect to become readable. */ public function getReadSockets() { return array(); } /** * Retrieve a list of sockets which we can wait to become writable while a * future is resolving. See @{method:getReadSockets}. * * @return list A list of sockets which we expect to become writable. */ public function getWriteSockets() { return array(); } /** * Default amount of time to wait on stream select for this future. Normally * 1 second is fine, but if the future has a timeout sooner than that it * should return the amount of time left before the timeout. */ public function getDefaultWait() { return 1; } public function start() { $this->isReady(); return $this; } /** * Retrieve the final result of the future. * * @return wild Final resolution of this future. */ final protected function getResult() { if (!$this->hasResult()) { throw new Exception( pht( 'Future has not yet resolved. Resolve futures before retrieving '. 'results.')); } return $this->result; } final protected function setResult($result) { if ($this->hasResult()) { throw new Exception( pht( 'Future has already resolved. Futures may not resolve more than '. 'once.')); } $this->hasResult = true; $this->result = $result; return $this; } final public function hasResult() { return $this->hasResult; } final private function setException($exception) { // NOTE: The parameter may be an Exception or a Throwable. $this->exception = $exception; return $this; } final private function getException() { return $this->exception; } final public function hasException() { return ($this->exception !== null); } final public function setFutureKey($key) { if ($this->futureKey !== null) { throw new Exception( pht( 'Future already has a key ("%s") assigned.', $key)); } $this->futureKey = $key; return $this; } final public function getFutureKey() { if ($this->futureKey === null) { $this->futureKey = sprintf('Future/%d', self::$nextKey++); } return $this->futureKey; } } diff --git a/src/future/FutureProxy.php b/src/future/FutureProxy.php index 77c8c5bb..d7e00cd6 100644 --- a/src/future/FutureProxy.php +++ b/src/future/FutureProxy.php @@ -1,76 +1,82 @@ setProxiedFuture($proxied); } } public function setProxiedFuture(Future $proxied) { $this->proxied = $proxied; return $this; } protected function getProxiedFuture() { if (!$this->proxied) { throw new Exception(pht('The proxied future has not been provided yet.')); } return $this->proxied; } public function isReady() { - if ($this->hasResult()) { + if ($this->hasResult() || $this->hasException()) { return true; } $proxied = $this->getProxiedFuture(); + $proxied->updateFuture(); + + if ($proxied->hasResult() || $proxied->hasException()) { + try { + $result = $proxied->resolve(); + $result = $this->didReceiveResult($result); + } catch (Exception $ex) { + $result = $this->didReceiveException($ex); + } catch (Throwable $ex) { + $result = $this->didReceiveException($ex); + } - $is_ready = $proxied->isReady(); - - if ($proxied->hasResult()) { - $result = $proxied->getResult(); - $result = $this->didReceiveResult($result); $this->setResult($result); - } - return $is_ready; - } + return true; + } - public function resolve() { - $this->getProxiedFuture()->resolve(); - $this->isReady(); - return $this->getResult(); + return false; } public function getReadSockets() { return $this->getProxiedFuture()->getReadSockets(); } public function getWriteSockets() { return $this->getProxiedFuture()->getWriteSockets(); } public function start() { $this->getProxiedFuture()->start(); return $this; } protected function getServiceProfilerStartParameters() { return $this->getProxiedFuture()->getServiceProfilerStartParameters(); } protected function getServiceProfilerResultParameters() { return $this->getProxiedFuture()->getServiceProfilerResultParameters(); } abstract protected function didReceiveResult($result); + protected function didReceiveException($exception) { + throw $exception; + } + } diff --git a/src/future/exec/PhutilExecPassthru.php b/src/future/exec/PhutilExecPassthru.php index 6dec0f2a..6501bb8c 100644 --- a/src/future/exec/PhutilExecPassthru.php +++ b/src/future/exec/PhutilExecPassthru.php @@ -1,129 +1,148 @@ execute(); * * You can set the current working directory for the command with * @{method:setCWD}, and set the environment with @{method:setEnv}. * * @task command Executing Passthru Commands */ final class PhutilExecPassthru extends PhutilExecutableFuture { + private $stdinData; + + public function write($data) { + $this->stdinData = $data; + return $this; + } /* -( Executing Passthru Commands )---------------------------------------- */ /** * Execute this command. * * @return int Error code returned by the subprocess. * * @task command */ public function execute() { $command = $this->getCommand(); - $spec = array(STDIN, STDOUT, STDERR); + $is_write = ($this->stdinData !== null); + + if ($is_write) { + $stdin_spec = array('pipe', 'r'); + } else { + $stdin_spec = STDIN; + } + + $spec = array($stdin_spec, STDOUT, STDERR); $pipes = array(); $unmasked_command = $command->getUnmaskedString(); if ($this->hasEnv()) { $env = $this->getEnv(); } else { $env = null; } $cwd = $this->getCWD(); $options = array(); if (phutil_is_windows()) { // Without 'bypass_shell', things like launching vim don't work properly, // and we can't execute commands with spaces in them, and all commands // invoked from git bash fail horridly, and everything is a mess in // general. $options['bypass_shell'] = true; } $trap = new PhutilErrorTrap(); $proc = @proc_open( $unmasked_command, $spec, $pipes, $cwd, $env, $options); $errors = $trap->getErrorsAsString(); $trap->destroy(); if (!is_resource($proc)) { // See T13504. When "proc_open()" is given a command with a binary // that isn't available on Windows with "bypass_shell", the function // fails entirely. if (phutil_is_windows()) { $err = 1; } else { throw new Exception( pht( 'Failed to passthru %s: %s', 'proc_open()', $errors)); } } else { + if ($is_write) { + fwrite($pipes[0], $this->stdinData); + fclose($pipes[0]); + } + $err = proc_close($proc); } return $err; } /* -( Future )------------------------------------------------------------- */ public function isReady() { // This isn't really a future because it executes synchronously and has // full control of the console. We're just implementing the interfaces to // make it easier to share code with ExecFuture. if (!$this->hasResult()) { $result = $this->execute(); $this->setResult($result); } return true; } protected function getServiceProfilerStartParameters() { return array( 'type' => 'exec', 'subtype' => 'passthru', 'command' => phutil_string_cast($this->getCommand()), ); } protected function getServiceProfilerResultParameters() { if ($this->hasResult()) { $err = $this->getResult(); } else { $err = null; } return array( 'err' => $err, ); } } diff --git a/src/hardpoint/ArcanistHardpointEngine.php b/src/hardpoint/ArcanistHardpointEngine.php index ad15bcd5..b77341cd 100644 --- a/src/hardpoint/ArcanistHardpointEngine.php +++ b/src/hardpoint/ArcanistHardpointEngine.php @@ -1,237 +1,238 @@ queries = $queries; $this->queryHardpointMap = null; return $this; } private function getQueriesForHardpoint($hardpoint) { if ($this->queryHardpointMap === null) { $map = array(); foreach ($this->queries as $query_key => $query) { $query->setHardpointEngine($this); $hardpoints = $query->getHardpoints(); foreach ($hardpoints as $query_hardpoint) { $map[$query_hardpoint][$query_key] = $query; } } $this->queryHardpointMap = $map; } return idx($this->queryHardpointMap, $hardpoint, array()); } public function requestHardpoints(array $objects, array $requests) { assert_instances_of($objects, 'ArcanistHardpointObject'); $results = array(); foreach ($requests as $request) { $request = ArcanistHardpointRequest::newFromSpecification($request) ->setEngine($this) ->setObjects($objects); $this->requests[] = $request; $this->startRequest($request); $results[] = $request; } return ArcanistHardpointRequestList::newFromRequests($results); } private function startRequest(ArcanistHardpointRequest $request) { $objects = $request->getObjects(); $hardpoint = $request->getHardpoint(); $queries = $this->getQueriesForHardpoint($hardpoint); $load = array(); foreach ($objects as $object_key => $object) { if (!$object->hasHardpoint($hardpoint)) { throw new Exception( pht( 'Object (with key "%s", of type "%s") has no hardpoint "%s". '. 'Hardpoints on this object are: %s.', $object_key, phutil_describe_type($object), $hardpoint, $object->getHardpointList()->getHardpointListForDisplay())); } // If the object already has the hardpoint attached, we don't have to // do anything. Throw the object away. if ($object->hasAttachedHardpoint($hardpoint)) { unset($objects[$object_key]); continue; } $any_query = false; foreach ($queries as $query_key => $query) { if (!$query->canLoadObject($object)) { continue; } $any_query = true; $load[$query_key][$object_key] = $object; } if (!$any_query) { throw new Exception( pht( 'No query exists which can load hardpoint "%s" for object '. '(with key "%s" of type "%s").', $hardpoint, $object_key, phutil_describe_type($object))); } } if (!$objects) { return; } $any_object = head($objects); $list = $object->getHardpointList(); $definition = $list->getHardpointDefinition($any_object, $hardpoint); $is_vector = ($definition->isVectorHardpoint()); if ($is_vector) { foreach ($objects as $object) { $object->attachHardpoint($hardpoint, array()); } } $request->setHardpointDefinition($definition); foreach ($load as $query_key => $object_map) { $query = id(clone $queries[$query_key]); $task = $request->newTask() ->setQuery($query) ->setObjects($object_map); } } public function waitForRequests(array $wait_requests) { foreach ($wait_requests as $wait_key => $wait_request) { if ($wait_request->getEngine() !== $this) { throw new Exception( pht( 'Attempting to wait on a hardpoint request (with index "%s", for '. 'hardpoint "%s") that is part of a different engine.', $wait_key, $wait_request->getHardpoint())); } } while (true) { $any_progress = false; foreach ($this->requests as $req_key => $request) { $did_update = $request->updateTasks(); if ($did_update) { $any_progress = true; } } // If we made progress by directly executing requests, continue // excuting them until we stop making progress. We want to queue all // reachable futures before we wait on futures. if ($any_progress) { continue; } foreach ($this->requests as $request_key => $request) { if ($request->isComplete()) { unset($this->requests[$request_key]); } } if (!$this->requests) { break; } $resolved_key = $this->updateFutures(); if ($resolved_key === null) { throw new Exception( pht( 'Hardpoint engine can not resolve: no request made progress '. 'during the last update cycle and there are no futures '. 'awaiting resolution.')); } } } private function updateFutures() { $iterator = $this->futureIterator; $is_rewind = false; $wait_futures = $this->waitFutures; if ($wait_futures) { if (!$this->futureIterator) { - $iterator = new FutureIterator(array()); + $iterator = id(new FutureIterator(array())) + ->limit(32); foreach ($wait_futures as $wait_future) { $iterator->addFuture($wait_future); } $is_rewind = true; $this->futureIterator = $iterator; } else { foreach ($wait_futures as $wait_future) { $iterator->addFuture($wait_future); } } $this->waitFutures = array(); } $resolved_key = null; if ($iterator) { if ($is_rewind) { $iterator->rewind(); } else { $iterator->next(); } if ($iterator->valid()) { $resolved_key = $iterator->key(); } else { $this->futureIterator = null; } } return $resolved_key; } public function addFutures(array $futures) { assert_instances_of($futures, 'Future'); $this->waitFutures += mpull($futures, null, 'getFutureKey'); // TODO: We could reasonably add these futures to the iterator // immediately and start them here, instead of waiting. return $this; } } diff --git a/src/hardpoint/ArcanistHardpointObject.php b/src/hardpoint/ArcanistHardpointObject.php index 93a3bac9..b2afa8cd 100644 --- a/src/hardpoint/ArcanistHardpointObject.php +++ b/src/hardpoint/ArcanistHardpointObject.php @@ -1,99 +1,105 @@ hardpointList) { + $this->hardpointList = clone $this->hardpointList; + } + } + final public function getHardpoint($hardpoint) { return $this->getHardpointList()->getHardpoint( $this, $hardpoint); } final public function attachHardpoint($hardpoint, $value) { $this->getHardpointList()->attachHardpoint( $this, $hardpoint, $value); return $this; } final public function mergeHardpoint($hardpoint, $value) { $hardpoint_list = $this->getHardpointList(); $hardpoint_def = $hardpoint_list->getHardpointDefinition( $this, $hardpoint); $old_value = $this->getHardpoint($hardpoint); $new_value = $hardpoint_def->mergeHardpointValues( $this, $old_value, $value); $hardpoint_list->setHardpointValue( $this, $hardpoint, $new_value); return $this; } final public function hasHardpoint($hardpoint) { return $this->getHardpointList()->hasHardpoint($this, $hardpoint); } final public function hasAttachedHardpoint($hardpoint) { return $this->getHardpointList()->hasAttachedHardpoint( $this, $hardpoint); } protected function newHardpoints() { return array(); } final protected function newHardpoint($hardpoint_key) { return id(new ArcanistScalarHardpoint()) ->setHardpointKey($hardpoint_key); } final protected function newVectorHardpoint($hardpoint_key) { return id(new ArcanistVectorHardpoint()) ->setHardpointKey($hardpoint_key); } final protected function newTemplateHardpoint( $hardpoint_key, ArcanistHardpoint $template) { return id(clone $template) ->setHardpointKey($hardpoint_key); } final public function getHardpointList() { if ($this->hardpointList === null) { $list = $this->newHardpointList(); // TODO: Cache the hardpoint list with the class name as a key? If so, // it needs to be purged when the request cache is purged. $hardpoints = $this->newHardpoints(); // TODO: Verify the hardpoints list is structured properly. $list->setHardpoints($hardpoints); $this->hardpointList = $list; } return $this->hardpointList; } private function newHardpointList() { return new ArcanistHardpointList(); } } diff --git a/src/inspector/ArcanistRefInspector.php b/src/inspector/ArcanistRefInspector.php index 8115b12d..2afb44b5 100644 --- a/src/inspector/ArcanistRefInspector.php +++ b/src/inspector/ArcanistRefInspector.php @@ -1,29 +1,40 @@ workflow = $workflow; + return $this; + } + + final public function getWorkflow() { + return $this->workflow; + } + abstract public function getInspectFunctionName(); abstract public function newInspectRef(array $argv); protected function newInspectors() { return array($this); } final public static function getAllInspectors() { $base_inspectors = id(new PhutilClassMapQuery()) ->setAncestorClass(__CLASS__) ->execute(); $results = array(); foreach ($base_inspectors as $base_inspector) { foreach ($base_inspector->newInspectors() as $inspector) { $results[] = $inspector; } } return mpull($results, null, 'getInspectFunctionName'); } } diff --git a/src/land/ArcanistGitLandEngine.php b/src/land/ArcanistGitLandEngine.php deleted file mode 100644 index c0a7a870..00000000 --- a/src/land/ArcanistGitLandEngine.php +++ /dev/null @@ -1,913 +0,0 @@ -isGitPerforce = $is_git_perforce; - return $this; - } - - private function getIsGitPerforce() { - return $this->isGitPerforce; - } - - public function parseArguments() { - $api = $this->getRepositoryAPI(); - - $onto = $this->getEngineOnto(); - $this->setTargetOnto($onto); - - $remote = $this->getEngineRemote(); - - $is_pushable = $api->isPushableRemote($remote); - $is_perforce = $api->isPerforceRemote($remote); - - if (!$is_pushable && !$is_perforce) { - throw new PhutilArgumentUsageException( - pht( - 'No pushable remote "%s" exists. Use the "--remote" flag to choose '. - 'a valid, pushable remote to land changes onto.', - $remote)); - } - - if ($is_perforce) { - $this->setIsGitPerforce(true); - $this->writeWarn( - pht('P4 MODE'), - pht( - 'Operating in Git/Perforce mode after selecting a Perforce '. - 'remote.')); - - if (!$this->getShouldSquash()) { - throw new PhutilArgumentUsageException( - pht( - 'Perforce mode does not support the "merge" land strategy. '. - 'Use the "squash" land strategy when landing to a Perforce '. - 'remote (you can use "--squash" to select this strategy).')); - } - } - - $this->setTargetRemote($remote); - } - - public function execute() { - $this->verifySourceAndTargetExist(); - $this->fetchTarget(); - - $this->printLandingCommits(); - - if ($this->getShouldPreview()) { - $this->writeInfo( - pht('PREVIEW'), - pht('Completed preview of operation.')); - return; - } - - $this->saveLocalState(); - - try { - $this->identifyRevision(); - $this->updateWorkingCopy(); - - if ($this->getShouldHold()) { - $this->didHoldChanges(); - } else { - $this->pushChange(); - $this->reconcileLocalState(); - - $api = $this->getRepositoryAPI(); - $api->execxLocal('submodule update --init --recursive'); - - if ($this->getShouldKeep()) { - echo tsprintf( - "%s\n", - pht('Keeping local branch.')); - } else { - $this->destroyLocalBranch(); - } - - $this->writeOkay( - pht('DONE'), - pht('Landed changes.')); - } - - $this->restoreWhenDestroyed = false; - } catch (Exception $ex) { - $this->restoreLocalState(); - throw $ex; - } - } - - public function __destruct() { - if ($this->restoreWhenDestroyed) { - $this->writeWarn( - pht('INTERRUPTED!'), - pht('Restoring working copy to its original state.')); - - $this->restoreLocalState(); - } - } - - protected function getLandingCommits() { - $api = $this->getRepositoryAPI(); - - list($out) = $api->execxLocal( - 'log --oneline %s..%s --', - $this->getTargetFullRef(), - $this->sourceCommit); - - $out = trim($out); - - if (!strlen($out)) { - return array(); - } else { - return phutil_split_lines($out, false); - } - } - - private function identifyRevision() { - $api = $this->getRepositoryAPI(); - $api->execxLocal('checkout %s --', $this->getSourceRef()); - call_user_func($this->getBuildMessageCallback(), $this); - } - - private function verifySourceAndTargetExist() { - $api = $this->getRepositoryAPI(); - - list($err) = $api->execManualLocal( - 'rev-parse --verify %s', - $this->getTargetFullRef()); - - if ($err) { - $this->writeWarn( - pht('TARGET'), - pht( - 'No local ref exists for branch "%s" in remote "%s", attempting '. - 'fetch...', - $this->getTargetOnto(), - $this->getTargetRemote())); - - $api->execManualLocal( - 'fetch %s %s --', - $this->getTargetRemote(), - $this->getTargetOnto()); - - list($err) = $api->execManualLocal( - 'rev-parse --verify %s', - $this->getTargetFullRef()); - if ($err) { - throw new Exception( - pht( - 'Branch "%s" does not exist in remote "%s".', - $this->getTargetOnto(), - $this->getTargetRemote())); - } - - $this->writeInfo( - pht('FETCHED'), - pht( - 'Fetched branch "%s" from remote "%s".', - $this->getTargetOnto(), - $this->getTargetRemote())); - } - - list($err, $stdout) = $api->execManualLocal( - 'rev-parse --verify %s', - $this->getSourceRef()); - - if ($err) { - throw new Exception( - pht( - 'Branch "%s" does not exist in the local working copy.', - $this->getSourceRef())); - } - - $this->sourceCommit = trim($stdout); - } - - private function fetchTarget() { - $api = $this->getRepositoryAPI(); - - $ref = $this->getTargetFullRef(); - - // NOTE: Although this output isn't hugely useful, we need to passthru - // instead of using a subprocess here because `git fetch` may prompt the - // user to enter a password if they're fetching over HTTP with basic - // authentication. See T10314. - - if ($this->getIsGitPerforce()) { - $this->writeInfo( - pht('P4 SYNC'), - pht('Synchronizing "%s" from Perforce...', $ref)); - - $sync_ref = sprintf( - 'refs/remotes/%s/%s', - $this->getTargetRemote(), - $this->getTargetOnto()); - - $err = $api->execPassthru( - 'p4 sync --silent --branch %R --', - $sync_ref); - - if ($err) { - throw new ArcanistUsageException( - pht( - 'Perforce sync failed! Fix the error and run "arc land" again.')); - } - } else { - $this->writeInfo( - pht('FETCH'), - pht('Fetching "%s"...', $ref)); - - $err = $api->execPassthru( - 'fetch --quiet -- %s %s', - $this->getTargetRemote(), - $this->getTargetOnto()); - - if ($err) { - throw new ArcanistUsageException( - pht( - 'Fetch failed! Fix the error and run "arc land" again.')); - } - } - } - - private function updateWorkingCopy() { - $api = $this->getRepositoryAPI(); - $source = $this->sourceCommit; - - $api->execxLocal( - 'checkout %s --', - $this->getTargetFullRef()); - - list($original_author, $original_date) = $this->getAuthorAndDate($source); - - try { - if ($this->getShouldSquash()) { - // NOTE: We're explicitly specifying "--ff" to override the presence - // of "merge.ff" options in user configuration. - - $api->execxLocal( - 'merge --no-stat --no-commit --ff --squash -- %s', - $source); - } else { - $api->execxLocal( - 'merge --no-stat --no-commit --no-ff -- %s', - $source); - } - } catch (Exception $ex) { - $api->execManualLocal('merge --abort'); - $api->execManualLocal('reset --hard HEAD --'); - - throw new Exception( - pht( - 'Local "%s" does not merge cleanly into "%s". Merge or rebase '. - 'local changes so they can merge cleanly.', - $this->getSourceRef(), - $this->getTargetFullRef())); - } - - // TODO: This could probably be cleaner by asking the API a question - // about working copy status instead of running a raw diff command. See - // discussion in T11435. - list($changes) = $api->execxLocal('diff --no-ext-diff HEAD --'); - $changes = trim($changes); - if (!strlen($changes)) { - throw new Exception( - pht( - 'Merging local "%s" into "%s" produces an empty diff. '. - 'This usually means these changes have already landed.', - $this->getSourceRef(), - $this->getTargetFullRef())); - } - - $api->execxLocal( - 'commit --author %s --date %s -F %s --', - $original_author, - $original_date, - $this->getCommitMessageFile()); - - $this->getWorkflow()->didCommitMerge(); - - list($stdout) = $api->execxLocal( - 'rev-parse --verify %s', - 'HEAD'); - $this->mergedRef = trim($stdout); - } - - private function pushChange() { - $api = $this->getRepositoryAPI(); - - if ($this->getIsGitPerforce()) { - $this->writeInfo( - pht('SUBMITTING'), - pht('Submitting changes to "%s".', $this->getTargetFullRef())); - - $config_argv = array(); - - // Skip the "git p4 submit" interactive editor workflow. We expect - // the commit message that "arc land" has built to be satisfactory. - $config_argv[] = '-c'; - $config_argv[] = 'git-p4.skipSubmitEdit=true'; - - // Skip the "git p4 submit" confirmation prompt if the user does not edit - // the submit message. - $config_argv[] = '-c'; - $config_argv[] = 'git-p4.skipSubmitEditCheck=true'; - - $flags_argv = array(); - - // Disable implicit "git p4 rebase" as part of submit. We're allowing - // the implicit "git p4 sync" to go through since this puts us in a - // state which is generally similar to the state after "git push", with - // updated remotes. - - // We could do a manual "git p4 sync" with a more narrow "--branch" - // instead, but it's not clear that this is beneficial. - $flags_argv[] = '--disable-rebase'; - - // Detect moves and submit them to Perforce as move operations. - $flags_argv[] = '-M'; - - // If we run into a conflict, abort the operation. We expect users to - // fix conflicts and run "arc land" again. - $flags_argv[] = '--conflict=quit'; - - $err = $api->execPassthru( - '%LR p4 submit %LR --commit %R --', - $config_argv, - $flags_argv, - $this->mergedRef); - - if ($err) { - throw new ArcanistUsageException( - pht( - 'Submit failed! Fix the error and run "arc land" again.')); - } - } else { - $this->writeInfo( - pht('PUSHING'), - pht('Pushing changes to "%s".', $this->getTargetFullRef())); - - $err = $api->execPassthru( - 'push -- %s %s:%s', - $this->getTargetRemote(), - $this->mergedRef, - $this->getTargetOnto()); - - if ($err) { - throw new ArcanistUsageException( - pht( - 'Push failed! Fix the error and run "arc land" again.')); - } - } - } - - private function reconcileLocalState() { - $api = $this->getRepositoryAPI(); - - // Try to put the user into the best final state we can. This is very - // complicated because users are incredibly creative and their local - // branches may have the same names as branches in the remote but no - // relationship to them. - - if ($this->localRef != $this->getSourceRef()) { - // The user ran `arc land X` but was on a different branch, so just put - // them back wherever they were before. - $this->writeInfo( - pht('RESTORE'), - pht('Switching back to "%s".', $this->localRef)); - $this->restoreLocalState(); - return; - } - - // We're going to try to find a path to the upstream target branch. We - // try in two different ways: - // - // - follow the source branch directly along tracking branches until - // we reach the upstream; or - // - follow a local branch with the same name as the target branch until - // we reach the upstream. - - // First, get the path from whatever we landed to wherever it goes. - $local_branch = $this->getSourceRef(); - - $path = $api->getPathToUpstream($local_branch); - if ($path->getLength()) { - // We may want to discard the thing we landed from the path, if we're - // going to delete it. In this case, we don't want to update it or worry - // if it's dirty. - if ($this->getSourceRef() == $this->getTargetOnto()) { - // In this case, we've done something like land "master" onto itself, - // so we do want to update the actual branch. We're going to use the - // entire path. - } else { - // Otherwise, we're going to delete the branch at the end of the - // workflow, so throw it away the most-local branch that isn't long - // for this world. - $path->removeUpstream($local_branch); - - if (!$path->getLength()) { - // The local branch tracked upstream directly; however, it - // may not be the only one to do so. If there's a local - // branch of the same name that tracks the remote, try - // switching to that. - $local_branch = $this->getTargetOnto(); - list($err) = $api->execManualLocal( - 'rev-parse --verify %s', - $local_branch); - if (!$err) { - $path = $api->getPathToUpstream($local_branch); - } - if (!$path->isConnectedToRemote()) { - $this->writeInfo( - pht('UPDATE'), - pht( - 'Local branch "%s" directly tracks remote, staying on '. - 'detached HEAD.', - $local_branch)); - return; - } - } - - $local_branch = head($path->getLocalBranches()); - } - } else { - // The source branch has no upstream, so look for a local branch with - // the same name as the target branch. This corresponds to the common - // case where you have "master" and checkout local branches from it - // with "git checkout -b feature", then land onto "master". - - $local_branch = $this->getTargetOnto(); - - list($err) = $api->execManualLocal( - 'rev-parse --verify %s', - $local_branch); - if ($err) { - $this->writeInfo( - pht('UPDATE'), - pht( - 'Local branch "%s" does not exist, staying on detached HEAD.', - $local_branch)); - return; - } - - $path = $api->getPathToUpstream($local_branch); - } - - if ($path->getCycle()) { - $this->writeWarn( - pht('LOCAL CYCLE'), - pht( - 'Local branch "%s" tracks an upstream but following it leads to '. - 'a local cycle, staying on detached HEAD.', - $local_branch)); - return; - } - - $is_perforce = $this->getIsGitPerforce(); - - if ($is_perforce) { - // If we're in Perforce mode, we don't expect to have a meaningful - // path to the remote: the "p4" remote is not a real remote, and - // "git p4" commands do not configure branch upstreams to provide - // a path. - - // Just pretend the target branch is connected directly to the remote, - // since this is effectively the behavior of Perforce and appears to - // do the right thing. - $cascade_branches = array($local_branch); - } else { - if (!$path->isConnectedToRemote()) { - $this->writeInfo( - pht('UPDATE'), - pht( - 'Local branch "%s" is not connected to a remote, staying on '. - 'detached HEAD.', - $local_branch)); - return; - } - - $remote_remote = $path->getRemoteRemoteName(); - $remote_branch = $path->getRemoteBranchName(); - - $remote_actual = $remote_remote.'/'.$remote_branch; - $remote_expect = $this->getTargetFullRef(); - if ($remote_actual != $remote_expect) { - $this->writeInfo( - pht('UPDATE'), - pht( - 'Local branch "%s" is connected to a remote ("%s") other than '. - 'the target remote ("%s"), staying on detached HEAD.', - $local_branch, - $remote_actual, - $remote_expect)); - return; - } - - // If we get this far, we have a sequence of branches which ultimately - // connect to the remote. We're going to try to update them all in reverse - // order, from most-upstream to most-local. - - $cascade_branches = $path->getLocalBranches(); - $cascade_branches = array_reverse($cascade_branches); - } - - // First, check if any of them are ahead of the remote. - - $ahead_of_remote = array(); - foreach ($cascade_branches as $cascade_branch) { - list($stdout) = $api->execxLocal( - 'log %s..%s --', - $this->mergedRef, - $cascade_branch); - $stdout = trim($stdout); - - if (strlen($stdout)) { - $ahead_of_remote[$cascade_branch] = $cascade_branch; - } - } - - // We're going to handle the last branch (the thing we ultimately intend - // to check out) differently. It's OK if it's ahead of the remote, as long - // as we just landed it. - - $local_ahead = isset($ahead_of_remote[$local_branch]); - unset($ahead_of_remote[$local_branch]); - $land_self = ($this->getTargetOnto() === $this->getSourceRef()); - - // We aren't going to pull anything if anything upstream from us is ahead - // of the remote, or the local is ahead of the remote and we didn't land - // it onto itself. - $skip_pull = ($ahead_of_remote || ($local_ahead && !$land_self)); - - if ($skip_pull) { - $this->writeInfo( - pht('UPDATE'), - pht( - 'Local "%s" is ahead of remote "%s". Checking out "%s" but '. - 'not pulling changes.', - nonempty(head($ahead_of_remote), $local_branch), - $this->getTargetFullRef(), - $local_branch)); - - $this->writeInfo( - pht('CHECKOUT'), - pht( - 'Checking out "%s".', - $local_branch)); - - $api->execxLocal('checkout %s --', $local_branch); - - return; - } - - // If nothing upstream from our nearest branch is ahead of the remote, - // pull it all. - - $cascade_targets = array(); - if (!$ahead_of_remote) { - foreach ($cascade_branches as $cascade_branch) { - if ($local_ahead && ($local_branch == $cascade_branch)) { - continue; - } - $cascade_targets[] = $cascade_branch; - } - } - - if ($is_perforce) { - // In Perforce, we've already set the remote to the right state with an - // implicit "git p4 sync" during "git p4 submit", and "git pull" isn't a - // meaningful operation. We're going to skip this step and jump down to - // the "git reset --hard" below to get everything into the right state. - } else if ($cascade_targets) { - $this->writeInfo( - pht('UPDATE'), - pht( - 'Local "%s" tracks target remote "%s", checking out and '. - 'pulling changes.', - $local_branch, - $this->getTargetFullRef())); - - foreach ($cascade_targets as $cascade_branch) { - $this->writeInfo( - pht('PULL'), - pht( - 'Checking out and pulling "%s".', - $cascade_branch)); - - $api->execxLocal('checkout %s --', $cascade_branch); - $api->execxLocal( - 'pull %s %s --', - $this->getTargetRemote(), - $cascade_branch); - } - - if (!$local_ahead) { - return; - } - } - - // In this case, the user did something like land a branch onto itself, - // and the branch is tracking the correct remote. We're going to discard - // the local state and reset it to the state we just pushed. - - $this->writeInfo( - pht('RESET'), - pht( - 'Local "%s" landed into remote "%s", resetting local branch to '. - 'remote state.', - $this->getTargetOnto(), - $this->getTargetFullRef())); - - $api->execxLocal('checkout %s --', $local_branch); - $api->execxLocal('reset --hard %s --', $this->getTargetFullRef()); - - return; - } - - private function destroyLocalBranch() { - $api = $this->getRepositoryAPI(); - $source_ref = $this->getSourceRef(); - - if ($source_ref == $this->getTargetOnto()) { - // If we landed a branch into a branch with the same name, so don't - // destroy it. This prevents us from cleaning up "master" if you're - // landing master into itself. - return; - } - - // TODO: Maybe this should also recover the proper upstream? - - // See T10321. If we were not landing a branch, don't try to clean it up. - // This happens most often when landing from a detached HEAD. - $is_branch = $this->isBranch($source_ref); - if (!$is_branch) { - echo tsprintf( - "%s\n", - pht( - '(Source "%s" is not a branch, leaving working copy as-is.)', - $source_ref)); - return; - } - - $recovery_command = csprintf( - 'git checkout -b %R %R', - $source_ref, - $this->sourceCommit); - - echo tsprintf( - "%s\n", - pht('Cleaning up branch "%s"...', $source_ref)); - - echo tsprintf( - "%s\n", - pht('(Use `%s` if you want it back.)', $recovery_command)); - - $api->execxLocal('branch -D -- %s', $source_ref); - } - - /** - * Save the local working copy state so we can restore it later. - */ - private function saveLocalState() { - $api = $this->getRepositoryAPI(); - - $this->localCommit = $api->getWorkingCopyRevision(); - - list($ref) = $api->execxLocal('rev-parse --abbrev-ref HEAD'); - $ref = trim($ref); - if ($ref === 'HEAD') { - $ref = $this->localCommit; - } - - $this->localRef = $ref; - - $this->restoreWhenDestroyed = true; - } - - /** - * Restore the working copy to the state it was in before we started - * performing writes. - */ - private function restoreLocalState() { - $api = $this->getRepositoryAPI(); - - $api->execxLocal('checkout %s --', $this->localRef); - $api->execxLocal('reset --hard %s --', $this->localCommit); - $api->execxLocal('submodule update --init --recursive'); - - $this->restoreWhenDestroyed = false; - } - - private function getTargetFullRef() { - return $this->getTargetRemote().'/'.$this->getTargetOnto(); - } - - private function getAuthorAndDate($commit) { - $api = $this->getRepositoryAPI(); - - // TODO: This is working around Windows escaping problems, see T8298. - - list($info) = $api->execxLocal( - 'log -n1 --format=%C %s --', - '%aD%n%an%n%ae', - $commit); - - $info = trim($info); - list($date, $author, $email) = explode("\n", $info, 3); - - return array( - "$author <{$email}>", - $date, - ); - } - - private function didHoldChanges() { - if ($this->getIsGitPerforce()) { - $this->writeInfo( - pht('HOLD'), - pht( - 'Holding change locally, it has not been submitted.')); - - $push_command = csprintf( - '$ git p4 submit -M --commit %R --', - $this->mergedRef); - } else { - $this->writeInfo( - pht('HOLD'), - pht( - 'Holding change locally, it has not been pushed.')); - - $push_command = csprintf( - '$ git push -- %R %R:%R', - $this->getTargetRemote(), - $this->mergedRef, - $this->getTargetOnto()); - } - - $restore_command = csprintf( - '$ git checkout %R --', - $this->localRef); - - echo tsprintf( - "\n%s\n\n". - "%s\n\n". - " **%s**\n\n". - "%s\n\n". - " **%s**\n\n". - "%s\n", - pht( - 'This local working copy now contains the merged changes in a '. - 'detached state.'), - pht('You can push the changes manually with this command:'), - $push_command, - pht( - 'You can go back to how things were before you ran "arc land" with '. - 'this command:'), - $restore_command, - pht( - 'Local branches have not been changed, and are still in exactly the '. - 'same state as before.')); - } - - private function isBranch($ref) { - $api = $this->getRepositoryAPI(); - - list($err) = $api->execManualLocal( - 'show-ref --verify --quiet -- %R', - 'refs/heads/'.$ref); - - return !$err; - } - - private function getEngineOnto() { - $source_ref = $this->getSourceRef(); - - $onto = $this->getOntoArgument(); - if ($onto !== null) { - $this->writeInfo( - pht('TARGET'), - pht( - 'Landing onto "%s", selected with the "--onto" flag.', - $onto)); - return $onto; - } - - $api = $this->getRepositoryAPI(); - $path = $api->getPathToUpstream($source_ref); - - if ($path->getLength()) { - $cycle = $path->getCycle(); - if ($cycle) { - $this->writeWarn( - pht('LOCAL CYCLE'), - pht( - 'Local branch tracks an upstream, but following it leads to a '. - 'local cycle; ignoring branch upstream.')); - - echo tsprintf( - "\n %s\n\n", - implode(' -> ', $cycle)); - - } else { - if ($path->isConnectedToRemote()) { - $onto = $path->getRemoteBranchName(); - $this->writeInfo( - pht('TARGET'), - pht( - 'Landing onto "%s", selected by following tracking branches '. - 'upstream to the closest remote.', - $onto)); - return $onto; - } else { - $this->writeInfo( - pht('NO PATH TO UPSTREAM'), - pht( - 'Local branch tracks an upstream, but there is no path '. - 'to a remote; ignoring branch upstream.')); - } - } - } - - $workflow = $this->getWorkflow(); - - $config_key = 'arc.land.onto.default'; - $onto = $workflow->getConfigFromAnySource($config_key); - if ($onto !== null) { - $this->writeInfo( - pht('TARGET'), - pht( - 'Landing onto "%s", selected by "%s" configuration.', - $onto, - $config_key)); - return $onto; - } - - $onto = 'master'; - $this->writeInfo( - pht('TARGET'), - pht( - 'Landing onto "%s", the default target under git.', - $onto)); - - return $onto; - } - - private function getEngineRemote() { - $source_ref = $this->getSourceRef(); - - $remote = $this->getRemoteArgument(); - if ($remote !== null) { - $this->writeInfo( - pht('REMOTE'), - pht( - 'Using remote "%s", selected with the "--remote" flag.', - $remote)); - return $remote; - } - - $api = $this->getRepositoryAPI(); - $path = $api->getPathToUpstream($source_ref); - - $remote = $path->getRemoteRemoteName(); - if ($remote !== null) { - $this->writeInfo( - pht('REMOTE'), - pht( - 'Using remote "%s", selected by following tracking branches '. - 'upstream to the closest remote.', - $remote)); - return $remote; - } - - $remote = 'p4'; - if ($api->isPerforceRemote($remote)) { - $this->writeInfo( - pht('REMOTE'), - pht( - 'Using Perforce remote "%s". The existence of this remote implies '. - 'this working copy was synchronized from a Perforce repository.', - $remote)); - return $remote; - } - - $remote = 'origin'; - $this->writeInfo( - pht('REMOTE'), - pht( - 'Using remote "%s", the default remote under Git.', - $remote)); - - return $remote; - } - -} diff --git a/src/land/ArcanistLandCommit.php b/src/land/ArcanistLandCommit.php new file mode 100644 index 00000000..3d0d2125 --- /dev/null +++ b/src/land/ArcanistLandCommit.php @@ -0,0 +1,170 @@ +hash = $hash; + return $this; + } + + public function getHash() { + return $this->hash; + } + + public function setSummary($summary) { + $this->summary = $summary; + return $this; + } + + public function getSummary() { + return $this->summary; + } + + public function getDisplaySummary() { + if ($this->displaySummary === null) { + $this->displaySummary = id(new PhutilUTF8StringTruncator()) + ->setMaximumGlyphs(64) + ->truncateString($this->getSummary()); + } + return $this->displaySummary; + } + + public function setParents(array $parents) { + $this->parents = $parents; + return $this; + } + + public function getParents() { + return $this->parents; + } + + public function addDirectSymbol(ArcanistLandSymbol $symbol) { + $this->directSymbols[] = $symbol; + return $this; + } + + public function getDirectSymbols() { + return $this->directSymbols; + } + + public function addIndirectSymbol(ArcanistLandSymbol $symbol) { + $this->indirectSymbols[] = $symbol; + return $this; + } + + public function getIndirectSymbols() { + return $this->indirectSymbols; + } + + public function setExplicitRevisionref(ArcanistRevisionRef $ref) { + $this->explicitRevisionRef = $ref; + return $this; + } + + public function getExplicitRevisionref() { + return $this->explicitRevisionRef; + } + + public function setParentCommits(array $parent_commits) { + $this->parentCommits = $parent_commits; + return $this; + } + + public function getParentCommits() { + return $this->parentCommits; + } + + public function setIsHeadCommit($is_head_commit) { + $this->isHeadCommit = $is_head_commit; + return $this; + } + + public function getIsHeadCommit() { + return $this->isHeadCommit; + } + + public function setIsImplicitCommit($is_implicit_commit) { + $this->isImplicitCommit = $is_implicit_commit; + return $this; + } + + public function getIsImplicitCommit() { + return $this->isImplicitCommit; + } + + public function getAncestorRevisionPHIDs() { + $phids = array(); + + foreach ($this->getParentCommits() as $parent_commit) { + $phids += $parent_commit->getAncestorRevisionPHIDs(); + } + + $revision_ref = $this->getRevisionRef(); + if ($revision_ref) { + $phids[$revision_ref->getPHID()] = $revision_ref->getPHID(); + } + + return $phids; + } + + public function getRevisionRef() { + if ($this->revisionRef === false) { + $this->revisionRef = $this->newRevisionRef(); + } + + return $this->revisionRef; + } + + private function newRevisionRef() { + $revision_ref = $this->getExplicitRevisionRef(); + if ($revision_ref) { + return $revision_ref; + } + + $parent_refs = array(); + foreach ($this->getParentCommits() as $parent_commit) { + $parent_ref = $parent_commit->getRevisionRef(); + if ($parent_ref) { + $parent_refs[$parent_ref->getPHID()] = $parent_ref; + } + } + + if (count($parent_refs) > 1) { + throw new Exception( + pht( + 'Too many distinct parent refs!')); + } + + if ($parent_refs) { + return head($parent_refs); + } + + return null; + } + + public function setRelatedRevisionRefs(array $refs) { + assert_instances_of($refs, 'ArcanistRevisionRef'); + $this->relatedRevisionRefs = $refs; + return $this; + } + + public function getRelatedRevisionRefs() { + return $this->relatedRevisionRefs; + } + +} diff --git a/src/land/ArcanistLandCommitSet.php b/src/land/ArcanistLandCommitSet.php new file mode 100644 index 00000000..b58e2687 --- /dev/null +++ b/src/land/ArcanistLandCommitSet.php @@ -0,0 +1,72 @@ +revisionRef = $revision_ref; + return $this; + } + + public function getRevisionRef() { + return $this->revisionRef; + } + + public function setCommits(array $commits) { + assert_instances_of($commits, 'ArcanistLandCommit'); + $this->commits = $commits; + + $revision_phid = $this->getRevisionRef()->getPHID(); + foreach ($commits as $commit) { + $revision_ref = $commit->getExplicitRevisionRef(); + + if ($revision_ref) { + if ($revision_ref->getPHID() === $revision_phid) { + continue; + } + } + + $commit->setIsImplicitCommit(true); + } + + return $this; + } + + public function getCommits() { + return $this->commits; + } + + public function hasImplicitCommits() { + foreach ($this->commits as $commit) { + if ($commit->getIsImplicitCommit()) { + return true; + } + } + + return false; + } + + public function hasDirectSymbols() { + foreach ($this->commits as $commit) { + if ($commit->getDirectSymbols()) { + return true; + } + } + + return false; + } + + public function setIsPick($is_pick) { + $this->isPick = $is_pick; + return $this; + } + + public function getIsPick() { + return $this->isPick; + } + +} diff --git a/src/land/ArcanistLandEngine.php b/src/land/ArcanistLandEngine.php deleted file mode 100644 index e81349f8..00000000 --- a/src/land/ArcanistLandEngine.php +++ /dev/null @@ -1,182 +0,0 @@ -workflow = $workflow; - return $this; - } - - final public function getWorkflow() { - return $this->workflow; - } - - final public function setRepositoryAPI( - ArcanistRepositoryAPI $repository_api) { - $this->repositoryAPI = $repository_api; - return $this; - } - - final public function getRepositoryAPI() { - return $this->repositoryAPI; - } - - final public function setShouldHold($should_hold) { - $this->shouldHold = $should_hold; - return $this; - } - - final public function getShouldHold() { - return $this->shouldHold; - } - - final public function setShouldKeep($should_keep) { - $this->shouldKeep = $should_keep; - return $this; - } - - final public function getShouldKeep() { - return $this->shouldKeep; - } - - final public function setShouldSquash($should_squash) { - $this->shouldSquash = $should_squash; - return $this; - } - - final public function getShouldSquash() { - return $this->shouldSquash; - } - - final public function setShouldPreview($should_preview) { - $this->shouldPreview = $should_preview; - return $this; - } - - final public function getShouldPreview() { - return $this->shouldPreview; - } - - final public function setTargetRemote($target_remote) { - $this->targetRemote = $target_remote; - return $this; - } - - final public function getTargetRemote() { - return $this->targetRemote; - } - - final public function setTargetOnto($target_onto) { - $this->targetOnto = $target_onto; - return $this; - } - - final public function getTargetOnto() { - return $this->targetOnto; - } - - final public function setSourceRef($source_ref) { - $this->sourceRef = $source_ref; - return $this; - } - - final public function getSourceRef() { - return $this->sourceRef; - } - - final public function setBuildMessageCallback($build_message_callback) { - $this->buildMessageCallback = $build_message_callback; - return $this; - } - - final public function getBuildMessageCallback() { - return $this->buildMessageCallback; - } - - final public function setCommitMessageFile($commit_message_file) { - $this->commitMessageFile = $commit_message_file; - return $this; - } - - final public function getCommitMessageFile() { - return $this->commitMessageFile; - } - - final public function setRemoteArgument($remote_argument) { - $this->remoteArgument = $remote_argument; - return $this; - } - - final public function getRemoteArgument() { - return $this->remoteArgument; - } - - final public function setOntoArgument($onto_argument) { - $this->ontoArgument = $onto_argument; - return $this; - } - - final public function getOntoArgument() { - return $this->ontoArgument; - } - - abstract public function parseArguments(); - abstract public function execute(); - - abstract protected function getLandingCommits(); - - protected function printLandingCommits() { - $logs = $this->getLandingCommits(); - - if (!$logs) { - throw new ArcanistUsageException( - pht( - 'There are no commits on "%s" which are not already present on '. - 'the target.', - $this->getSourceRef())); - } - - $list = id(new PhutilConsoleList()) - ->setWrap(false) - ->addItems($logs); - - id(new PhutilConsoleBlock()) - ->addParagraph( - pht( - 'These %s commit(s) will be landed:', - new PhutilNumber(count($logs)))) - ->addList($list) - ->draw(); - } - - protected function writeWarn($title, $message) { - return $this->getWorkflow()->writeWarn($title, $message); - } - - protected function writeInfo($title, $message) { - return $this->getWorkflow()->writeInfo($title, $message); - } - - protected function writeOkay($title, $message) { - return $this->getWorkflow()->writeOkay($title, $message); - } - - -} diff --git a/src/land/ArcanistLandSymbol.php b/src/land/ArcanistLandSymbol.php new file mode 100644 index 00000000..672f792e --- /dev/null +++ b/src/land/ArcanistLandSymbol.php @@ -0,0 +1,27 @@ +symbol = $symbol; + return $this; + } + + public function getSymbol() { + return $this->symbol; + } + + public function setCommit($commit) { + $this->commit = $commit; + return $this; + } + + public function getCommit() { + return $this->commit; + } + +} diff --git a/src/land/ArcanistLandTarget.php b/src/land/ArcanistLandTarget.php new file mode 100644 index 00000000..6a258e6b --- /dev/null +++ b/src/land/ArcanistLandTarget.php @@ -0,0 +1,41 @@ +remote = $remote; + return $this; + } + + public function getRemote() { + return $this->remote; + } + + public function setRef($ref) { + $this->ref = $ref; + return $this; + } + + public function getRef() { + return $this->ref; + } + + public function getLandTargetKey() { + return sprintf('%s/%s', $this->getRemote(), $this->getRef()); + } + + public function setLandTargetCommit($commit) { + $this->commit = $commit; + return $this; + } + + public function getLandTargetCommit() { + return $this->commit; + } + +} diff --git a/src/land/engine/ArcanistGitLandEngine.php b/src/land/engine/ArcanistGitLandEngine.php new file mode 100644 index 00000000..b67d0792 --- /dev/null +++ b/src/land/engine/ArcanistGitLandEngine.php @@ -0,0 +1,1608 @@ +isGitPerforce = $is_git_perforce; + return $this; + } + + private function getIsGitPerforce() { + return $this->isGitPerforce; + } + + protected function pruneBranches(array $sets) { + $api = $this->getRepositoryAPI(); + $log = $this->getLogEngine(); + + $old_commits = array(); + foreach ($sets as $set) { + $hash = last($set->getCommits())->getHash(); + $old_commits[] = $hash; + } + + $branch_map = $this->getBranchesForCommits( + $old_commits, + $is_contains = false); + + foreach ($branch_map as $branch_name => $branch_hash) { + $recovery_command = csprintf( + 'git checkout -b %s %s', + $branch_name, + $api->getDisplayHash($branch_hash)); + + $log->writeStatus( + pht('CLEANUP'), + pht('Cleaning up branch "%s". To recover, run:', $branch_name)); + + echo tsprintf( + "\n **$** %s\n\n", + $recovery_command); + + $api->execxLocal('branch -D -- %s', $branch_name); + $this->deletedBranches[$branch_name] = true; + } + } + + private function getBranchesForCommits(array $hashes, $is_contains) { + $api = $this->getRepositoryAPI(); + + $format = '%(refname) %(objectname)'; + + $result = array(); + foreach ($hashes as $hash) { + if ($is_contains) { + $command = csprintf( + 'for-each-ref --contains %s --format %s --', + $hash, + $format); + } else { + $command = csprintf( + 'for-each-ref --points-at %s --format %s --', + $hash, + $format); + } + + list($foreach_lines) = $api->execxLocal('%C', $command); + $foreach_lines = phutil_split_lines($foreach_lines, false); + + foreach ($foreach_lines as $line) { + if (!strlen($line)) { + continue; + } + + $expect_parts = 2; + $parts = explode(' ', $line, $expect_parts); + if (count($parts) !== $expect_parts) { + throw new Exception( + pht( + 'Failed to explode line "%s".', + $line)); + } + + $ref_name = $parts[0]; + $ref_hash = $parts[1]; + + $matches = null; + $ok = preg_match('(^refs/heads/(.*)\z)', $ref_name, $matches); + if ($ok === false) { + throw new Exception( + pht( + 'Failed to match against branch pattern "%s".', + $line)); + } + + if (!$ok) { + continue; + } + + $result[$matches[1]] = $ref_hash; + } + } + + // Sort the result so that branches are processed in natural order. + $names = array_keys($result); + natcasesort($names); + $result = array_select_keys($result, $names); + + return $result; + } + + protected function cascadeState(ArcanistLandCommitSet $set, $into_commit) { + $api = $this->getRepositoryAPI(); + $log = $this->getLogEngine(); + + // This has no effect when we're executing a merge strategy. + if (!$this->isSquashStrategy()) { + return; + } + + $min_commit = head($set->getCommits())->getHash(); + $old_commit = last($set->getCommits())->getHash(); + $new_commit = $into_commit; + + $branch_map = $this->getBranchesForCommits( + array($old_commit), + $is_contains = true); + + $log = $this->getLogEngine(); + foreach ($branch_map as $branch_name => $branch_head) { + // If this branch just points at the old state, don't bother rebasing + // it. We'll update or delete it later. + if ($branch_head === $old_commit) { + continue; + } + + $log->writeStatus( + pht('CASCADE'), + pht( + 'Rebasing "%s" onto landed state...', + $branch_name)); + + // If we used "--pick" to select this commit, we want to rebase branches + // that descend from it onto its ancestor, not onto the landed change. + + // For example, if the change sequence was "W", "X", "Y", "Z" and we + // landed "Y" onto "master" using "--pick", we want to rebase "Z" onto + // "X" (so "W" and "X", which it will often depend on, are still + // its ancestors), not onto the new "master". + + if ($set->getIsPick()) { + $rebase_target = $min_commit.'^'; + } else { + $rebase_target = $new_commit; + } + + try { + $api->execxLocal( + 'rebase --onto %s -- %s %s', + $rebase_target, + $old_commit, + $branch_name); + } catch (CommandException $ex) { + $api->execManualLocal('rebase --abort'); + $api->execManualLocal('reset --hard HEAD --'); + + $log->writeWarning( + pht('REBASE CONFLICT'), + pht( + 'Branch "%s" does not rebase cleanly from "%s" onto '. + '"%s", skipping.', + $branch_name, + $api->getDisplayHash($old_commit), + $api->getDisplayHash($rebase_target))); + } + } + } + + private function fetchTarget(ArcanistLandTarget $target) { + $api = $this->getRepositoryAPI(); + $log = $this->getLogEngine(); + + // NOTE: Although this output isn't hugely useful, we need to passthru + // instead of using a subprocess here because `git fetch` may prompt the + // user to enter a password if they're fetching over HTTP with basic + // authentication. See T10314. + + if ($this->getIsGitPerforce()) { + $log->writeStatus( + pht('P4 SYNC'), + pht( + 'Synchronizing "%s" from Perforce...', + $target->getRef())); + + $err = $this->newPassthru( + 'p4 sync --silent --branch %s --', + $target->getRemote().'/'.$target->getRef()); + if ($err) { + throw new ArcanistUsageException( + pht( + 'Perforce sync failed! Fix the error and run "arc land" again.')); + } + + return $this->getLandTargetLocalCommit($target); + } + + $exists = $this->getLandTargetLocalExists($target); + if (!$exists) { + $log->writeWarning( + pht('TARGET'), + pht( + 'No local copy of ref "%s" in remote "%s" exists, attempting '. + 'fetch...', + $target->getRef(), + $target->getRemote())); + + $this->fetchLandTarget($target, $ignore_failure = true); + + $exists = $this->getLandTargetLocalExists($target); + if (!$exists) { + return null; + } + + $log->writeStatus( + pht('FETCHED'), + pht( + 'Fetched ref "%s" from remote "%s".', + $target->getRef(), + $target->getRemote())); + + return $this->getLandTargetLocalCommit($target); + } + + $log->writeStatus( + pht('FETCH'), + pht( + 'Fetching "%s" from remote "%s"...', + $target->getRef(), + $target->getRemote())); + + $this->fetchLandTarget($target, $ignore_failure = false); + + return $this->getLandTargetLocalCommit($target); + } + + protected function executeMerge(ArcanistLandCommitSet $set, $into_commit) { + $api = $this->getRepositoryAPI(); + $log = $this->getLogEngine(); + + $this->confirmLegacyStrategyConfiguration(); + + $is_empty = ($into_commit === null); + + if ($is_empty) { + $empty_commit = ArcanistGitRawCommit::newEmptyCommit(); + $into_commit = $api->writeRawCommit($empty_commit); + } + + $commits = $set->getCommits(); + + $min_commit = head($commits); + $min_hash = $min_commit->getHash(); + + $max_commit = last($commits); + $max_hash = $max_commit->getHash(); + + // NOTE: See T11435 for some history. See PHI1727 for a case where a user + // modified their working copy while running "arc land". This attempts to + // resist incorrectly detecting simultaneous working copy modifications + // as changes. + + list($changes) = $api->execxLocal( + 'diff --no-ext-diff %s..%s --', + $into_commit, + $max_hash); + $changes = trim($changes); + if (!strlen($changes)) { + + // TODO: We could make a more significant effort to identify the + // human-readable symbol which led us to try to land this ref. + + throw new PhutilArgumentUsageException( + pht( + 'Merging local "%s" into "%s" produces an empty diff. '. + 'This usually means these changes have already landed.', + $api->getDisplayHash($max_hash), + $api->getDisplayHash($into_commit))); + } + + $log->writeStatus( + pht('MERGING'), + pht( + '%s %s', + $api->getDisplayHash($max_hash), + $max_commit->getDisplaySummary())); + + $argv = array(); + $argv[] = '--no-stat'; + $argv[] = '--no-commit'; + + // When we're merging into the empty state, Git refuses to perform the + // merge until we tell it explicitly that we're doing something unusual. + if ($is_empty) { + $argv[] = '--allow-unrelated-histories'; + } + + if ($this->isSquashStrategy()) { + // NOTE: We're explicitly specifying "--ff" to override the presence + // of "merge.ff" options in user configuration. + $argv[] = '--ff'; + $argv[] = '--squash'; + } else { + $argv[] = '--no-ff'; + } + + $argv[] = '--'; + + $is_rebasing = false; + $is_merging = false; + try { + if ($this->isSquashStrategy() && !$is_empty) { + // If we're performing a squash merge, we're going to rebase the + // commit range first. We only want to merge the specific commits + // in the range, and merging too much can create conflicts. + + $api->execxLocal('checkout %s --', $max_hash); + + $is_rebasing = true; + $api->execxLocal( + 'rebase --onto %s -- %s', + $into_commit, + $min_hash.'^'); + $is_rebasing = false; + + $merge_hash = $api->getCanonicalRevisionName('HEAD'); + } else { + $merge_hash = $max_hash; + } + + $api->execxLocal('checkout %s --', $into_commit); + + $argv[] = $merge_hash; + + $is_merging = true; + $api->execxLocal('merge %Ls', $argv); + $is_merging = false; + } catch (CommandException $ex) { + $direct_symbols = $max_commit->getDirectSymbols(); + $indirect_symbols = $max_commit->getIndirectSymbols(); + if ($direct_symbols) { + $message = pht( + 'Local commit "%s" (%s) does not merge cleanly into "%s". '. + 'Merge or rebase local changes so they can merge cleanly.', + $api->getDisplayHash($max_hash), + $this->getDisplaySymbols($direct_symbols), + $api->getDisplayHash($into_commit)); + } else if ($indirect_symbols) { + $message = pht( + 'Local commit "%s" (reachable from: %s) does not merge cleanly '. + 'into "%s". Merge or rebase local changes so they can merge '. + 'cleanly.', + $api->getDisplayHash($max_hash), + $this->getDisplaySymbols($indirect_symbols), + $api->getDisplayHash($into_commit)); + } else { + $message = pht( + 'Local commit "%s" does not merge cleanly into "%s". Merge or '. + 'rebase local changes so they can merge cleanly.', + $api->getDisplayHash($max_hash), + $api->getDisplayHash($into_commit)); + } + + echo tsprintf( + "\n%!\n%W\n\n", + pht('MERGE CONFLICT'), + $message); + + if ($this->getHasUnpushedChanges()) { + echo tsprintf( + "%?\n\n", + pht( + 'Use "--incremental" to merge and push changes one by one.')); + } + + if ($is_rebasing) { + $api->execManualLocal('rebase --abort'); + } + + if ($is_merging) { + $api->execManualLocal('merge --abort'); + } + + if ($is_merging || $is_rebasing) { + $api->execManualLocal('reset --hard HEAD --'); + } + + throw new PhutilArgumentUsageException( + pht('Encountered a merge conflict.')); + } + + list($original_author, $original_date) = $this->getAuthorAndDate( + $max_hash); + + $revision_ref = $set->getRevisionRef(); + $commit_message = $revision_ref->getCommitMessage(); + + $future = $api->execFutureLocal( + 'commit --author %s --date %s -F - --', + $original_author, + $original_date); + $future->write($commit_message); + $future->resolvex(); + + list($stdout) = $api->execxLocal('rev-parse --verify %s', 'HEAD'); + $new_cursor = trim($stdout); + + if ($is_empty) { + // See T12876. If we're landing into the empty state, we just did a fake + // merge on top of an empty commit. We're now on a commit with all of the + // right details except that it has an extra empty commit as a parent. + + // Create a new commit which is the same as the current HEAD, except that + // it doesn't have the extra parent. + + $raw_commit = $api->readRawCommit($new_cursor); + if ($this->isSquashStrategy()) { + $raw_commit->setParents(array()); + } else { + $raw_commit->setParents(array($merge_hash)); + } + $new_cursor = $api->writeRawCommit($raw_commit); + + $api->execxLocal('checkout %s --', $new_cursor); + } + + return $new_cursor; + } + + protected function pushChange($into_commit) { + $api = $this->getRepositoryAPI(); + $log = $this->getLogEngine(); + + if ($this->getIsGitPerforce()) { + + // TODO: Specifying "--onto" more than once is almost certainly an error + // in Perforce. + + $log->writeStatus( + pht('SUBMITTING'), + pht( + 'Submitting changes to "%s".', + $this->getOntoRemote())); + + $config_argv = array(); + + // Skip the "git p4 submit" interactive editor workflow. We expect + // the commit message that "arc land" has built to be satisfactory. + $config_argv[] = '-c'; + $config_argv[] = 'git-p4.skipSubmitEdit=true'; + + // Skip the "git p4 submit" confirmation prompt if the user does not edit + // the submit message. + $config_argv[] = '-c'; + $config_argv[] = 'git-p4.skipSubmitEditCheck=true'; + + $flags_argv = array(); + + // Disable implicit "git p4 rebase" as part of submit. We're allowing + // the implicit "git p4 sync" to go through since this puts us in a + // state which is generally similar to the state after "git push", with + // updated remotes. + + // We could do a manual "git p4 sync" with a more narrow "--branch" + // instead, but it's not clear that this is beneficial. + $flags_argv[] = '--disable-rebase'; + + // Detect moves and submit them to Perforce as move operations. + $flags_argv[] = '-M'; + + // If we run into a conflict, abort the operation. We expect users to + // fix conflicts and run "arc land" again. + $flags_argv[] = '--conflict=quit'; + + $err = $this->newPassthru( + '%LR p4 submit %LR --commit %R --', + $config_argv, + $flags_argv, + $into_commit); + if ($err) { + throw new ArcanistUsageException( + pht( + 'Submit failed! Fix the error and run "arc land" again.')); + } + + return; + } + + $log->writeStatus( + pht('PUSHING'), + pht('Pushing changes to "%s".', $this->getOntoRemote())); + + $err = $this->newPassthru( + 'push -- %s %Ls', + $this->getOntoRemote(), + $this->newOntoRefArguments($into_commit)); + + if ($err) { + throw new ArcanistUsageException( + pht( + 'Push failed! Fix the error and run "arc land" again.')); + } + + // TODO + // if ($this->isGitSvn) { + // $err = phutil_passthru('git svn dcommit'); + // $cmd = 'git svn dcommit'; + + } + + protected function reconcileLocalState( + $into_commit, + ArcanistRepositoryLocalState $state) { + + $api = $this->getRepositoryAPI(); + $log = $this->getWorkflow()->getLogEngine(); + + // Try to put the user into the best final state we can. This is very + // complicated because users are incredibly creative and their local + // branches may, for example, have the same names as branches in the + // remote but no relationship to them. + + // First, we're going to try to update these local branches: + // + // - the branch we started on originally; and + // - the local upstreams of the branch we started on originally; and + // - the local branch with the same name as the "into" ref; and + // - the local branch with the same name as the "onto" ref. + // + // These branches may not all exist and may not all be unique. + // + // To be updated, these branches must: + // + // - exist; + // - have not been deleted; and + // - be connected to the remote we pushed into. + + $update_branches = array(); + + $local_ref = $state->getLocalRef(); + if ($local_ref !== null) { + $update_branches[] = $local_ref; + } + + $local_path = $state->getLocalPath(); + if ($local_path) { + foreach ($local_path->getLocalBranches() as $local_branch) { + $update_branches[] = $local_branch; + } + } + + if (!$this->getIntoEmpty() && !$this->getIntoLocal()) { + $update_branches[] = $this->getIntoRef(); + } + + foreach ($this->getOntoRefs() as $onto_ref) { + $update_branches[] = $onto_ref; + } + + $update_branches = array_fuse($update_branches); + + // Remove any branches we know we deleted. + foreach ($update_branches as $key => $update_branch) { + if (isset($this->deletedBranches[$update_branch])) { + unset($update_branches[$key]); + } + } + + // Now, remove any branches which don't actually exist. + foreach ($update_branches as $key => $update_branch) { + list($err) = $api->execManualLocal( + 'rev-parse --verify %s', + $update_branch); + if ($err) { + unset($update_branches[$key]); + } + } + + $is_perforce = $this->getIsGitPerforce(); + if ($is_perforce) { + // If we're in Perforce mode, we don't expect to have a meaningful + // path to the remote: the "p4" remote is not a real remote, and + // "git p4" commands do not configure branch upstreams to provide + // a path. + + // Additionally, we've already set the remote to the right state with an + // implicit "git p4 sync" during "git p4 submit", and "git pull" isn't a + // meaningful operation. + + // We're going to skip everything here and just switch to the most + // desirable branch (if we can find one), then reset the state (if that + // operation is safe). + + if (!$update_branches) { + $log->writeStatus( + pht('DETACHED HEAD'), + pht( + 'Unable to find any local branches to update, staying on '. + 'detached head.')); + $state->discardLocalState(); + return; + } + + $dst_branch = head($update_branches); + if (!$this->isAncestorOf($dst_branch, $into_commit)) { + $log->writeStatus( + pht('CHECKOUT'), + pht( + 'Local branch "%s" has unpublished changes, checking it out '. + 'but leaving them in place.', + $dst_branch)); + $do_reset = false; + } else { + $log->writeStatus( + pht('UPDATE'), + pht( + 'Switching to local branch "%s".', + $dst_branch)); + $do_reset = true; + } + + $api->execxLocal('checkout %s --', $dst_branch); + + if ($do_reset) { + $api->execxLocal('reset --hard %s --', $into_commit); + } + + $state->discardLocalState(); + return; + } + + $onto_refs = array_fuse($this->getOntoRefs()); + + $pull_branches = array(); + foreach ($update_branches as $update_branch) { + $update_path = $api->getPathToUpstream($update_branch); + + // Remove any branches which contain upstream cycles. + if ($update_path->getCycle()) { + $log->writeWarning( + pht('LOCAL CYCLE'), + pht( + 'Local branch "%s" tracks an upstream but following it leads to '. + 'a local cycle, ignoring branch.', + $update_branch)); + continue; + } + + // Remove any branches not connected to a remote. + if (!$update_path->isConnectedToRemote()) { + continue; + } + + // Remove any branches connected to a remote other than the remote + // we actually pushed to. + $remote_name = $update_path->getRemoteRemoteName(); + if ($remote_name !== $this->getOntoRemote()) { + continue; + } + + // Remove any branches not connected to a branch we pushed to. + $remote_branch = $update_path->getRemoteBranchName(); + if (!isset($onto_refs[$remote_branch])) { + continue; + } + + // This is the most-desirable path between some local branch and + // an impacted upstream. Select it and continue. + $pull_branches = $update_path->getLocalBranches(); + break; + } + + // When we update these branches later, we want to start with the branch + // closest to the upstream and work our way down. + $pull_branches = array_reverse($pull_branches); + $pull_branches = array_fuse($pull_branches); + + // If we started on a branch and it still exists but is not impacted + // by the changes we made to the remote (i.e., we aren't actually going + // to pull or update it if we continue), just switch back to it now. It's + // okay if this branch is completely unrelated to the changes we just + // landed. + + if ($local_ref !== null) { + if (isset($update_branches[$local_ref])) { + if (!isset($pull_branches[$local_ref])) { + + $log->writeStatus( + pht('RETURN'), + pht( + 'Returning to original branch "%s" in original state.', + $local_ref)); + + $state->restoreLocalState(); + return; + } + } + } + + // Otherwise, if we don't have any path from the upstream to any local + // branch, we don't want to switch to some unrelated branch which happens + // to have the same name as a branch we interacted with. Just stay where + // we ended up. + + $dst_branch = null; + if ($pull_branches) { + $dst_branch = null; + foreach ($pull_branches as $pull_branch) { + if (!$this->isAncestorOf($pull_branch, $into_commit)) { + + $log->writeStatus( + pht('LOCAL CHANGES'), + pht( + 'Local branch "%s" has unpublished changes, ending updates.', + $pull_branch)); + + break; + } + + $log->writeStatus( + pht('UPDATE'), + pht( + 'Updating local branch "%s"...', + $pull_branch)); + + $api->execxLocal( + 'branch -f %s %s --', + $pull_branch, + $into_commit); + + $dst_branch = $pull_branch; + } + } + + if ($dst_branch) { + $log->writeStatus( + pht('CHECKOUT'), + pht( + 'Checking out "%s".', + $dst_branch)); + + $api->execxLocal('checkout %s --', $dst_branch); + } else { + $log->writeStatus( + pht('DETACHED HEAD'), + pht( + 'Unable to find any local branches to update, staying on '. + 'detached head.')); + } + + $state->discardLocalState(); + } + + private function isAncestorOf($branch, $commit) { + $api = $this->getRepositoryAPI(); + + list($stdout) = $api->execxLocal( + 'merge-base %s %s', + $branch, + $commit); + $merge_base = trim($stdout); + + list($stdout) = $api->execxLocal( + 'rev-parse --verify %s', + $branch); + $branch_hash = trim($stdout); + + return ($merge_base === $branch_hash); + } + + private function getAuthorAndDate($commit) { + $api = $this->getRepositoryAPI(); + + list($info) = $api->execxLocal( + 'log -n1 --format=%s %s --', + '%aD%n%an%n%ae', + $commit); + + $info = trim($info); + list($date, $author, $email) = explode("\n", $info, 3); + + return array( + "$author <{$email}>", + $date, + ); + } + + protected function didHoldChanges($into_commit) { + $log = $this->getLogEngine(); + $local_state = $this->getLocalState(); + + if ($this->getIsGitPerforce()) { + $message = pht( + 'Holding changes locally, they have not been submitted.'); + + $push_command = csprintf( + 'git p4 submit -M --commit %s --', + $into_commit); + } else { + $message = pht( + 'Holding changes locally, they have not been pushed.'); + + $push_command = csprintf( + 'git push -- %s %Ls', + $this->getOntoRemote(), + $this->newOntoRefArguments($into_commit)); + } + + echo tsprintf( + "\n%!\n%s\n\n", + pht('HOLD CHANGES'), + $message); + + echo tsprintf( + "%s\n\n%>\n", + pht('To push changes manually, run this command:'), + $push_command); + + $restore_commands = $local_state->getRestoreCommandsForDisplay(); + if ($restore_commands) { + echo tsprintf( + "%s\n\n", + pht( + 'To go back to how things were before you ran "arc land", run '. + 'these %s command(s):', + phutil_count($restore_commands))); + + foreach ($restore_commands as $restore_command) { + echo tsprintf('%>', $restore_command); + } + + echo tsprintf("\n"); + } + + echo tsprintf( + "%s\n", + pht( + 'Local branches have not been changed, and are still in the '. + 'same state as before.')); + } + + protected function resolveSymbols(array $symbols) { + assert_instances_of($symbols, 'ArcanistLandSymbol'); + $api = $this->getRepositoryAPI(); + + foreach ($symbols as $symbol) { + $raw_symbol = $symbol->getSymbol(); + + list($err, $stdout) = $api->execManualLocal( + 'rev-parse --verify %s', + $raw_symbol); + + if ($err) { + throw new PhutilArgumentUsageException( + pht( + 'Branch "%s" does not exist in the local working copy.', + $raw_symbol)); + } + + $commit = trim($stdout); + $symbol->setCommit($commit); + } + } + + protected function confirmOntoRefs(array $onto_refs) { + $api = $this->getRepositoryAPI(); + + foreach ($onto_refs as $onto_ref) { + if (!strlen($onto_ref)) { + throw new PhutilArgumentUsageException( + pht( + 'Selected "onto" ref "%s" is invalid: the empty string is not '. + 'a valid ref.', + $onto_ref)); + } + } + + $markers = $api->newMarkerRefQuery() + ->withRemotes(array($this->getOntoRemoteRef())) + ->withNames($onto_refs) + ->execute(); + + $markers = mgroup($markers, 'getName'); + + $new_markers = array(); + foreach ($onto_refs as $onto_ref) { + if (isset($markers[$onto_ref])) { + // Remote already has a branch with this name, so we're fine: we + // aren't creatinga new branch. + continue; + } + + $new_markers[] = id(new ArcanistMarkerRef()) + ->setMarkerType(ArcanistMarkerRef::TYPE_BRANCH) + ->setName($onto_ref); + } + + if ($new_markers) { + echo tsprintf( + "\n%!\n%W\n\n", + pht('CREATE %s BRANCHE(S)', phutil_count($new_markers)), + pht( + 'These %s symbol(s) do not exist in the remote. They will be '. + 'created as new branches:', + phutil_count($new_markers))); + + foreach ($new_markers as $new_marker) { + echo tsprintf('%s', $new_marker->newRefView()); + } + + echo tsprintf("\n"); + + $is_hold = $this->getShouldHold(); + if ($is_hold) { + echo tsprintf( + "%?\n", + pht( + 'You are using "--hold", so execution will stop before the '. + '%s branche(s) are actually created. You will be given '. + 'instructions to create the branches.', + phutil_count($new_markers))); + } + + $query = pht( + 'Create %s new branche(s) in the remote?', + phutil_count($new_markers)); + + $this->getWorkflow() + ->getPrompt('arc.land.create') + ->setQuery($query) + ->execute(); + } + } + + protected function selectOntoRefs(array $symbols) { + assert_instances_of($symbols, 'ArcanistLandSymbol'); + $log = $this->getLogEngine(); + + $onto = $this->getOntoArguments(); + if ($onto) { + + $log->writeStatus( + pht('ONTO TARGET'), + pht( + 'Refs were selected with the "--onto" flag: %s.', + implode(', ', $onto))); + + return $onto; + } + + $onto = $this->getOntoFromConfiguration(); + if ($onto) { + $onto_key = $this->getOntoConfigurationKey(); + + $log->writeStatus( + pht('ONTO TARGET'), + pht( + 'Refs were selected by reading "%s" configuration: %s.', + $onto_key, + implode(', ', $onto))); + + return $onto; + } + + $api = $this->getRepositoryAPI(); + + $remote_onto = array(); + foreach ($symbols as $symbol) { + $raw_symbol = $symbol->getSymbol(); + $path = $api->getPathToUpstream($raw_symbol); + + if (!$path->getLength()) { + continue; + } + + $cycle = $path->getCycle(); + if ($cycle) { + $log->writeWarning( + pht('LOCAL CYCLE'), + pht( + 'Local branch "%s" tracks an upstream, but following it leads '. + 'to a local cycle; ignoring branch upstream.', + $raw_symbol)); + + $log->writeWarning( + pht('LOCAL CYCLE'), + implode(' -> ', $cycle)); + + continue; + } + + if (!$path->isConnectedToRemote()) { + $log->writeWarning( + pht('NO PATH TO REMOTE'), + pht( + 'Local branch "%s" tracks an upstream, but there is no path '. + 'to a remote; ignoring branch upstream.', + $raw_symbol)); + + continue; + } + + $onto = $path->getRemoteBranchName(); + + $remote_onto[$onto] = $onto; + } + + if (count($remote_onto) > 1) { + throw new PhutilArgumentUsageException( + pht( + 'The branches you are landing are connected to multiple different '. + 'remote branches via Git branch upstreams. Use "--onto" to select '. + 'the refs you want to push to.')); + } + + if ($remote_onto) { + $remote_onto = array_values($remote_onto); + + $log->writeStatus( + pht('ONTO TARGET'), + pht( + 'Landing onto target "%s", selected by following tracking branches '. + 'upstream to the closest remote branch.', + head($remote_onto))); + + return $remote_onto; + } + + $default_onto = 'master'; + + $log->writeStatus( + pht('ONTO TARGET'), + pht( + 'Landing onto target "%s", the default target under Git.', + $default_onto)); + + return array($default_onto); + } + + protected function selectOntoRemote(array $symbols) { + assert_instances_of($symbols, 'ArcanistLandSymbol'); + $remote = $this->newOntoRemote($symbols); + + $api = $this->getRepositoryAPI(); + $log = $this->getLogEngine(); + $is_pushable = $api->isPushableRemote($remote); + $is_perforce = $api->isPerforceRemote($remote); + + if (!$is_pushable && !$is_perforce) { + throw new PhutilArgumentUsageException( + pht( + 'No pushable remote "%s" exists. Use the "--onto-remote" flag to '. + 'choose a valid, pushable remote to land changes onto.', + $remote)); + } + + if ($is_perforce) { + $this->setIsGitPerforce(true); + + $log->writeWarning( + pht('P4 MODE'), + pht( + 'Operating in Git/Perforce mode after selecting a Perforce '. + 'remote.')); + + if (!$this->isSquashStrategy()) { + throw new PhutilArgumentUsageException( + pht( + 'Perforce mode does not support the "merge" land strategy. '. + 'Use the "squash" land strategy when landing to a Perforce '. + 'remote (you can use "--squash" to select this strategy).')); + } + } + + return $remote; + } + + private function newOntoRemote(array $onto_symbols) { + assert_instances_of($onto_symbols, 'ArcanistLandSymbol'); + $log = $this->getLogEngine(); + + $remote = $this->getOntoRemoteArgument(); + if ($remote !== null) { + + $log->writeStatus( + pht('ONTO REMOTE'), + pht( + 'Remote "%s" was selected with the "--onto-remote" flag.', + $remote)); + + return $remote; + } + + $remote = $this->getOntoRemoteFromConfiguration(); + if ($remote !== null) { + $remote_key = $this->getOntoRemoteConfigurationKey(); + + $log->writeStatus( + pht('ONTO REMOTE'), + pht( + 'Remote "%s" was selected by reading "%s" configuration.', + $remote, + $remote_key)); + + return $remote; + } + + $api = $this->getRepositoryAPI(); + + $upstream_remotes = array(); + foreach ($onto_symbols as $onto_symbol) { + $path = $api->getPathToUpstream($onto_symbol->getSymbol()); + + $remote = $path->getRemoteRemoteName(); + if ($remote !== null) { + $upstream_remotes[$remote][] = $onto_symbol; + } + } + + if (count($upstream_remotes) > 1) { + throw new PhutilArgumentUsageException( + pht( + 'The "onto" refs you have selected are connected to multiple '. + 'different remotes via Git branch upstreams. Use "--onto-remote" '. + 'to select a single remote.')); + } + + if ($upstream_remotes) { + $upstream_remote = head_key($upstream_remotes); + + $log->writeStatus( + pht('ONTO REMOTE'), + pht( + 'Remote "%s" was selected by following tracking branches '. + 'upstream to the closest remote.', + $remote)); + + return $upstream_remote; + } + + $perforce_remote = 'p4'; + if ($api->isPerforceRemote($remote)) { + + $log->writeStatus( + pht('ONTO REMOTE'), + pht( + 'Peforce remote "%s" was selected because the existence of '. + 'this remote implies this working copy was synchronized '. + 'from a Perforce repository.', + $remote)); + + return $remote; + } + + $default_remote = 'origin'; + + $log->writeStatus( + pht('ONTO REMOTE'), + pht( + 'Landing onto remote "%s", the default remote under Git.', + $default_remote)); + + return $default_remote; + } + + protected function selectIntoRemote() { + $api = $this->getRepositoryAPI(); + $log = $this->getLogEngine(); + + if ($this->getIntoEmptyArgument()) { + $this->setIntoEmpty(true); + + $log->writeStatus( + pht('INTO REMOTE'), + pht( + 'Will merge into empty state, selected with the "--into-empty" '. + 'flag.')); + + return; + } + + if ($this->getIntoLocalArgument()) { + $this->setIntoLocal(true); + + $log->writeStatus( + pht('INTO REMOTE'), + pht( + 'Will merge into local state, selected with the "--into-local" '. + 'flag.')); + + return; + } + + $into = $this->getIntoRemoteArgument(); + if ($into !== null) { + + // TODO: We could allow users to pass a URI argument instead, but + // this also requires some updates to the fetch logic elsewhere. + + if (!$api->isFetchableRemote($into)) { + throw new PhutilArgumentUsageException( + pht( + 'Remote "%s", specified with "--into", is not a valid fetchable '. + 'remote.', + $into)); + } + + $this->setIntoRemote($into); + + $log->writeStatus( + pht('INTO REMOTE'), + pht( + 'Will merge into remote "%s", selected with the "--into" flag.', + $into)); + + return; + } + + $onto = $this->getOntoRemote(); + $this->setIntoRemote($onto); + + $log->writeStatus( + pht('INTO REMOTE'), + pht( + 'Will merge into remote "%s" by default, because this is the remote '. + 'the change is landing onto.', + $onto)); + } + + protected function selectIntoRef() { + $log = $this->getLogEngine(); + + if ($this->getIntoEmptyArgument()) { + $log->writeStatus( + pht('INTO TARGET'), + pht( + 'Will merge into empty state, selected with the "--into-empty" '. + 'flag.')); + + return; + } + + $into = $this->getIntoArgument(); + if ($into !== null) { + $this->setIntoRef($into); + + $log->writeStatus( + pht('INTO TARGET'), + pht( + 'Will merge into target "%s", selected with the "--into" flag.', + $into)); + + return; + } + + $ontos = $this->getOntoRefs(); + $onto = head($ontos); + + $this->setIntoRef($onto); + if (count($ontos) > 1) { + $log->writeStatus( + pht('INTO TARGET'), + pht( + 'Will merge into target "%s" by default, because this is the first '. + '"onto" target.', + $onto)); + } else { + $log->writeStatus( + pht('INTO TARGET'), + pht( + 'Will merge into target "%s" by default, because this is the "onto" '. + 'target.', + $onto)); + } + } + + protected function selectIntoCommit() { + $api = $this->getRepositoryAPI(); + // Make sure that our "into" target is valid. + $log = $this->getLogEngine(); + $api = $this->getRepositoryAPI(); + + if ($this->getIntoEmpty()) { + // If we're running under "--into-empty", we don't have to do anything. + + $log->writeStatus( + pht('INTO COMMIT'), + pht('Preparing merge into the empty state.')); + + return null; + } + + if ($this->getIntoLocal()) { + // If we're running under "--into-local", just make sure that the + // target identifies some actual commit. + $local_ref = $this->getIntoRef(); + + list($err, $stdout) = $api->execManualLocal( + 'rev-parse --verify %s', + $local_ref); + + if ($err) { + throw new PhutilArgumentUsageException( + pht( + 'Local ref "%s" does not exist.', + $local_ref)); + } + + $into_commit = trim($stdout); + + $log->writeStatus( + pht('INTO COMMIT'), + pht( + 'Preparing merge into local target "%s", at commit "%s".', + $local_ref, + $api->getDisplayHash($into_commit))); + + return $into_commit; + } + + $target = id(new ArcanistLandTarget()) + ->setRemote($this->getIntoRemote()) + ->setRef($this->getIntoRef()); + + $commit = $this->fetchTarget($target); + if ($commit !== null) { + $log->writeStatus( + pht('INTO COMMIT'), + pht( + 'Preparing merge into "%s" from remote "%s", at commit "%s".', + $target->getRef(), + $target->getRemote(), + $api->getDisplayHash($commit))); + return $commit; + } + + // If we have no valid target and the user passed "--into" explicitly, + // treat this as an error. For example, "arc land --into Q --onto Q", + // where "Q" does not exist, is an error. + if ($this->getIntoArgument()) { + throw new PhutilArgumentUsageException( + pht( + 'Ref "%s" does not exist in remote "%s".', + $target->getRef(), + $target->getRemote())); + } + + // Otherwise, treat this as implying "--into-empty". For example, + // "arc land --onto Q", where "Q" does not exist, is equivalent to + // "arc land --into-empty --onto Q". + $this->setIntoEmpty(true); + + $log->writeStatus( + pht('INTO COMMIT'), + pht( + 'Preparing merge into the empty state to create target "%s" '. + 'in remote "%s".', + $target->getRef(), + $target->getRemote())); + + return null; + } + + private function getLandTargetLocalCommit(ArcanistLandTarget $target) { + $commit = $this->resolveLandTargetLocalCommit($target); + + if ($commit === null) { + throw new Exception( + pht( + 'No ref "%s" exists in remote "%s".', + $target->getRef(), + $target->getRemote())); + } + + return $commit; + } + + private function getLandTargetLocalExists(ArcanistLandTarget $target) { + $commit = $this->resolveLandTargetLocalCommit($target); + return ($commit !== null); + } + + private function resolveLandTargetLocalCommit(ArcanistLandTarget $target) { + $target_key = $target->getLandTargetKey(); + + if (!array_key_exists($target_key, $this->landTargetCommitMap)) { + $full_ref = sprintf( + 'refs/remotes/%s/%s', + $target->getRemote(), + $target->getRef()); + + $api = $this->getRepositoryAPI(); + + list($err, $stdout) = $api->execManualLocal( + 'rev-parse --verify %s', + $full_ref); + + if ($err) { + $result = null; + } else { + $result = trim($stdout); + } + + $this->landTargetCommitMap[$target_key] = $result; + } + + return $this->landTargetCommitMap[$target_key]; + } + + private function fetchLandTarget( + ArcanistLandTarget $target, + $ignore_failure = false) { + $api = $this->getRepositoryAPI(); + + $err = $this->newPassthru( + 'fetch --no-tags --quiet -- %s %s', + $target->getRemote(), + $target->getRef()); + if ($err && !$ignore_failure) { + throw new ArcanistUsageException( + pht( + 'Fetch of "%s" from remote "%s" failed! Fix the error and '. + 'run "arc land" again.', + $target->getRef(), + $target->getRemote())); + } + + // TODO: If the remote is a bare URI, we could read ".git/FETCH_HEAD" + // here and write the commit into the map. For now, settle for clearing + // the cache. + + // We could also fetch into some named "refs/arc-land-temporary" named + // ref, then read that. + + if (!$err) { + $target_key = $target->getLandTargetKey(); + unset($this->landTargetCommitMap[$target_key]); + } + } + + protected function selectCommits($into_commit, array $symbols) { + assert_instances_of($symbols, 'ArcanistLandSymbol'); + $api = $this->getRepositoryAPI(); + + $commit_map = array(); + foreach ($symbols as $symbol) { + $symbol_commit = $symbol->getCommit(); + $format = '%H%x00%P%x00%s%x00'; + + if ($into_commit === null) { + list($commits) = $api->execxLocal( + 'log %s --format=%s', + $symbol_commit, + $format); + } else { + list($commits) = $api->execxLocal( + 'log %s --not %s --format=%s', + $symbol_commit, + $into_commit, + $format); + } + + $commits = phutil_split_lines($commits, false); + $is_first = true; + foreach ($commits as $line) { + if (!strlen($line)) { + continue; + } + + $parts = explode("\0", $line, 4); + if (count($parts) < 3) { + throw new Exception( + pht( + 'Unexpected output from "git log ...": %s', + $line)); + } + + $hash = $parts[0]; + if (!isset($commit_map[$hash])) { + $parents = $parts[1]; + $parents = trim($parents); + if (strlen($parents)) { + $parents = explode(' ', $parents); + } else { + $parents = array(); + } + + $summary = $parts[2]; + + $commit_map[$hash] = id(new ArcanistLandCommit()) + ->setHash($hash) + ->setParents($parents) + ->setSummary($summary); + } + + $commit = $commit_map[$hash]; + if ($is_first) { + $commit->addDirectSymbol($symbol); + $is_first = false; + } + + $commit->addIndirectSymbol($symbol); + } + } + + return $this->confirmCommits($into_commit, $symbols, $commit_map); + } + + protected function getDefaultSymbols() { + $api = $this->getRepositoryAPI(); + $log = $this->getLogEngine(); + + $branch = $api->getBranchName(); + if ($branch !== null) { + $log->writeStatus( + pht('SOURCE'), + pht( + 'Landing the current branch, "%s".', + $branch)); + + return array($branch); + } + + $commit = $api->getCurrentCommitRef(); + + $log->writeStatus( + pht('SOURCE'), + pht( + 'Landing the current HEAD, "%s".', + $commit->getCommitHash())); + + return array($commit->getCommitHash()); + } + + private function newOntoRefArguments($into_commit) { + $api = $this->getRepositoryAPI(); + $refspecs = array(); + + foreach ($this->getOntoRefs() as $onto_ref) { + $refspecs[] = sprintf( + '%s:refs/heads/%s', + $api->getDisplayHash($into_commit), + $onto_ref); + } + + return $refspecs; + } + + private function confirmLegacyStrategyConfiguration() { + // TODO: See T13547. Remove this check in the future. This prevents users + // from accidentally executing a "squash" workflow under a configuration + // which would previously have executed a "merge" workflow. + + // We're fine if we have an explicit "--strategy". + if ($this->getStrategyArgument() !== null) { + return; + } + + // We're fine if we have an explicit "arc.land.strategy". + if ($this->getStrategyFromConfiguration() !== null) { + return; + } + + // We're fine if "history.immutable" is not set to "true". + $source_list = $this->getWorkflow()->getConfigurationSourceList(); + $config_list = $source_list->getStorageValueList('history.immutable'); + if (!$config_list) { + return; + } + + $config_value = (bool)last($config_list)->getValue(); + if (!$config_value) { + return; + } + + // We're in trouble: we would previously have selected "merge" and will + // now select "squash". Make sure the user knows what they're in for. + + echo tsprintf( + "\n%!\n%W\n\n", + pht('MERGE STRATEGY IS AMBIGUOUS'), + pht( + 'See <%s>. The default merge strategy under Git with '. + '"history.immutable" has changed from "merge" to "squash". Your '. + 'configuration is ambiguous under this behavioral change. '. + '(Use "--strategy" or configure "arc.land.strategy" to bypass '. + 'this check.)', + 'https://secure.phabricator.com/T13547')); + + throw new PhutilArgumentUsageException( + pht( + 'Desired merge strategy is ambiguous, choose an explicit strategy.')); + } + +} diff --git a/src/land/engine/ArcanistLandEngine.php b/src/land/engine/ArcanistLandEngine.php new file mode 100644 index 00000000..a3e36ee7 --- /dev/null +++ b/src/land/engine/ArcanistLandEngine.php @@ -0,0 +1,1577 @@ +ontoRemote = $onto_remote; + return $this; + } + + final public function getOntoRemote() { + return $this->ontoRemote; + } + + final public function setOntoRefs($onto_refs) { + $this->ontoRefs = $onto_refs; + return $this; + } + + final public function getOntoRefs() { + return $this->ontoRefs; + } + + final public function setIntoRemote($into_remote) { + $this->intoRemote = $into_remote; + return $this; + } + + final public function getIntoRemote() { + return $this->intoRemote; + } + + final public function setIntoRef($into_ref) { + $this->intoRef = $into_ref; + return $this; + } + + final public function getIntoRef() { + return $this->intoRef; + } + + final public function setIntoEmpty($into_empty) { + $this->intoEmpty = $into_empty; + return $this; + } + + final public function getIntoEmpty() { + return $this->intoEmpty; + } + + final public function setPickArgument($pick_argument) { + $this->pickArgument = $pick_argument; + return $this; + } + + final public function getPickArgument() { + return $this->pickArgument; + } + + final public function setIntoLocal($into_local) { + $this->intoLocal = $into_local; + return $this; + } + + final public function getIntoLocal() { + return $this->intoLocal; + } + + final public function setShouldHold($should_hold) { + $this->shouldHold = $should_hold; + return $this; + } + + final public function getShouldHold() { + return $this->shouldHold; + } + + final public function setShouldKeep($should_keep) { + $this->shouldKeep = $should_keep; + return $this; + } + + final public function getShouldKeep() { + return $this->shouldKeep; + } + + final public function setStrategyArgument($strategy_argument) { + $this->strategyArgument = $strategy_argument; + return $this; + } + + final public function getStrategyArgument() { + return $this->strategyArgument; + } + + final public function setStrategy($strategy) { + $this->strategy = $strategy; + return $this; + } + + final public function getStrategy() { + return $this->strategy; + } + + final public function setRevisionSymbol($revision_symbol) { + $this->revisionSymbol = $revision_symbol; + return $this; + } + + final public function getRevisionSymbol() { + return $this->revisionSymbol; + } + + final public function setRevisionSymbolRef( + ArcanistRevisionSymbolRef $revision_ref) { + $this->revisionSymbolRef = $revision_ref; + return $this; + } + + final public function getRevisionSymbolRef() { + return $this->revisionSymbolRef; + } + + final public function setShouldPreview($should_preview) { + $this->shouldPreview = $should_preview; + return $this; + } + + final public function getShouldPreview() { + return $this->shouldPreview; + } + + final public function setSourceRefs(array $source_refs) { + $this->sourceRefs = $source_refs; + return $this; + } + + final public function getSourceRefs() { + return $this->sourceRefs; + } + + final public function setOntoRemoteArgument($remote_argument) { + $this->ontoRemoteArgument = $remote_argument; + return $this; + } + + final public function getOntoRemoteArgument() { + return $this->ontoRemoteArgument; + } + + final public function setOntoArguments(array $onto_arguments) { + $this->ontoArguments = $onto_arguments; + return $this; + } + + final public function getOntoArguments() { + return $this->ontoArguments; + } + + final public function setIsIncremental($is_incremental) { + $this->isIncremental = $is_incremental; + return $this; + } + + final public function getIsIncremental() { + return $this->isIncremental; + } + + final public function setIntoEmptyArgument($into_empty_argument) { + $this->intoEmptyArgument = $into_empty_argument; + return $this; + } + + final public function getIntoEmptyArgument() { + return $this->intoEmptyArgument; + } + + final public function setIntoLocalArgument($into_local_argument) { + $this->intoLocalArgument = $into_local_argument; + return $this; + } + + final public function getIntoLocalArgument() { + return $this->intoLocalArgument; + } + + final public function setIntoRemoteArgument($into_remote_argument) { + $this->intoRemoteArgument = $into_remote_argument; + return $this; + } + + final public function getIntoRemoteArgument() { + return $this->intoRemoteArgument; + } + + final public function setIntoArgument($into_argument) { + $this->intoArgument = $into_argument; + return $this; + } + + final public function getIntoArgument() { + return $this->intoArgument; + } + + private function setLocalState(ArcanistRepositoryLocalState $local_state) { + $this->localState = $local_state; + return $this; + } + + final protected function getLocalState() { + return $this->localState; + } + + private function setHasUnpushedChanges($unpushed) { + $this->hasUnpushedChanges = $unpushed; + return $this; + } + + final protected function getHasUnpushedChanges() { + return $this->hasUnpushedChanges; + } + + final protected function getOntoConfigurationKey() { + return 'arc.land.onto'; + } + + final protected function getOntoFromConfiguration() { + $config_key = $this->getOntoConfigurationKey(); + return $this->getWorkflow()->getConfig($config_key); + } + + final protected function getOntoRemoteConfigurationKey() { + return 'arc.land.onto-remote'; + } + + final protected function getOntoRemoteFromConfiguration() { + $config_key = $this->getOntoRemoteConfigurationKey(); + return $this->getWorkflow()->getConfig($config_key); + } + + final protected function getStrategyConfigurationKey() { + return 'arc.land.strategy'; + } + + final protected function getStrategyFromConfiguration() { + $config_key = $this->getStrategyConfigurationKey(); + return $this->getWorkflow()->getConfig($config_key); + } + + final protected function confirmRevisions(array $sets) { + assert_instances_of($sets, 'ArcanistLandCommitSet'); + + $revision_refs = mpull($sets, 'getRevisionRef'); + $viewer = $this->getViewer(); + $viewer_phid = $viewer->getPHID(); + + $unauthored = array(); + foreach ($revision_refs as $revision_ref) { + $author_phid = $revision_ref->getAuthorPHID(); + if ($author_phid !== $viewer_phid) { + $unauthored[] = $revision_ref; + } + } + + if ($unauthored) { + $this->getWorkflow()->loadHardpoints( + $unauthored, + array( + ArcanistRevisionRef::HARDPOINT_AUTHORREF, + )); + + echo tsprintf( + "\n%!\n%W\n\n", + pht('NOT REVISION AUTHOR'), + pht( + 'You are landing revisions which you ("%s") are not the author of:', + $viewer->getMonogram())); + + foreach ($unauthored as $revision_ref) { + $display_ref = $revision_ref->newRefView(); + + $author_ref = $revision_ref->getAuthorRef(); + if ($author_ref) { + $display_ref->appendLine( + pht( + 'Author: %s', + $author_ref->getMonogram())); + } + + echo tsprintf('%s', $display_ref); + } + + echo tsprintf( + "\n%?\n", + pht( + 'Use "Commandeer" in the web interface to become the author of '. + 'a revision.')); + + $query = pht('Land revisions you are not the author of?'); + + $this->getWorkflow() + ->getPrompt('arc.land.unauthored') + ->setQuery($query) + ->execute(); + } + + $planned = array(); + $published = array(); + $not_accepted = array(); + foreach ($revision_refs as $revision_ref) { + if ($revision_ref->isStatusChangesPlanned()) { + $planned[] = $revision_ref; + } else if ($revision_ref->isStatusPublished()) { + $published[] = $revision_ref; + } else if (!$revision_ref->isStatusAccepted()) { + $not_accepted[] = $revision_ref; + } + } + + // See T10233. Previously, this prompt was bundled with the generic "not + // accepted" prompt, but users found it confusing and interpreted the + // prompt as a bug. + + if ($planned) { + $example_ref = head($planned); + + echo tsprintf( + "\n%!\n%W\n\n%W\n\n%W\n\n", + pht('%s REVISION(S) HAVE CHANGES PLANNED', phutil_count($planned)), + pht( + 'You are landing %s revision(s) which are currently in the state '. + '"%s", indicating that you expect to revise them before moving '. + 'forward.', + phutil_count($planned), + $example_ref->getStatusDisplayName()), + pht( + 'Normally, you should update these %s revision(s), submit them '. + 'for review, and wait for reviewers to accept them before '. + 'you continue. To resubmit a revision for review, either: '. + 'update the revision with revised changes; or use '. + '"Request Review" from the web interface.', + phutil_count($planned)), + pht( + 'These %s revision(s) have changes planned:', + phutil_count($planned))); + + foreach ($planned as $revision_ref) { + echo tsprintf('%s', $revision_ref->newRefView()); + } + + $query = pht( + 'Land %s revision(s) with changes planned?', + phutil_count($planned)); + + $this->getWorkflow() + ->getPrompt('arc.land.changes-planned') + ->setQuery($query) + ->execute(); + } + + // See PHI1727. Previously, this prompt was bundled with the generic + // "not accepted" prompt, but at least one user found it confusing. + + if ($published) { + $example_ref = head($published); + + echo tsprintf( + "\n%!\n%W\n\n", + pht('%s REVISION(S) ARE ALREADY PUBLISHED', phutil_count($published)), + pht( + 'You are landing %s revision(s) which are already in the state '. + '"%s", indicating that they have previously landed:', + phutil_count($published), + $example_ref->getStatusDisplayName())); + + foreach ($published as $revision_ref) { + echo tsprintf('%s', $revision_ref->newRefView()); + } + + $query = pht( + 'Land %s revision(s) that are already published?', + phutil_count($published)); + + $this->getWorkflow() + ->getPrompt('arc.land.published') + ->setQuery($query) + ->execute(); + } + + if ($not_accepted) { + $example_ref = head($not_accepted); + + echo tsprintf( + "\n%!\n%W\n\n", + pht('%s REVISION(S) ARE NOT ACCEPTED', phutil_count($not_accepted)), + pht( + 'You are landing %s revision(s) which are not in state "Accepted", '. + 'indicating that they have not been accepted by reviewers. '. + 'Normally, you should land changes only once they have been '. + 'accepted. These revisions are in the wrong state:', + phutil_count($not_accepted))); + + foreach ($not_accepted as $revision_ref) { + $display_ref = $revision_ref->newRefView(); + $display_ref->appendLine( + pht( + 'Status: %s', + $revision_ref->getStatusDisplayName())); + echo tsprintf('%s', $display_ref); + } + + $query = pht( + 'Land %s revision(s) in the wrong state?', + phutil_count($not_accepted)); + + $this->getWorkflow() + ->getPrompt('arc.land.not-accepted') + ->setQuery($query) + ->execute(); + } + + $this->getWorkflow()->loadHardpoints( + $revision_refs, + array( + ArcanistRevisionRef::HARDPOINT_PARENTREVISIONREFS, + )); + + $open_parents = array(); + foreach ($revision_refs as $revision_phid => $revision_ref) { + $parent_refs = $revision_ref->getParentRevisionRefs(); + foreach ($parent_refs as $parent_ref) { + $parent_phid = $parent_ref->getPHID(); + + // If we're landing a parent revision in this operation, we don't need + // to complain that it hasn't been closed yet. + if (isset($revision_refs[$parent_phid])) { + continue; + } + + if ($parent_ref->isClosed()) { + continue; + } + + if (!isset($open_parents[$parent_phid])) { + $open_parents[$parent_phid] = array( + 'ref' => $parent_ref, + 'children' => array(), + ); + } + + $open_parents[$parent_phid]['children'][] = $revision_ref; + } + } + + if ($open_parents) { + echo tsprintf( + "\n%!\n%W\n\n", + pht('%s OPEN PARENT REVISION(S) ', phutil_count($open_parents)), + pht( + 'The changes you are landing depend on %s open parent revision(s). '. + 'Usually, you should land parent revisions before landing the '. + 'changes which depend on them. These parent revisions are open:', + phutil_count($open_parents))); + + foreach ($open_parents as $parent_phid => $spec) { + $parent_ref = $spec['ref']; + + $display_ref = $parent_ref->newRefView(); + + $display_ref->appendLine( + pht( + 'Status: %s', + $parent_ref->getStatusDisplayName())); + + foreach ($spec['children'] as $child_ref) { + $display_ref->appendLine( + pht( + 'Parent of: %s %s', + $child_ref->getMonogram(), + $child_ref->getName())); + } + + echo tsprintf('%s', $display_ref); + } + + $query = pht( + 'Land changes that depend on %s open revision(s)?', + phutil_count($open_parents)); + + $this->getWorkflow() + ->getPrompt('arc.land.open-parents') + ->setQuery($query) + ->execute(); + } + + $this->confirmBuilds($revision_refs); + + // This is a reasonable place to bulk-load the commit messages, which + // we'll need soon. + + $this->getWorkflow()->loadHardpoints( + $revision_refs, + array( + ArcanistRevisionRef::HARDPOINT_COMMITMESSAGE, + )); + } + + private function confirmBuilds(array $revision_refs) { + assert_instances_of($revision_refs, 'ArcanistRevisionRef'); + + $this->getWorkflow()->loadHardpoints( + $revision_refs, + array( + ArcanistRevisionRef::HARDPOINT_BUILDABLEREF, + )); + + $buildable_refs = array(); + foreach ($revision_refs as $revision_ref) { + $ref = $revision_ref->getBuildableRef(); + if ($ref) { + $buildable_refs[] = $ref; + } + } + + $this->getWorkflow()->loadHardpoints( + $buildable_refs, + array( + ArcanistBuildableRef::HARDPOINT_BUILDREFS, + )); + + $build_refs = array(); + foreach ($buildable_refs as $buildable_ref) { + foreach ($buildable_ref->getBuildRefs() as $build_ref) { + $build_refs[] = $build_ref; + } + } + + $this->getWorkflow()->loadHardpoints( + $build_refs, + array( + ArcanistBuildRef::HARDPOINT_BUILDPLANREF, + )); + + $problem_builds = array(); + $has_failures = false; + $has_ongoing = false; + + $build_refs = msortv($build_refs, 'getStatusSortVector'); + foreach ($build_refs as $build_ref) { + $plan_ref = $build_ref->getBuildPlanRef(); + if (!$plan_ref) { + continue; + } + + $plan_behavior = $plan_ref->getBehavior('arc-land', 'always'); + $if_building = ($plan_behavior == 'building'); + $if_complete = ($plan_behavior == 'complete'); + $if_never = ($plan_behavior == 'never'); + + // If the build plan "Never" warns when landing, skip it. + if ($if_never) { + continue; + } + + // If the build plan warns when landing "If Complete" but the build is + // not complete, skip it. + if ($if_complete && !$build_ref->isComplete()) { + continue; + } + + // If the build plan warns when landing "If Building" but the build is + // complete, skip it. + if ($if_building && $build_ref->isComplete()) { + continue; + } + + // Ignore passing builds. + if ($build_ref->isPassed()) { + continue; + } + + if ($build_ref->isComplete()) { + $has_failures = true; + } else { + $has_ongoing = true; + } + + $problem_builds[] = $build_ref; + } + + if (!$problem_builds) { + return; + } + + $build_map = array(); + $failure_map = array(); + $buildable_map = mpull($buildable_refs, null, 'getPHID'); + $revision_map = mpull($revision_refs, null, 'getDiffPHID'); + foreach ($problem_builds as $build_ref) { + $buildable_phid = $build_ref->getBuildablePHID(); + $buildable_ref = $buildable_map[$buildable_phid]; + + $object_phid = $buildable_ref->getObjectPHID(); + $revision_ref = $revision_map[$object_phid]; + + $revision_phid = $revision_ref->getPHID(); + + if (!isset($build_map[$revision_phid])) { + $build_map[$revision_phid] = array( + 'revisionRef' => $revision_ref, + 'buildRefs' => array(), + ); + } + + $build_map[$revision_phid]['buildRefs'][] = $build_ref; + } + + $log = $this->getLogEngine(); + + if ($has_failures) { + if ($has_ongoing) { + $message = pht( + '%s revision(s) have build failures or ongoing builds:', + phutil_count($build_map)); + + $query = pht( + 'Land %s revision(s) anyway, despite ongoing and failed builds?', + phutil_count($build_map)); + + } else { + $message = pht( + '%s revision(s) have build failures:', + phutil_count($build_map)); + + $query = pht( + 'Land %s revision(s) anyway, despite failed builds?', + phutil_count($build_map)); + } + + echo tsprintf( + "%!\n%s\n", + pht('BUILD FAILURES'), + $message); + + $prompt_key = 'arc.land.failed-builds'; + } else if ($has_ongoing) { + echo tsprintf( + "%!\n%s\n", + pht('ONGOING BUILDS'), + pht( + '%s revision(s) have ongoing builds:', + phutil_count($build_map))); + + $query = pht( + 'Land %s revision(s) anyway, despite ongoing builds?', + phutil_count($build_map)); + + $prompt_key = 'arc.land.ongoing-builds'; + } + + $workflow = $this->getWorkflow(); + + echo tsprintf("\n"); + foreach ($build_map as $build_item) { + $revision_ref = $build_item['revisionRef']; + $revision_view = $revision_ref->newRefView(); + + $buildable_ref = $revision_ref->getBuildableRef(); + $buildable_view = $buildable_ref->newRefView(); + + $raw_uri = $buildable_ref->getURI(); + $raw_uri = $workflow->getAbsoluteURI($raw_uri); + $buildable_view->setURI($raw_uri); + + $revision_view->addChild($buildable_view); + + foreach ($build_item['buildRefs'] as $build_ref) { + $build_view = $build_ref->newRefView(); + $buildable_view->addChild($build_view); + } + + echo tsprintf('%s', $revision_view); + echo tsprintf("\n"); + } + + $this->getWorkflow() + ->getPrompt($prompt_key) + ->setQuery($query) + ->execute(); + } + + final protected function confirmImplicitCommits(array $sets, array $symbols) { + assert_instances_of($sets, 'ArcanistLandCommitSet'); + assert_instances_of($symbols, 'ArcanistLandSymbol'); + + $implicit = array(); + foreach ($sets as $set) { + if ($set->hasImplicitCommits()) { + $implicit[] = $set; + } + } + + if (!$implicit) { + return; + } + + echo tsprintf( + "\n%!\n%W\n", + pht('IMPLICIT COMMITS'), + pht( + 'Some commits reachable from the specified sources (%s) are not '. + 'associated with revisions, and may not have been reviewed. These '. + 'commits will be landed as though they belong to the nearest '. + 'ancestor revision:', + $this->getDisplaySymbols($symbols))); + + foreach ($implicit as $set) { + $this->printCommitSet($set); + } + + $query = pht( + 'Continue with this mapping between commits and revisions?'); + + $this->getWorkflow() + ->getPrompt('arc.land.implicit') + ->setQuery($query) + ->execute(); + } + + final protected function getDisplaySymbols(array $symbols) { + $display = array(); + + foreach ($symbols as $symbol) { + $display[] = sprintf('"%s"', addcslashes($symbol->getSymbol(), '\\"')); + } + + return implode(', ', $display); + } + + final protected function printCommitSet(ArcanistLandCommitSet $set) { + $api = $this->getRepositoryAPI(); + $revision_ref = $set->getRevisionRef(); + + echo tsprintf( + "\n%s", + $revision_ref->newRefView()); + + foreach ($set->getCommits() as $commit) { + $is_implicit = $commit->getIsImplicitCommit(); + + $display_hash = $api->getDisplayHash($commit->getHash()); + $display_summary = $commit->getDisplaySummary(); + + if ($is_implicit) { + echo tsprintf( + " %s %s\n", + $display_hash, + $display_summary); + } else { + echo tsprintf( + " %s %s\n", + $display_hash, + $display_summary); + } + } + } + + final protected function loadRevisionRefs(array $commit_map) { + assert_instances_of($commit_map, 'ArcanistLandCommit'); + $api = $this->getRepositoryAPI(); + $workflow = $this->getWorkflow(); + + $state_refs = array(); + foreach ($commit_map as $commit) { + $hash = $commit->getHash(); + + $commit_ref = id(new ArcanistCommitRef()) + ->setCommitHash($hash); + + $state_ref = id(new ArcanistWorkingCopyStateRef()) + ->setCommitRef($commit_ref); + + $state_refs[$hash] = $state_ref; + } + + $force_symbol_ref = $this->getRevisionSymbolRef(); + $force_ref = null; + if ($force_symbol_ref) { + $workflow->loadHardpoints( + $force_symbol_ref, + ArcanistSymbolRef::HARDPOINT_OBJECT); + + $force_ref = $force_symbol_ref->getObject(); + if (!$force_ref) { + throw new PhutilArgumentUsageException( + pht( + 'Symbol "%s" does not identify a valid revision.', + $force_symbol_ref->getSymbol())); + } + } + + $workflow->loadHardpoints( + $state_refs, + ArcanistWorkingCopyStateRef::HARDPOINT_REVISIONREFS); + + foreach ($commit_map as $commit) { + $hash = $commit->getHash(); + $state_ref = $state_refs[$hash]; + $revision_refs = $state_ref->getRevisionRefs(); + $commit->setRelatedRevisionRefs($revision_refs); + } + + // For commits which have exactly one related revision, select it now. + + foreach ($commit_map as $commit) { + $revision_refs = $commit->getRelatedRevisionRefs(); + + if (count($revision_refs) !== 1) { + continue; + } + + $revision_ref = head($revision_refs); + $commit->setExplicitRevisionRef($revision_ref); + } + + // If we have a "--revision", select that revision for any commits with + // no known related revisions. + + // Also select that revision for any commits which have several possible + // revisions including that revision. This is relatively safe and + // reasonable and doesn't require a warning. + + if ($force_ref) { + $force_phid = $force_ref->getPHID(); + foreach ($commit_map as $commit) { + if ($commit->getExplicitRevisionRef()) { + continue; + } + + $revision_refs = $commit->getRelatedRevisionRefs(); + + if ($revision_refs) { + $revision_refs = mpull($revision_refs, null, 'getPHID'); + if (!isset($revision_refs[$force_phid])) { + continue; + } + } + + $commit->setExplicitRevisionRef($force_ref); + } + } + + // If we have a "--revision", identify any commits which it is not yet + // selected for. These are commits which are not associated with the + // identified revision but are associated with one or more other revisions. + + if ($force_ref) { + $force_phid = $force_ref->getPHID(); + + $confirm_force = array(); + foreach ($commit_map as $key => $commit) { + $revision_ref = $commit->getExplicitRevisionRef(); + + if (!$revision_ref) { + continue; + } + + if ($revision_ref->getPHID() === $force_phid) { + continue; + } + + $confirm_force[] = $commit; + } + + if ($confirm_force) { + + // TODO: Make this more clear. + // TODO: Show all the commits. + + throw new PhutilArgumentUsageException( + pht( + 'TODO: You are forcing a revision, but commits are associated '. + 'with some other revision. Are you REALLY sure you want to land '. + 'ALL these commits with a different unrelated revision???')); + } + + foreach ($confirm_force as $commit) { + $commit->setExplicitRevisionRef($force_ref); + } + } + + // Finally, raise an error if we're left with ambiguous revisions. This + // happens when we have no "--revision" and some commits in the range + // that are associated with more than one revision. + + $ambiguous = array(); + foreach ($commit_map as $commit) { + if ($commit->getExplicitRevisionRef()) { + continue; + } + + if (!$commit->getRelatedRevisionRefs()) { + continue; + } + + $ambiguous[] = $commit; + } + + if ($ambiguous) { + foreach ($ambiguous as $commit) { + $symbols = $commit->getIndirectSymbols(); + $raw_symbols = mpull($symbols, 'getSymbol'); + $symbol_list = implode(', ', $raw_symbols); + $display_hash = $api->getDisplayHash($hash); + + $revision_refs = $commit->getRelatedRevisionRefs(); + + // TODO: Include "use 'arc look --type commit abc' to figure out why" + // once that works? + + // TODO: We could print all the ambiguous commits. + + // TODO: Suggest "--pick" as a remedy once it exists? + + echo tsprintf( + "\n%!\n%W\n\n", + pht('AMBIGUOUS REVISION'), + pht( + 'The revision associated with commit "%s" (an ancestor of: %s) '. + 'is ambiguous. These %s revision(s) are associated with the '. + 'commit:', + $display_hash, + implode(', ', $raw_symbols), + phutil_count($revision_refs))); + + foreach ($revision_refs as $revision_ref) { + echo tsprintf( + '%s', + $revision_ref->newRefView()); + } + + echo tsprintf("\n"); + + throw new PhutilArgumentUsageException( + pht( + 'Revision for commit "%s" is ambiguous. Use "--revision" to force '. + 'selection of a particular revision.', + $display_hash)); + } + } + + // NOTE: We may exit this method with commits that are still unassociated. + // These will be handled later by the "implicit commits" mechanism. + } + + final protected function confirmCommits( + $into_commit, + array $symbols, + array $commit_map) { + $api = $this->getRepositoryAPI(); + + $commit_count = count($commit_map); + + if (!$commit_count) { + $message = pht( + 'There are no commits reachable from the specified sources (%s) '. + 'which are not already present in the state you are merging '. + 'into ("%s"), so nothing can land.', + $this->getDisplaySymbols($symbols), + $api->getDisplayHash($into_commit)); + + echo tsprintf( + "\n%!\n%W\n\n", + pht('NOTHING TO LAND'), + $message); + + throw new PhutilArgumentUsageException( + pht('There are no commits to land.')); + } + + // Reverse the commit list so that it's oldest-first, since this is the + // order we'll use to show revisions. + $commit_map = array_reverse($commit_map, true); + + $warn_limit = $this->getWorkflow()->getLargeWorkingSetLimit(); + $show_limit = 5; + if ($commit_count > $warn_limit) { + if ($into_commit === null) { + $message = pht( + 'There are %s commit(s) reachable from the specified sources (%s). '. + 'You are landing into the empty state, so all of these commits '. + 'will land:', + new PhutilNumber($commit_count), + $this->getDisplaySymbols($symbols)); + } else { + $message = pht( + 'There are %s commit(s) reachable from the specified sources (%s) '. + 'that are not present in the repository state you are merging '. + 'into ("%s"). All of these commits will land:', + new PhutilNumber($commit_count), + $this->getDisplaySymbols($symbols), + $api->getDisplayHash($into_commit)); + } + + echo tsprintf( + "\n%!\n%W\n", + pht('LARGE WORKING SET'), + $message); + + $display_commits = array_merge( + array_slice($commit_map, 0, $show_limit), + array(null), + array_slice($commit_map, -$show_limit)); + + echo tsprintf("\n"); + + foreach ($display_commits as $commit) { + if ($commit === null) { + echo tsprintf( + " %s\n", + pht( + '< ... %s more commits ... >', + new PhutilNumber($commit_count - ($show_limit * 2)))); + } else { + echo tsprintf( + " %s %s\n", + $api->getDisplayHash($commit->getHash()), + $commit->getDisplaySummary()); + } + } + + $query = pht( + 'Land %s commit(s)?', + new PhutilNumber($commit_count)); + + $this->getWorkflow() + ->getPrompt('arc.land.large-working-set') + ->setQuery($query) + ->execute(); + } + + // Build the commit objects into a tree. + foreach ($commit_map as $commit_hash => $commit) { + $parent_map = array(); + foreach ($commit->getParents() as $parent) { + if (isset($commit_map[$parent])) { + $parent_map[$parent] = $commit_map[$parent]; + } + } + $commit->setParentCommits($parent_map); + } + + // Identify the commits which are heads (have no children). + $child_map = array(); + foreach ($commit_map as $commit_hash => $commit) { + foreach ($commit->getParents() as $parent) { + $child_map[$parent][$commit_hash] = $commit; + } + } + + foreach ($commit_map as $commit_hash => $commit) { + if (isset($child_map[$commit_hash])) { + continue; + } + $commit->setIsHeadCommit(true); + } + + return $commit_map; + } + + public function execute() { + $api = $this->getRepositoryAPI(); + $log = $this->getLogEngine(); + + $this->validateArguments(); + + $raw_symbols = $this->getSourceRefs(); + if (!$raw_symbols) { + $raw_symbols = $this->getDefaultSymbols(); + } + + $symbols = array(); + foreach ($raw_symbols as $raw_symbol) { + $symbols[] = id(new ArcanistLandSymbol()) + ->setSymbol($raw_symbol); + } + + $this->resolveSymbols($symbols); + + $onto_remote = $this->selectOntoRemote($symbols); + $this->setOntoRemote($onto_remote); + + $onto_refs = $this->selectOntoRefs($symbols); + $this->confirmOntoRefs($onto_refs); + $this->setOntoRefs($onto_refs); + + $this->selectIntoRemote(); + $this->selectIntoRef(); + + $into_commit = $this->selectIntoCommit(); + $commit_map = $this->selectCommits($into_commit, $symbols); + + $this->loadRevisionRefs($commit_map); + + // TODO: It's possible we have a list of commits which includes disjoint + // groups of commits associated with the same revision, or groups of + // commits which do not form a range. We should test that here, since we + // can't land commit groups which are not a single contiguous range. + + $revision_groups = array(); + foreach ($commit_map as $commit_hash => $commit) { + $revision_ref = $commit->getRevisionRef(); + + if (!$revision_ref) { + echo tsprintf( + "\n%!\n%W\n\n", + pht('UNKNOWN REVISION'), + pht( + 'Unable to determine which revision is associated with commit '. + '"%s". Use "arc diff" to create or update a revision with this '. + 'commit, or "--revision" to force selection of a particular '. + 'revision.', + $api->getDisplayHash($commit_hash))); + + throw new PhutilArgumentUsageException( + pht( + 'Unable to determine revision for commit "%s".', + $api->getDisplayHash($commit_hash))); + } + + $revision_groups[$revision_ref->getPHID()][] = $commit; + } + + $commit_heads = array(); + foreach ($commit_map as $commit) { + if ($commit->getIsHeadCommit()) { + $commit_heads[] = $commit; + } + } + + $revision_order = array(); + foreach ($commit_heads as $head) { + foreach ($head->getAncestorRevisionPHIDs() as $phid) { + $revision_order[$phid] = true; + } + } + + $revision_groups = array_select_keys( + $revision_groups, + array_keys($revision_order)); + + $sets = array(); + foreach ($revision_groups as $revision_phid => $group) { + $revision_ref = head($group)->getRevisionRef(); + + $set = id(new ArcanistLandCommitSet()) + ->setRevisionRef($revision_ref) + ->setCommits($group); + + $sets[$revision_phid] = $set; + } + + $sets = $this->filterCommitSets($sets); + + if (!$this->getShouldPreview()) { + $this->confirmImplicitCommits($sets, $symbols); + } + + $log->writeStatus( + pht('LANDING'), + pht('These changes will land:')); + + foreach ($sets as $set) { + $this->printCommitSet($set); + } + + if ($this->getShouldPreview()) { + $log->writeStatus( + pht('PREVIEW'), + pht('Completed preview of land operation.')); + return; + } + + $query = pht('Land these changes?'); + $this->getWorkflow() + ->getPrompt('arc.land.confirm') + ->setQuery($query) + ->execute(); + + $this->confirmRevisions($sets); + + $workflow = $this->getWorkflow(); + + $is_incremental = $this->getIsIncremental(); + $is_hold = $this->getShouldHold(); + $is_keep = $this->getShouldKeep(); + + $local_state = $api->newLocalState() + ->setWorkflow($workflow) + ->saveLocalState(); + + $this->setLocalState($local_state); + + $seen_into = array(); + try { + $last_key = last_key($sets); + + $need_cascade = array(); + $need_prune = array(); + + foreach ($sets as $set_key => $set) { + // Add these first, so we don't add them multiple times if we need + // to retry a push. + $need_prune[] = $set; + $need_cascade[] = $set; + + while (true) { + $into_commit = $this->executeMerge($set, $into_commit); + $this->setHasUnpushedChanges(true); + + if ($is_hold) { + $should_push = false; + } else if ($is_incremental) { + $should_push = true; + } else { + $is_last = ($set_key === $last_key); + $should_push = $is_last; + } + + if ($should_push) { + try { + $this->pushChange($into_commit); + $this->setHasUnpushedChanges(false); + } catch (Exception $ex) { + + // TODO: If the push fails, fetch and retry if the remote ref + // has moved ahead of us. + + if ($this->getIntoLocal()) { + $can_retry = false; + } else if ($this->getIntoEmpty()) { + $can_retry = false; + } else if ($this->getIntoRemote() !== $this->getOntoRemote()) { + $can_retry = false; + } else { + $can_retry = false; + } + + if ($can_retry) { + // New commit state here + $into_commit = '..'; + continue; + } + + throw $ex; + } + + if ($need_cascade) { + + // NOTE: We cascade each set we've pushed, but we're going to + // cascade them from most recent to least recent. This way, + // branches which descend from more recent changes only cascade + // once, directly in to the correct state. + + $need_cascade = array_reverse($need_cascade); + foreach ($need_cascade as $cascade_set) { + $this->cascadeState($set, $into_commit); + } + $need_cascade = array(); + } + + if (!$is_keep) { + $this->pruneBranches($need_prune); + $need_prune = array(); + } + } + + break; + } + } + + if ($is_hold) { + $this->didHoldChanges($into_commit); + $local_state->discardLocalState(); + } else { + // TODO: Restore this. + // $this->getWorkflow()->askForRepositoryUpdate(); + + $this->reconcileLocalState($into_commit, $local_state); + + $log->writeSuccess( + pht('DONE'), + pht('Landed changes.')); + } + } catch (Exception $ex) { + $local_state->restoreLocalState(); + throw $ex; + } catch (Throwable $ex) { + $local_state->restoreLocalState(); + throw $ex; + } + } + + protected function validateArguments() { + $log = $this->getLogEngine(); + + $into_local = $this->getIntoLocalArgument(); + $into_empty = $this->getIntoEmptyArgument(); + $into_remote = $this->getIntoRemoteArgument(); + + $into_count = 0; + if ($into_remote !== null) { + $into_count++; + } + + if ($into_local) { + $into_count++; + } + + if ($into_empty) { + $into_count++; + } + + if ($into_count > 1) { + throw new PhutilArgumentUsageException( + pht( + 'Arguments "--into-local", "--into-remote", and "--into-empty" '. + 'are mutually exclusive.')); + } + + $into = $this->getIntoArgument(); + if ($into && $into_empty) { + throw new PhutilArgumentUsageException( + pht( + 'Arguments "--into" and "--into-empty" are mutually exclusive.')); + } + + $strategy = $this->selectMergeStrategy(); + $this->setStrategy($strategy); + + $is_pick = $this->getPickArgument(); + if ($is_pick && !$this->isSquashStrategy()) { + throw new PhutilArgumentUsageException( + pht( + 'You can not "--pick" changes under the "merge" strategy.')); + } + + // Build the symbol ref here (which validates the format of the symbol), + // but don't load the object until later on when we're sure we actually + // need it, since loading it requires a relatively expensive Conduit call. + $revision_symbol = $this->getRevisionSymbol(); + if ($revision_symbol) { + $symbol_ref = id(new ArcanistRevisionSymbolRef()) + ->setSymbol($revision_symbol); + $this->setRevisionSymbolRef($symbol_ref); + } + + // NOTE: When a user provides: "--hold" or "--preview"; and "--incremental" + // or various combinations of remote flags, the flags affecting push/remote + // behavior have no effect. + + // These combinations are allowed to support adding "--preview" or "--hold" + // to any command to run the same command with fewer side effects. + } + + abstract protected function getDefaultSymbols(); + abstract protected function resolveSymbols(array $symbols); + abstract protected function selectOntoRemote(array $symbols); + abstract protected function selectOntoRefs(array $symbols); + abstract protected function confirmOntoRefs(array $onto_refs); + abstract protected function selectIntoRemote(); + abstract protected function selectIntoRef(); + abstract protected function selectIntoCommit(); + abstract protected function selectCommits($into_commit, array $symbols); + abstract protected function executeMerge( + ArcanistLandCommitSet $set, + $into_commit); + abstract protected function pushChange($into_commit); + abstract protected function cascadeState( + ArcanistLandCommitSet $set, + $into_commit); + + protected function isSquashStrategy() { + return ($this->getStrategy() === 'squash'); + } + + abstract protected function pruneBranches(array $sets); + + abstract protected function reconcileLocalState( + $into_commit, + ArcanistRepositoryLocalState $state); + + abstract protected function didHoldChanges($into_commit); + + private function selectMergeStrategy() { + $log = $this->getLogEngine(); + + $supported_strategies = array( + 'merge', + 'squash', + ); + $supported_strategies = array_fuse($supported_strategies); + $strategy_list = implode(', ', $supported_strategies); + + $strategy = $this->getStrategyArgument(); + if ($strategy !== null) { + if (!isset($supported_strategies[$strategy])) { + throw new PhutilArgumentUsageException( + pht( + 'Merge strategy "%s" specified with "--strategy" is unknown. '. + 'Supported merge strategies are: %s.', + $strategy, + $strategy_list)); + } + + $log->writeStatus( + pht('STRATEGY'), + pht( + 'Merging with "%s" strategy, selected with "--strategy".', + $strategy)); + + return $strategy; + } + + $strategy = $this->getStrategyFromConfiguration(); + if ($strategy !== null) { + if (!isset($supported_strategies[$strategy])) { + throw new PhutilArgumentUsageException( + pht( + 'Merge strategy "%s" specified in "%s" configuration is '. + 'unknown. Supported merge strategies are: %s.', + $strategy, + $strategy_list)); + } + + $log->writeStatus( + pht('STRATEGY'), + pht( + 'Merging with "%s" strategy, configured with "%s".', + $strategy, + $this->getStrategyConfigurationKey())); + + return $strategy; + } + + $strategy = 'squash'; + + $log->writeStatus( + pht('STRATEGY'), + pht( + 'Merging with "%s" strategy, the default strategy.', + $strategy)); + + return $strategy; + } + + private function filterCommitSets(array $sets) { + assert_instances_of($sets, 'ArcanistLandCommitSet'); + $log = $this->getLogEngine(); + + // If some of the ancestor revisions are already closed, and the user did + // not specifically indicate that we should land them, and we are using + // a "squash" strategy, discard those sets. + + if ($this->isSquashStrategy()) { + $discard = array(); + foreach ($sets as $key => $set) { + $revision_ref = $set->getRevisionRef(); + + if (!$revision_ref->isClosed()) { + continue; + } + + if ($set->hasDirectSymbols()) { + continue; + } + + $discard[] = $set; + unset($sets[$key]); + } + + if ($discard) { + echo tsprintf( + "\n%!\n%W\n", + pht('DISCARDING ANCESTORS'), + pht( + 'Some ancestor commits are associated with revisions that have '. + 'already been closed. These changes will be skipped:')); + + foreach ($discard as $set) { + $this->printCommitSet($set); + } + + echo tsprintf("\n"); + } + } + + // TODO: Some of the revisions we've identified may be mapped to an + // outdated set of commits. We should look in local branches for a better + // set of commits, and try to confirm that the state we're about to land + // is the current state in Differential. + + $is_pick = $this->getPickArgument(); + if ($is_pick) { + foreach ($sets as $key => $set) { + if ($set->hasDirectSymbols()) { + $set->setIsPick(true); + continue; + } + + unset($sets[$key]); + } + } + + return $sets; + } + + final protected function newPassthruCommand($pattern /* , ... */) { + $workflow = $this->getWorkflow(); + $argv = func_get_args(); + + $api = $this->getRepositoryAPI(); + + $passthru = call_user_func_array( + array($api, 'newPassthru'), + $argv); + + $command = $workflow->newCommand($passthru) + ->setResolveOnError(true); + + return $command; + } + + final protected function newPassthru($pattern /* , ... */) { + $argv = func_get_args(); + + $command = call_user_func_array( + array($this, 'newPassthruCommand'), + $argv); + + return $command->execute(); + } + + final protected function getOntoRemoteRef() { + return id(new ArcanistRemoteRef()) + ->setRemoteName($this->getOntoRemote()); + } + +} diff --git a/src/land/engine/ArcanistMercurialLandEngine.php b/src/land/engine/ArcanistMercurialLandEngine.php new file mode 100644 index 00000000..0fb8c6a9 --- /dev/null +++ b/src/land/engine/ArcanistMercurialLandEngine.php @@ -0,0 +1,1093 @@ +getRepositoryAPI(); + $log = $this->getLogEngine(); + + // TODO: In Mercurial, you normally can not create a branch and a bookmark + // with the same name. However, you can fetch a branch or bookmark from + // a remote that has the same name as a local branch or bookmark of the + // other type, and end up with a local branch and bookmark with the same + // name. We should detect this and treat it as an error. + + // TODO: In Mercurial, you can create local bookmarks named + // "default@default" and similar which do not surive a round trip through + // a remote. Possibly, we should disallow interacting with these bookmarks. + + $markers = $api->newMarkerRefQuery() + ->withIsActive(true) + ->execute(); + + $bookmark = null; + foreach ($markers as $marker) { + if ($marker->isBookmark()) { + $bookmark = $marker->getName(); + break; + } + } + + if ($bookmark !== null) { + $log->writeStatus( + pht('SOURCE'), + pht( + 'Landing the active bookmark, "%s".', + $bookmark)); + + return array($bookmark); + } + + $branch = null; + foreach ($markers as $marker) { + if ($marker->isBranch()) { + $branch = $marker->getName(); + break; + } + } + + if ($branch !== null) { + $log->writeStatus( + pht('SOURCE'), + pht( + 'Landing the active branch, "%s".', + $branch)); + + return array($branch); + } + + $commit = $api->getCanonicalRevisionName('.'); + $commit = $api->getDisplayHash($commit); + + $log->writeStatus( + pht('SOURCE'), + pht( + 'Landing the active commit, "%s".', + $api->getDisplayHash($commit))); + + return array($commit); + } + + protected function resolveSymbols(array $symbols) { + assert_instances_of($symbols, 'ArcanistLandSymbol'); + $api = $this->getRepositoryAPI(); + + $marker_types = array( + ArcanistMarkerRef::TYPE_BOOKMARK, + ArcanistMarkerRef::TYPE_BRANCH, + ); + + $unresolved = $symbols; + foreach ($marker_types as $marker_type) { + $markers = $api->newMarkerRefQuery() + ->withMarkerTypes(array($marker_type)) + ->execute(); + + $markers = mgroup($markers, 'getName'); + + foreach ($unresolved as $key => $symbol) { + $raw_symbol = $symbol->getSymbol(); + + $named_markers = idx($markers, $raw_symbol); + if (!$named_markers) { + continue; + } + + if (count($named_markers) > 1) { + echo tsprintf( + "\n%!\n%W\n\n", + pht('AMBIGUOUS SYMBOL'), + pht( + 'Symbol "%s" is ambiguous: it matches multiple markers '. + '(of type "%s"). Use an unambiguous identifier.', + $raw_symbol, + $marker_type)); + + foreach ($named_markers as $named_marker) { + echo tsprintf('%s', $named_marker->newRefView()); + } + + echo tsprintf("\n"); + + throw new PhutilArgumentUsageException( + pht( + 'Symbol "%s" is ambiguous.', + $symbol)); + } + + $marker = head($named_markers); + + $symbol->setCommit($marker->getCommitHash()); + + unset($unresolved[$key]); + } + } + + foreach ($unresolved as $symbol) { + $raw_symbol = $symbol->getSymbol(); + + // TODO: This doesn't have accurate error behavior if the user provides + // a revset like "x::y". + try { + $commit = $api->getCanonicalRevisionName($raw_symbol); + } catch (CommandException $ex) { + $commit = null; + } + + if ($commit === null) { + throw new PhutilArgumentUsageException( + pht( + 'Symbol "%s" does not identify a bookmark, branch, or commit.', + $raw_symbol)); + } + + $symbol->setCommit($commit); + } + } + + protected function selectOntoRemote(array $symbols) { + assert_instances_of($symbols, 'ArcanistLandSymbol'); + $api = $this->getRepositoryAPI(); + + $remote = $this->newOntoRemote($symbols); + + $remote_ref = $api->newRemoteRefQuery() + ->withNames(array($remote)) + ->executeOne(); + if (!$remote_ref) { + throw new PhutilArgumentUsageException( + pht( + 'No remote "%s" exists in this repository.', + $remote)); + } + + // TODO: Allow selection of a bare URI. + + return $remote; + } + + private function newOntoRemote(array $symbols) { + assert_instances_of($symbols, 'ArcanistLandSymbol'); + $api = $this->getRepositoryAPI(); + $log = $this->getLogEngine(); + + $remote = $this->getOntoRemoteArgument(); + if ($remote !== null) { + + $log->writeStatus( + pht('ONTO REMOTE'), + pht( + 'Remote "%s" was selected with the "--onto-remote" flag.', + $remote)); + + return $remote; + } + + $remote = $this->getOntoRemoteFromConfiguration(); + if ($remote !== null) { + $remote_key = $this->getOntoRemoteConfigurationKey(); + + $log->writeStatus( + pht('ONTO REMOTE'), + pht( + 'Remote "%s" was selected by reading "%s" configuration.', + $remote, + $remote_key)); + + return $remote; + } + + $api = $this->getRepositoryAPI(); + + $default_remote = 'default'; + + $log->writeStatus( + pht('ONTO REMOTE'), + pht( + 'Landing onto remote "%s", the default remote under Mercurial.', + $default_remote)); + + return $default_remote; + } + + protected function selectOntoRefs(array $symbols) { + assert_instances_of($symbols, 'ArcanistLandSymbol'); + $log = $this->getLogEngine(); + + $onto = $this->getOntoArguments(); + if ($onto) { + + $log->writeStatus( + pht('ONTO TARGET'), + pht( + 'Refs were selected with the "--onto" flag: %s.', + implode(', ', $onto))); + + return $onto; + } + + $onto = $this->getOntoFromConfiguration(); + if ($onto) { + $onto_key = $this->getOntoConfigurationKey(); + + $log->writeStatus( + pht('ONTO TARGET'), + pht( + 'Refs were selected by reading "%s" configuration: %s.', + $onto_key, + implode(', ', $onto))); + + return $onto; + } + + $api = $this->getRepositoryAPI(); + + $default_onto = 'default'; + + $log->writeStatus( + pht('ONTO TARGET'), + pht( + 'Landing onto target "%s", the default target under Mercurial.', + $default_onto)); + + return array($default_onto); + } + + protected function confirmOntoRefs(array $onto_refs) { + $api = $this->getRepositoryAPI(); + + foreach ($onto_refs as $onto_ref) { + if (!strlen($onto_ref)) { + throw new PhutilArgumentUsageException( + pht( + 'Selected "onto" ref "%s" is invalid: the empty string is not '. + 'a valid ref.', + $onto_ref)); + } + } + + $remote_ref = $this->getOntoRemoteRef(); + + $markers = $api->newMarkerRefQuery() + ->withRemotes(array($remote_ref)) + ->execute(); + + $onto_markers = array(); + $new_markers = array(); + foreach ($onto_refs as $onto_ref) { + $matches = array(); + foreach ($markers as $marker) { + if ($marker->getName() === $onto_ref) { + $matches[] = $marker; + } + } + + $match_count = count($matches); + if ($match_count > 1) { + throw new PhutilArgumentUsageException( + pht( + 'TODO: Ambiguous ref.')); + } else if (!$match_count) { + $new_bookmark = id(new ArcanistMarkerRef()) + ->setMarkerType(ArcanistMarkerRef::TYPE_BOOKMARK) + ->setName($onto_ref) + ->attachRemoteRef($remote_ref); + + $onto_markers[] = $new_bookmark; + $new_markers[] = $new_bookmark; + } else { + $onto_markers[] = head($matches); + } + } + + $branches = array(); + foreach ($onto_markers as $onto_marker) { + if ($onto_marker->isBranch()) { + $branches[] = $onto_marker; + } + + $branch_count = count($branches); + if ($branch_count > 1) { + echo tsprintf( + "\n%!\n%W\n\n%W\n\n%W\n\n", + pht('MULTIPLE "ONTO" BRANCHES'), + pht( + 'You have selected multiple branches to push changes onto. '. + 'Pushing to multiple branches is not supported by "arc land" '. + 'in Mercurial: Mercurial commits may only belong to one '. + 'branch, so this operation can not be executed atomically.'), + pht( + 'You may land one branches and any number of bookmarks in a '. + 'single operation.'), + pht('These branches were selected:')); + + foreach ($branches as $branch) { + echo tsprintf('%s', $branch->newRefView()); + } + + echo tsprintf("\n"); + + throw new PhutilArgumentUsageException( + pht( + 'Landing onto multiple branches at once is not supported in '. + 'Mercurial.')); + } else if ($branch_count) { + $this->ontoBranchMarker = head($branches); + } + } + + if ($new_markers) { + echo tsprintf( + "\n%!\n%W\n\n", + pht('CREATE %s BOOKMARK(S)', phutil_count($new_markers)), + pht( + 'These %s symbol(s) do not exist in the remote. They will be '. + 'created as new bookmarks:', + phutil_count($new_markers))); + + + foreach ($new_markers as $new_marker) { + echo tsprintf('%s', $new_marker->newRefView()); + } + + echo tsprintf("\n"); + + $is_hold = $this->getShouldHold(); + if ($is_hold) { + echo tsprintf( + "%?\n", + pht( + 'You are using "--hold", so execution will stop before the '. + '%s bookmark(s) are actually created. You will be given '. + 'instructions to create the bookmarks.', + phutil_count($new_markers))); + } + + $query = pht( + 'Create %s new remote bookmark(s)?', + phutil_count($new_markers)); + + $this->getWorkflow() + ->getPrompt('arc.land.create') + ->setQuery($query) + ->execute(); + } + + $this->ontoMarkers = $onto_markers; + } + + protected function selectIntoRemote() { + $api = $this->getRepositoryAPI(); + $log = $this->getLogEngine(); + + if ($this->getIntoEmptyArgument()) { + $this->setIntoEmpty(true); + + $log->writeStatus( + pht('INTO REMOTE'), + pht( + 'Will merge into empty state, selected with the "--into-empty" '. + 'flag.')); + + return; + } + + if ($this->getIntoLocalArgument()) { + $this->setIntoLocal(true); + + $log->writeStatus( + pht('INTO REMOTE'), + pht( + 'Will merge into local state, selected with the "--into-local" '. + 'flag.')); + + return; + } + + $into = $this->getIntoRemoteArgument(); + if ($into !== null) { + + $remote_ref = $api->newRemoteRefQuery() + ->withNames(array($into)) + ->executeOne(); + if (!$remote_ref) { + throw new PhutilArgumentUsageException( + pht( + 'No remote "%s" exists in this repository.', + $into)); + } + + // TODO: Allow a raw URI. + + $this->setIntoRemote($into); + + $log->writeStatus( + pht('INTO REMOTE'), + pht( + 'Will merge into remote "%s", selected with the "--into" flag.', + $into)); + + return; + } + + $onto = $this->getOntoRemote(); + $this->setIntoRemote($onto); + + $log->writeStatus( + pht('INTO REMOTE'), + pht( + 'Will merge into remote "%s" by default, because this is the remote '. + 'the change is landing onto.', + $onto)); + } + + protected function selectIntoRef() { + $log = $this->getLogEngine(); + + if ($this->getIntoEmptyArgument()) { + $log->writeStatus( + pht('INTO TARGET'), + pht( + 'Will merge into empty state, selected with the "--into-empty" '. + 'flag.')); + + return; + } + + $into = $this->getIntoArgument(); + if ($into !== null) { + $this->setIntoRef($into); + + $log->writeStatus( + pht('INTO TARGET'), + pht( + 'Will merge into target "%s", selected with the "--into" flag.', + $into)); + + return; + } + + $ontos = $this->getOntoRefs(); + $onto = head($ontos); + + $this->setIntoRef($onto); + if (count($ontos) > 1) { + $log->writeStatus( + pht('INTO TARGET'), + pht( + 'Will merge into target "%s" by default, because this is the first '. + '"onto" target.', + $onto)); + } else { + $log->writeStatus( + pht('INTO TARGET'), + pht( + 'Will merge into target "%s" by default, because this is the "onto" '. + 'target.', + $onto)); + } + } + + protected function selectIntoCommit() { + $log = $this->getLogEngine(); + + if ($this->getIntoEmpty()) { + // If we're running under "--into-empty", we don't have to do anything. + + $log->writeStatus( + pht('INTO COMMIT'), + pht('Preparing merge into the empty state.')); + + return null; + } + + if ($this->getIntoLocal()) { + // If we're running under "--into-local", just make sure that the + // target identifies some actual commit. + $api = $this->getRepositoryAPI(); + $local_ref = $this->getIntoRef(); + + // TODO: This error handling could probably be cleaner, it will just + // raise an exception without any context. + + $into_commit = $api->getCanonicalRevisionName($local_ref); + + $log->writeStatus( + pht('INTO COMMIT'), + pht( + 'Preparing merge into local target "%s", at commit "%s".', + $local_ref, + $api->getDisplayHash($into_commit))); + + return $into_commit; + } + + $target = id(new ArcanistLandTarget()) + ->setRemote($this->getIntoRemote()) + ->setRef($this->getIntoRef()); + + $commit = $this->fetchTarget($target); + if ($commit !== null) { + $log->writeStatus( + pht('INTO COMMIT'), + pht( + 'Preparing merge into "%s" from remote "%s", at commit "%s".', + $target->getRef(), + $target->getRemote(), + $api->getDisplayHash($commit))); + return $commit; + } + + // If we have no valid target and the user passed "--into" explicitly, + // treat this as an error. For example, "arc land --into Q --onto Q", + // where "Q" does not exist, is an error. + if ($this->getIntoArgument()) { + throw new PhutilArgumentUsageException( + pht( + 'Ref "%s" does not exist in remote "%s".', + $target->getRef(), + $target->getRemote())); + } + + // Otherwise, treat this as implying "--into-empty". For example, + // "arc land --onto Q", where "Q" does not exist, is equivalent to + // "arc land --into-empty --onto Q". + $this->setIntoEmpty(true); + + $log->writeStatus( + pht('INTO COMMIT'), + pht( + 'Preparing merge into the empty state to create target "%s" '. + 'in remote "%s".', + $target->getRef(), + $target->getRemote())); + + return null; + } + + private function fetchTarget(ArcanistLandTarget $target) { + $api = $this->getRepositoryAPI(); + $log = $this->getLogEngine(); + + $target_name = $target->getRef(); + + $remote_ref = id(new ArcanistRemoteRef()) + ->setRemoteName($target->getRemote()); + + $markers = $api->newMarkerRefQuery() + ->withRemotes(array($remote_ref)) + ->withNames(array($target_name)) + ->execute(); + + $bookmarks = array(); + $branches = array(); + foreach ($markers as $marker) { + if ($marker->isBookmark()) { + $bookmarks[] = $marker; + } else { + $branches[] = $marker; + } + } + + if (!$bookmarks && !$branches) { + throw new PhutilArgumentUsageException( + pht( + 'Remote "%s" has no bookmark or branch named "%s".', + $target->getRemote(), + $target->getRef())); + } + + if ($bookmarks && $branches) { + echo tsprintf( + "\n%!\n%W\n\n", + pht('AMBIGUOUS MARKER'), + pht( + 'In remote "%s", the name "%s" identifies one or more branch '. + 'heads and one or more bookmarks. Close, rename, or delete all '. + 'but one of these markers, or pull the state you want to merge '. + 'into and use "--into-local --into " to disambiguate the '. + 'desired merge target.', + $target->getRemote(), + $target->getRef())); + + throw new PhutilArgumentUsageException( + pht('Merge target is ambiguous.')); + } + + if ($bookmarks) { + if (count($bookmarks) > 1) { + throw new Exception( + pht( + 'Remote "%s" has multiple bookmarks with name "%s". This '. + 'is unexpected.', + $target->getRemote(), + $target->getRef())); + } + $bookmark = head($bookmarks); + + $target_marker = $bookmark; + } + + if ($branches) { + if (count($branches) > 1) { + echo tsprintf( + "\n%!\n%W\n\n", + pht('MULTIPLE BRANCH HEADS'), + pht( + 'Remote "%s" has multiple branch heads named "%s". Close all '. + 'but one, or pull the head you want and use "--into-local '. + '--into " to specify an explicit merge target.', + $target->getRemote(), + $target->getRef())); + + throw new PhutilArgumentUsageException( + pht( + 'Remote branch has multiple heads.')); + } + + $branch = head($branches); + + $target_marker = $branch; + } + + if ($target_marker->isBranch()) { + $err = $this->newPassthru( + 'pull --branch %s -- %s', + $target->getRef(), + $target->getRemote()); + } else { + + // NOTE: This may have side effects: + // + // - It can create a "bookmark@remote" bookmark if there is a local + // bookmark with the same name that is not an ancestor. + // - It can create an arbitrary number of other bookmarks. + // + // Since these seem to generally be intentional behaviors in Mercurial, + // and should theoretically be familiar to Mercurial users, just accept + // them as the cost of doing business. + + $err = $this->newPassthru( + 'pull --bookmark %s -- %s', + $target->getRef(), + $target->getRemote()); + } + + // NOTE: It's possible that between the time we ran "ls-markers" and the + // time we ran "pull" that the remote changed. + + // It may even have been rewound or rewritten, in which case we did not + // actually fetch the ref we are about to return as a target. For now, + // assume this didn't happen: it's so unlikely that it's probably not + // worth spending 100ms to check. + + // TODO: If the Mercurial command server is revived, this check becomes + // more reasonable if it's cheap. + + return $target_marker->getCommitHash(); + } + + protected function selectCommits($into_commit, array $symbols) { + assert_instances_of($symbols, 'ArcanistLandSymbol'); + $api = $this->getRepositoryAPI(); + + $commit_map = array(); + foreach ($symbols as $symbol) { + $symbol_commit = $symbol->getCommit(); + $template = '{node}-{parents}-'; + + if ($into_commit === null) { + list($commits) = $api->execxLocal( + 'log --rev %s --template %s --', + hgsprintf('reverse(ancestors(%s))', $into_commit), + $template); + } else { + list($commits) = $api->execxLocal( + 'log --rev %s --template %s --', + hgsprintf( + 'reverse(ancestors(%s) - ancestors(%s))', + $symbol_commit, + $into_commit), + $template); + } + + $commits = phutil_split_lines($commits, false); + $is_first = true; + foreach ($commits as $line) { + if (!strlen($line)) { + continue; + } + + $parts = explode('-', $line, 3); + if (count($parts) < 3) { + throw new Exception( + pht( + 'Unexpected output from "hg log ...": %s', + $line)); + } + + $hash = $parts[0]; + if (!isset($commit_map[$hash])) { + $parents = $parts[1]; + $parents = trim($parents); + if (strlen($parents)) { + $parents = explode(' ', $parents); + } else { + $parents = array(); + } + + $summary = $parts[2]; + + $commit_map[$hash] = id(new ArcanistLandCommit()) + ->setHash($hash) + ->setParents($parents) + ->setSummary($summary); + } + + $commit = $commit_map[$hash]; + if ($is_first) { + $commit->addDirectSymbol($symbol); + $is_first = false; + } + + $commit->addIndirectSymbol($symbol); + } + } + + return $this->confirmCommits($into_commit, $symbols, $commit_map); + } + + protected function executeMerge(ArcanistLandCommitSet $set, $into_commit) { + $api = $this->getRepositoryAPI(); + + if ($this->getStrategy() !== 'squash') { + throw new Exception(pht('TODO: Support merge strategies')); + } + + // TODO: Add a Mercurial version check requiring 2.1.1 or newer. + + $api->execxLocal( + 'update --rev %s', + hgsprintf('%s', $into_commit)); + + $commits = $set->getCommits(); + + $min_commit = last($commits)->getHash(); + $max_commit = head($commits)->getHash(); + + $revision_ref = $set->getRevisionRef(); + $commit_message = $revision_ref->getCommitMessage(); + + // If we're landing "--onto" a branch, set that as the branch marker + // before creating the new commit. + + // TODO: We could skip this if we know that the "$into_commit" already + // has the right branch, which it will if we created it. + + $branch_marker = $this->ontoBranchMarker; + if ($branch_marker) { + $api->execxLocal('branch -- %s', $branch_marker->getName()); + } + + try { + $argv = array(); + $argv[] = '--dest'; + $argv[] = hgsprintf('%s', $into_commit); + + $argv[] = '--rev'; + $argv[] = hgsprintf('%s..%s', $min_commit, $max_commit); + + $argv[] = '--logfile'; + $argv[] = '-'; + + $argv[] = '--keep'; + $argv[] = '--collapse'; + + $future = $api->execFutureLocal('rebase %Ls', $argv); + $future->write($commit_message); + $future->resolvex(); + + } catch (CommandException $ex) { + // TODO + // $api->execManualLocal('rebase --abort'); + throw $ex; + } + + list($stdout) = $api->execxLocal('log --rev tip --template %s', '{node}'); + $new_cursor = trim($stdout); + + return $new_cursor; + } + + protected function pushChange($into_commit) { + $api = $this->getRepositoryAPI(); + + list($head, $body, $tail) = $this->newPushCommands($into_commit); + + foreach ($head as $command) { + $api->execxLocal('%Ls', $command); + } + + try { + foreach ($body as $command) { + $this->newPasthru('%Ls', $command); + } + } finally { + foreach ($tail as $command) { + $api->execxLocal('%Ls', $command); + } + } + } + + private function newPushCommands($into_commit) { + $api = $this->getRepositoryAPI(); + + $head_commands = array(); + $body_commands = array(); + $tail_commands = array(); + + $bookmarks = array(); + foreach ($this->ontoMarkers as $onto_marker) { + if (!$onto_marker->isBookmark()) { + continue; + } + $bookmarks[] = $onto_marker; + } + + // If we're pushing to bookmarks, move all the bookmarks we want to push + // to the merge commit. (There doesn't seem to be any way to specify + // "push commit X as bookmark Y" in Mercurial.) + + $restore = array(); + if ($bookmarks) { + $markers = $api->newMarkerRefQuery() + ->withNames(mpull($bookmarks, 'getName')) + ->withMarkerTypes(array(ArcanistMarkerRef::TYPE_BOOKMARK)) + ->execute(); + $markers = mpull($markers, 'getCommitHash', 'getName'); + + foreach ($bookmarks as $bookmark) { + $bookmark_name = $bookmark->getName(); + + $old_position = idx($markers, $bookmark_name); + $new_position = $into_commit; + + if ($old_position === $new_position) { + continue; + } + + $head_commands[] = array( + 'bookmark', + '--force', + '--rev', + hgsprintf('%s', $api->getDisplayHash($new_position)), + '--', + $bookmark_name, + ); + + $api->execxLocal( + 'bookmark --force --rev %s -- %s', + hgsprintf('%s', $new_position), + $bookmark_name); + + $restore[$bookmark_name] = $old_position; + } + } + + // Now, prepare the actual push. + + $argv = array(); + $argv[] = 'push'; + + if ($bookmarks) { + // If we're pushing at least one bookmark, we can just specify the list + // of bookmarks as things we want to push. + foreach ($bookmarks as $bookmark) { + $argv[] = '--bookmark'; + $argv[] = $bookmark->getName(); + } + } else { + // Otherwise, specify the commit itself. + $argv[] = '--rev'; + $argv[] = hgsprintf('%s', $into_commit); + } + + $argv[] = '--'; + $argv[] = $this->getOntoRemote(); + + $body_commands[] = $argv; + + // Finally, restore the bookmarks. + + foreach ($restore as $bookmark_name => $old_position) { + $tail = array(); + $tail[] = 'bookmark'; + + if ($old_position === null) { + $tail[] = '--delete'; + } else { + $tail[] = '--force'; + $tail[] = '--rev'; + $tail[] = hgsprintf('%s', $api->getDisplayHash($old_position)); + } + + $tail[] = '--'; + $tail[] = $bookmark_name; + + $tail_commands[] = $tail; + } + + return array( + $head_commands, + $body_commands, + $tail_commands, + ); + } + + protected function cascadeState(ArcanistLandCommitSet $set, $into_commit) { + $api = $this->getRepositoryAPI(); + $log = $this->getLogEngine(); + + // This has no effect when we're executing a merge strategy. + if (!$this->isSquashStrategy()) { + return; + } + + $old_commit = last($set->getCommits())->getHash(); + $new_commit = $into_commit; + + list($output) = $api->execxLocal( + 'log --rev %s --template %s', + hgsprintf('children(%s)', $old_commit), + '{node}\n'); + $child_hashes = phutil_split_lines($output, false); + + foreach ($child_hashes as $child_hash) { + if (!strlen($child_hash)) { + continue; + } + + // TODO: If the only heads which are descendants of this child will + // be deleted, we can skip this rebase? + + try { + $api->execxLocal( + 'rebase --source %s --dest %s --keep --keepbranches', + $child_hash, + $new_commit); + } catch (CommandException $ex) { + // TODO: Recover state. + throw $ex; + } + } + } + + + protected function pruneBranches(array $sets) { + assert_instances_of($sets, 'ArcanistLandCommitSet'); + $api = $this->getRepositoryAPI(); + $log = $this->getLogEngine(); + + // This has no effect when we're executing a merge strategy. + if (!$this->isSquashStrategy()) { + return; + } + + $revs = array(); + + // We've rebased all descendants already, so we can safely delete all + // of these commits. + + $sets = array_reverse($sets); + foreach ($sets as $set) { + $commits = $set->getCommits(); + + $min_commit = head($commits)->getHash(); + $max_commit = last($commits)->getHash(); + + $revs[] = hgsprintf('%s::%s', $min_commit, $max_commit); + } + + $rev_set = '('.implode(') or (', $revs).')'; + + // See PHI45. If we have "hg evolve", get rid of old commits using + // "hg prune" instead of "hg strip". + + // If we "hg strip" a commit which has an obsolete predecessor, it + // removes the obsolescence marker and revives the predecessor. This is + // not desirable: we want to destroy all predecessors of these commits. + + if ($api->getMercurialFeature('evolve')) { + $api->execxLocal( + 'prune --rev %s', + $rev_set); + } else { + $api->execxLocal( + '--config extensions.strip= strip --rev %s', + $rev_set); + } + } + + protected function reconcileLocalState( + $into_commit, + ArcanistRepositoryLocalState $state) { + + // TODO: For now, just leave users wherever they ended up. + + $state->discardLocalState(); + } + + protected function didHoldChanges($into_commit) { + $log = $this->getLogEngine(); + $local_state = $this->getLocalState(); + + $message = pht( + 'Holding changes locally, they have not been pushed.'); + + list($head, $body, $tail) = $this->newPushCommands($into_commit); + $commands = array_merge($head, $body, $tail); + + echo tsprintf( + "\n%!\n%s\n\n", + pht('HOLD CHANGES'), + $message); + + echo tsprintf( + "%s\n\n", + pht('To push changes manually, run these %s command(s):', + phutil_count($commands))); + + foreach ($commands as $command) { + echo tsprintf('%>', csprintf('hg %Ls', $command)); + } + + echo tsprintf("\n"); + + $restore_commands = $local_state->getRestoreCommandsForDisplay(); + if ($restore_commands) { + echo tsprintf( + "%s\n\n", + pht( + 'To go back to how things were before you ran "arc land", run '. + 'these %s command(s):', + phutil_count($restore_commands))); + + foreach ($restore_commands as $restore_command) { + echo tsprintf('%>', $restore_command); + } + + echo tsprintf("\n"); + } + + echo tsprintf( + "%s\n", + pht( + 'Local branches and bookmarks have not been changed, and are still '. + 'in the same state as before.')); + } + +} diff --git a/src/parser/argument/PhutilArgumentSpellingCorrector.php b/src/parser/argument/PhutilArgumentSpellingCorrector.php index dd999123..47218a5c 100644 --- a/src/parser/argument/PhutilArgumentSpellingCorrector.php +++ b/src/parser/argument/PhutilArgumentSpellingCorrector.php @@ -1,155 +1,170 @@ setInsertCost(4) ->setDeleteCost(4) ->setReplaceCost(3) ->setTransposeCost(2); return id(new self()) ->setEditDistanceMatrix($matrix) ->setMode(self::MODE_COMMANDS) ->setMaximumDistance($max_distance); } /** * Build a new corrector with parameters for correcting flags, like * fixing "--nolint" into "--no-lint". * * @return PhutilArgumentSpellingCorrector Configured corrector. */ public static function newFlagCorrector() { // When correcting flag spelling, we're stricter than we are when // correcting command spelling: we allow only one inserted or deleted // character. It is mainly to handle cases like "--no-lint" versus // "--nolint" or "--reviewer" versus "--reviewers". $max_distance = 1; $matrix = id(new PhutilEditDistanceMatrix()) ->setInsertCost(1) ->setDeleteCost(1) ->setReplaceCost(10); return id(new self()) ->setEditDistanceMatrix($matrix) ->setMode(self::MODE_FLAGS) ->setMaximumDistance($max_distance); } public function setMode($mode) { $this->mode = $mode; return $this; } public function getMode() { return $this->mode; } public function setEditDistanceMatrix(PhutilEditDistanceMatrix $matrix) { $this->editDistanceMatrix = $matrix; return $this; } public function getEditDistanceMatrix() { return $this->editDistanceMatrix; } public function setMaximumDistance($maximum_distance) { $this->maximumDistance = $maximum_distance; return $this; } public function getMaximumDistance() { return $this->maximumDistance; } public function correctSpelling($input, array $options) { $matrix = $this->getEditDistanceMatrix(); if (!$matrix) { throw new PhutilInvalidStateException('setEditDistanceMatrix'); } $max_distance = $this->getMaximumDistance(); if (!$max_distance) { throw new PhutilInvalidStateException('setMaximumDistance'); } // If we're correcting commands, never correct an input which begins // with "-", since this is almost certainly intended to be a flag. if ($this->getMode() === self::MODE_COMMANDS) { if (preg_match('/^-/', $input)) { return array(); } } $input = $this->normalizeString($input); foreach ($options as $key => $option) { $options[$key] = $this->normalizeString($option); } + // In command mode, accept any unique prefix of a command as a shorthand + // for that command. + if ($this->getMode() === self::MODE_COMMANDS) { + $prefixes = array(); + foreach ($options as $option) { + if (!strncmp($input, $option, strlen($input))) { + $prefixes[] = $option; + } + } + + if (count($prefixes) === 1) { + return $prefixes; + } + } + $distances = array(); $inputv = phutil_utf8v($input); foreach ($options as $option) { $optionv = phutil_utf8v($option); $matrix->setSequences($optionv, $inputv); $distances[$option] = $matrix->getEditDistance(); } asort($distances); $best = min($max_distance, head($distances)); foreach ($distances as $option => $distance) { if ($distance > $best) { unset($distances[$option]); } } // Before filtering, check if we have multiple equidistant matches and // return them if we do. This prevents us from, e.g., matching "alnd" with // both "land" and "amend", then dropping "land" for being too short, and // incorrectly completing to "amend". if (count($distances) > 1) { return array_keys($distances); } foreach ($distances as $option => $distance) { if (phutil_utf8_strlen($option) < $distance) { unset($distances[$option]); } } return array_keys($distances); } private function normalizeString($string) { return phutil_utf8_strtolower($string); } } diff --git a/src/parser/argument/workflow/PhutilHelpArgumentWorkflow.php b/src/parser/argument/workflow/PhutilHelpArgumentWorkflow.php index d464e1cf..c1e82c20 100644 --- a/src/parser/argument/workflow/PhutilHelpArgumentWorkflow.php +++ b/src/parser/argument/workflow/PhutilHelpArgumentWorkflow.php @@ -1,45 +1,66 @@ workflow = $workflow; + return $this; + } + + public function getWorkflow() { + return $this->workflow; + } + protected function didConstruct() { $this->setName('help'); $this->setExamples(<<setSynopsis(<<setArguments( array( array( 'name' => 'help-with-what', 'wildcard' => true, ), )); } public function isExecutable() { return true; } public function execute(PhutilArgumentParser $args) { $with = $args->getArg('help-with-what'); if (!$with) { + // TODO: Update this to use a pager, too. + $args->printHelpAndExit(); } else { + $out = array(); foreach ($with as $thing) { - echo phutil_console_format( + $out[] = phutil_console_format( "**%s**\n\n", pht('%s WORKFLOW', strtoupper($thing))); - echo $args->renderWorkflowHelp($thing, $show_flags = true); - echo "\n"; + $out[] = $args->renderWorkflowHelp($thing, $show_flags = true); + $out[] = "\n"; + } + $out = implode('', $out); + + $workflow = $this->getWorkflow(); + if ($workflow) { + $workflow->writeToPager($out); + } else { + echo $out; } - exit(PhutilArgumentParser::PARSE_ERROR_CODE); } } } diff --git a/src/query/ArcanistMercurialCommitMessageHardpointQuery.php b/src/query/ArcanistMercurialCommitMessageHardpointQuery.php new file mode 100644 index 00000000..45dc8ee3 --- /dev/null +++ b/src/query/ArcanistMercurialCommitMessageHardpointQuery.php @@ -0,0 +1,36 @@ +getRepositoryAPI(); + + $hashes = mpull($refs, 'getCommitHash'); + $unique_hashes = array_fuse($hashes); + + // TODO: Batch this properly and make it future oriented. + + $messages = array(); + foreach ($unique_hashes as $unique_hash) { + $messages[$unique_hash] = $api->getCommitMessage($unique_hash); + } + + foreach ($hashes as $ref_key => $hash) { + $hashes[$ref_key] = $messages[$hash]; + } + + yield $this->yieldMap($hashes); + } + +} diff --git a/src/query/ArcanistMercurialWorkingCopyRevisionHardpointQuery.php b/src/query/ArcanistMercurialWorkingCopyRevisionHardpointQuery.php new file mode 100644 index 00000000..c6c03cf3 --- /dev/null +++ b/src/query/ArcanistMercurialWorkingCopyRevisionHardpointQuery.php @@ -0,0 +1,76 @@ +yieldRequests( + $refs, + array( + ArcanistWorkingCopyStateRef::HARDPOINT_COMMITREF, + )); + + // TODO: This has a lot in common with the Git query in the same role. + + $hashes = array(); + $map = array(); + foreach ($refs as $ref_key => $ref) { + $commit = $ref->getCommitRef(); + + $commit_hashes = array(); + + $commit_hashes[] = array( + 'hgcm', + $commit->getCommitHash(), + ); + + foreach ($commit_hashes as $hash) { + $hashes[] = $hash; + $hash_key = $this->getHashKey($hash); + $map[$hash_key][$ref_key] = $ref; + } + } + + $results = array_fill_keys(array_keys($refs), array()); + if ($hashes) { + $revisions = (yield $this->yieldConduit( + 'differential.query', + array( + 'commitHashes' => $hashes, + ))); + + foreach ($revisions as $dict) { + $revision_hashes = idx($dict, 'hashes'); + if (!$revision_hashes) { + continue; + } + + $revision_ref = ArcanistRevisionRef::newFromConduitQuery($dict); + foreach ($revision_hashes as $revision_hash) { + $hash_key = $this->getHashKey($revision_hash); + $state_refs = idx($map, $hash_key, array()); + foreach ($state_refs as $ref_key => $state_ref) { + $results[$ref_key][] = $revision_ref; + } + } + } + } + + yield $this->yieldMap($results); + } + + private function getHashKey(array $hash) { + return $hash[0].':'.$hash[1]; + } + +} diff --git a/src/query/ArcanistWorkflowMercurialHardpointQuery.php b/src/query/ArcanistWorkflowMercurialHardpointQuery.php new file mode 100644 index 00000000..b1932ae6 --- /dev/null +++ b/src/query/ArcanistWorkflowMercurialHardpointQuery.php @@ -0,0 +1,11 @@ +getRepositoryAPI(); + return ($api instanceof ArcanistMercurialAPI); + } + +} diff --git a/src/query/ArcanistWorkingCopyCommitHardpointQuery.php b/src/query/ArcanistWorkingCopyCommitHardpointQuery.php deleted file mode 100644 index 9f2b4672..00000000 --- a/src/query/ArcanistWorkingCopyCommitHardpointQuery.php +++ /dev/null @@ -1,39 +0,0 @@ -yieldRequests( - $refs, - array( - ArcanistWorkingCopyStateRef::HARDPOINT_BRANCHREF, - )); - - $branch_refs = mpull($refs, 'getBranchRef'); - - yield $this->yieldRequests( - $branch_refs, - array( - ArcanistBranchRef::HARDPOINT_COMMITREF, - )); - - $results = array(); - foreach ($refs as $key => $ref) { - $results[$key] = $ref->getBranchRef()->getCommitRef(); - } - - yield $this->yieldMap($results); - } - -} diff --git a/src/ref/ArcanistBranchRef.php b/src/ref/ArcanistBranchRef.php deleted file mode 100644 index 067e5a2e..00000000 --- a/src/ref/ArcanistBranchRef.php +++ /dev/null @@ -1,57 +0,0 @@ -getBranchName()); - } - - protected function newHardpoints() { - return array( - $this->newHardpoint(self::HARDPOINT_COMMITREF), - ); - } - - public function setBranchName($branch_name) { - $this->branchName = $branch_name; - return $this; - } - - public function getBranchName() { - return $this->branchName; - } - - public function setRefName($ref_name) { - $this->refName = $ref_name; - return $this; - } - - public function getRefName() { - return $this->refName; - } - - public function setIsCurrentBranch($is_current_branch) { - $this->isCurrentBranch = $is_current_branch; - return $this; - } - - public function getIsCurrentBranch() { - return $this->isCurrentBranch; - } - - public function attachCommitRef(ArcanistCommitRef $ref) { - return $this->attachHardpoint(self::HARDPOINT_COMMITREF, $ref); - } - - public function getCommitRef() { - return $this->getHardpoint(self::HARDPOINT_COMMITREF); - } - -} diff --git a/src/ref/ArcanistBuildPlanRef.php b/src/ref/ArcanistBuildPlanRef.php deleted file mode 100644 index bc4a66d6..00000000 --- a/src/ref/ArcanistBuildPlanRef.php +++ /dev/null @@ -1,25 +0,0 @@ -parameters = $data; - return $ref; - } - - public function getPHID() { - return $this->parameters['phid']; - } - - public function getBehavior($behavior_key, $default = null) { - return idxv( - $this->parameters, - array('fields', 'behaviors', $behavior_key, 'value'), - $default); - } - -} diff --git a/src/ref/ArcanistBuildRef.php b/src/ref/ArcanistBuildRef.php deleted file mode 100644 index e6ca3855..00000000 --- a/src/ref/ArcanistBuildRef.php +++ /dev/null @@ -1,140 +0,0 @@ -parameters = $data; - return $ref; - } - - private function getStatusMap() { - // The modern "harbormaster.build.search" API method returns this in the - // "fields" list; the older API method returns it at the root level. - if (isset($this->parameters['fields']['buildStatus'])) { - $status = $this->parameters['fields']['buildStatus']; - } else if (isset($this->parameters['buildStatus'])) { - $status = $this->parameters['buildStatus']; - } else { - $status = 'unknown'; - } - - // We may either have an array or a scalar here. The array comes from - // "harbormaster.build.search", or from "harbormaster.querybuilds" if - // the server is newer than August 2016. The scalar comes from older - // versions of that method. See PHI261. - if (is_array($status)) { - $map = $status; - } else { - $map = array( - 'value' => $status, - ); - } - - // If we don't have a name, try to fill one in. - if (!isset($map['name'])) { - $name_map = array( - 'inactive' => pht('Inactive'), - 'pending' => pht('Pending'), - 'building' => pht('Building'), - 'passed' => pht('Passed'), - 'failed' => pht('Failed'), - 'aborted' => pht('Aborted'), - 'error' => pht('Error'), - 'paused' => pht('Paused'), - 'deadlocked' => pht('Deadlocked'), - 'unknown' => pht('Unknown'), - ); - - $map['name'] = idx($name_map, $map['value'], $map['value']); - } - - // If we don't have an ANSI color code, try to fill one in. - if (!isset($map['color.ansi'])) { - $color_map = array( - 'failed' => 'red', - 'passed' => 'green', - ); - - $map['color.ansi'] = idx($color_map, $map['value'], 'yellow'); - } - - return $map; - } - - public function getID() { - return $this->parameters['id']; - } - - public function getPHID() { - return $this->parameters['phid']; - } - - public function getName() { - if (isset($this->parameters['fields']['name'])) { - return $this->parameters['fields']['name']; - } - - return $this->parameters['name']; - } - - public function getStatus() { - $map = $this->getStatusMap(); - return $map['value']; - } - - public function getStatusName() { - $map = $this->getStatusMap(); - return $map['name']; - } - - public function getStatusANSIColor() { - $map = $this->getStatusMap(); - return $map['color.ansi']; - } - - public function getObjectName() { - return pht('Build %d', $this->getID()); - } - - public function getBuildPlanPHID() { - return idxv($this->parameters, array('fields', 'buildPlanPHID')); - } - - public function isComplete() { - switch ($this->getStatus()) { - case 'passed': - case 'failed': - case 'aborted': - case 'error': - case 'deadlocked': - return true; - default: - return false; - } - } - - public function isPassed() { - return ($this->getStatus() === 'passed'); - } - - public function getStatusSortVector() { - $status = $this->getStatus(); - - // For now, just sort passed builds first. - if ($this->isPassed()) { - $status_class = 1; - } else { - $status_class = 2; - } - - return id(new PhutilSortVector()) - ->addInt($status_class) - ->addString($status); - } - - -} diff --git a/src/ref/ArcanistDisplayRefInterface.php b/src/ref/ArcanistDisplayRefInterface.php deleted file mode 100644 index 550763ec..00000000 --- a/src/ref/ArcanistDisplayRefInterface.php +++ /dev/null @@ -1,8 +0,0 @@ -setRef($this); + + $this->buildRefView($ref_view); + + return $ref_view; } + + protected function buildRefView(ArcanistRefView $view) { + return null; + } + } diff --git a/src/ref/ArcanistDisplayRef.php b/src/ref/ArcanistRefView.php similarity index 53% rename from src/ref/ArcanistDisplayRef.php rename to src/ref/ArcanistRefView.php index c4e7a5d5..bb166aca 100644 --- a/src/ref/ArcanistDisplayRef.php +++ b/src/ref/ArcanistRefView.php @@ -1,89 +1,146 @@ ref = $ref; return $this; } public function getRef() { return $this->ref; } + public function setObjectName($object_name) { + $this->objectName = $object_name; + return $this; + } + + public function getObjectName() { + return $this->objectName; + } + + public function setTitle($title) { + $this->title = $title; + return $this; + } + + public function getTitle() { + return $this->title; + } + public function setURI($uri) { $this->uri = $uri; return $this; } public function getURI() { return $this->uri; } + public function addChild(ArcanistRefView $view) { + $this->children[] = $view; + return $this; + } + + private function getChildren() { + return $this->children; + } + + public function appendLine($line) { + $this->lines[] = $line; + return $this; + } + public function newTerminalString() { + return $this->newLines(0); + } + + private function newLines($indent) { $ref = $this->getRef(); - if ($ref instanceof ArcanistDisplayRefInterface) { - $object_name = $ref->getDisplayRefObjectName(); - $title = $ref->getDisplayRefTitle(); - } else { - $object_name = null; - $title = $ref->getRefDisplayName(); - } + $object_name = $this->getObjectName(); + $title = $this->getTitle(); if ($object_name !== null) { $reserve_width = phutil_utf8_console_strlen($object_name) + 1; } else { $reserve_width = 0; } + if ($indent) { + $indent_text = str_repeat(' ', $indent); + } else { + $indent_text = ''; + } + $indent_width = strlen($indent_text); + $marker_width = 6; $display_width = phutil_console_get_terminal_width(); $usable_width = ($display_width - $marker_width - $reserve_width); + $usable_width = ($usable_width - $indent_width); // If the terminal is extremely narrow, don't degrade so much that the // output is completely unusable. $usable_width = max($usable_width, 16); // TODO: This should truncate based on console display width, not // glyphs, but there's currently no "setMaximumConsoleCharacterWidth()". $title = id(new PhutilUTF8StringTruncator()) ->setMaximumGlyphs($usable_width) ->truncateString($title); if ($object_name !== null) { if (strlen($title)) { $display_text = tsprintf('**%s** %s', $object_name, $title); } else { $display_text = tsprintf('**%s**', $object_name); } } else { $display_text = $title; } - $ref = $this->getRef(); $output = array(); $output[] = tsprintf( - "** * ** %s\n", + "** * ** %s%s\n", + $indent_text, $display_text); $uri = $this->getURI(); if ($uri !== null) { $output[] = tsprintf( - "** :// ** __%s__\n", + "** :// ** %s__%s__\n", + $indent_text, $uri); } + foreach ($this->lines as $line) { + $output[] = tsprintf( + " %s%s\n", + $indent_text, + $line); + } + + foreach ($this->getChildren() as $child) { + foreach ($child->newLines($indent + 1) as $line) { + $output[] = $line; + } + } + return $output; } } diff --git a/src/ref/ArcanistRepositoryRef.php b/src/ref/ArcanistRepositoryRef.php index e7e7a2ee..b0122ab4 100644 --- a/src/ref/ArcanistRepositoryRef.php +++ b/src/ref/ArcanistRepositoryRef.php @@ -1,75 +1,146 @@ phid = $phid; return $this; } public function getPHID() { return $this->phid; } public function setBrowseURI($browse_uri) { $this->browseURI = $browse_uri; return $this; } + public static function newFromConduit(array $map) { + $ref = new self(); + $ref->parameters = $map; + + $ref->phid = $map['phid']; + + return $ref; + } + + public function getURIs() { + $uris = idxv($this->parameters, array('attachments', 'uris', 'uris')); + + if (!$uris) { + return array(); + } + + $results = array(); + foreach ($uris as $uri) { + $effective_uri = idxv($uri, array('fields', 'uri', 'effective')); + if ($effective_uri !== null) { + $results[] = $effective_uri; + } + } + + return $results; + } + + public function getDisplayName() { + return idxv($this->parameters, array('fields', 'name')); + } + public function newBrowseURI(array $params) { PhutilTypeSpec::checkMap( $params, array( 'path' => 'optional string|null', 'branch' => 'optional string|null', 'lines' => 'optional string|null', )); foreach ($params as $key => $value) { if (!strlen($value)) { unset($params[$key]); } } $defaults = array( 'path' => '/', 'branch' => $this->getDefaultBranch(), 'lines' => null, ); $params = $params + $defaults; $uri_base = $this->browseURI; $uri_base = rtrim($uri_base, '/'); $uri_branch = phutil_escape_uri_path_component($params['branch']); $uri_path = ltrim($params['path'], '/'); $uri_path = phutil_escape_uri($uri_path); $uri_lines = null; if ($params['lines']) { $uri_lines = '$'.phutil_escape_uri($params['lines']); } // TODO: This construction, which includes a branch, is probably wrong for // Subversion. return "{$uri_base}/browse/{$uri_branch}/{$uri_path}{$uri_lines}"; } public function getDefaultBranch() { - // TODO: This should read from the remote, and is not correct for - // Mercurial anyway, as "default" would be a better default branch. - return 'master'; + $branch = idxv($this->parameters, array('fields', 'defaultBranch')); + + if ($branch === null) { + return 'master'; + } + + return $branch; + } + + public function isPermanentRef(ArcanistMarkerRef $ref) { + $rules = idxv( + $this->parameters, + array('fields', 'refRules', 'permanentRefRules')); + + if ($rules === null) { + return false; + } + + // If the rules exist but there are no specified rules, treat every ref + // as permanent. + if (!$rules) { + return true; + } + + // TODO: It would be nice to unify evaluation of permanent ref rules + // across Arcanist and Phabricator. + + $ref_name = $ref->getName(); + foreach ($rules as $rule) { + $matches = null; + if (preg_match('(^regexp\\((.*)\\)\z)', $rule, $matches)) { + if (preg_match($matches[1], $ref_name)) { + return true; + } + } else { + if ($rule === $ref_name) { + return true; + } + } + } + + return false; } } diff --git a/src/ref/build/ArcanistBuildBuildplanHardpointQuery.php b/src/ref/build/ArcanistBuildBuildplanHardpointQuery.php new file mode 100644 index 00000000..8e386527 --- /dev/null +++ b/src/ref/build/ArcanistBuildBuildplanHardpointQuery.php @@ -0,0 +1,44 @@ +yieldConduitSearch( + 'harbormaster.buildplan.search', + array( + 'phids' => $plan_phids, + ))); + + $plan_refs = array(); + foreach ($plans as $plan) { + $plan_ref = ArcanistBuildPlanRef::newFromConduit($plan); + $plan_refs[] = $plan_ref; + } + $plan_refs = mpull($plan_refs, null, 'getPHID'); + + $results = array(); + foreach ($refs as $key => $build_ref) { + $plan_phid = $build_ref->getBuildPlanPHID(); + $plan = idx($plan_refs, $plan_phid); + $results[$key] = $plan; + } + + yield $this->yieldMap($results); + } + +} diff --git a/src/ref/build/ArcanistBuildRef.php b/src/ref/build/ArcanistBuildRef.php new file mode 100644 index 00000000..7b1fe227 --- /dev/null +++ b/src/ref/build/ArcanistBuildRef.php @@ -0,0 +1,102 @@ +newHardpoint(self::HARDPOINT_BUILDPLANREF), + ); + } + + public function getRefDisplayName() { + return pht('Build %d', $this->getID()); + } + + public static function newFromConduit(array $parameters) { + $ref = new self(); + $ref->parameters = $parameters; + return $ref; + } + + public function getID() { + return idx($this->parameters, 'id'); + } + + public function getPHID() { + return idx($this->parameters, 'phid'); + } + + public function getName() { + return idxv($this->parameters, array('fields', 'name')); + } + + protected function buildRefView(ArcanistRefView $view) { + $view + ->setObjectName($this->getRefDisplayName()) + ->setTitle($this->getName()); + } + + public function getBuildPlanRef() { + return $this->getHardpoint(self::HARDPOINT_BUILDPLANREF); + } + + public function getBuildablePHID() { + return idxv($this->parameters, array('fields', 'buildablePHID')); + } + + public function getBuildPlanPHID() { + return idxv($this->parameters, array('fields', 'buildPlanPHID')); + } + + public function getStatus() { + return idxv($this->parameters, array('fields', 'buildStatus', 'value')); + } + + public function getStatusName() { + return idxv($this->parameters, array('fields', 'buildStatus', 'name')); + } + + public function getStatusANSIColor() { + return idxv( + $this->parameters, + array('fields', 'buildStatus', 'color.ansi')); + } + + public function isComplete() { + switch ($this->getStatus()) { + case 'passed': + case 'failed': + case 'aborted': + case 'error': + case 'deadlocked': + return true; + default: + return false; + } + } + + public function isPassed() { + return ($this->getStatus() === 'passed'); + } + + public function getStatusSortVector() { + $status = $this->getStatus(); + + // For now, just sort passed builds first. + if ($this->isPassed()) { + $status_class = 1; + } else { + $status_class = 2; + } + + return id(new PhutilSortVector()) + ->addInt($status_class) + ->addString($status); + } + +} diff --git a/src/ref/build/ArcanistBuildSymbolRef.php b/src/ref/build/ArcanistBuildSymbolRef.php new file mode 100644 index 00000000..06122764 --- /dev/null +++ b/src/ref/build/ArcanistBuildSymbolRef.php @@ -0,0 +1,30 @@ +getSymbol()); + } + + protected function getSimpleSymbolPHIDType() { + return 'HMBD'; + } + + public function getSimpleSymbolConduitSearchMethodName() { + return 'harbormaster.build.search'; + } + + public function getSimpleSymbolConduitSearchAttachments() { + return array(); + } + + public function getSimpleSymbolInspectFunctionName() { + return 'build'; + } + + public function newSimpleSymbolObjectRef() { + return new ArcanistBuildRef(); + } + +} diff --git a/src/ref/buildable/ArcanistBuildableBuildsHardpointQuery.php b/src/ref/buildable/ArcanistBuildableBuildsHardpointQuery.php new file mode 100644 index 00000000..11ad03d7 --- /dev/null +++ b/src/ref/buildable/ArcanistBuildableBuildsHardpointQuery.php @@ -0,0 +1,43 @@ +yieldConduitSearch( + 'harbormaster.build.search', + array( + 'buildables' => $buildable_phids, + ))); + + $build_refs = array(); + foreach ($builds as $build) { + $build_ref = ArcanistBuildRef::newFromConduit($build); + $build_refs[] = $build_ref; + } + + $build_refs = mgroup($build_refs, 'getBuildablePHID'); + + $results = array(); + foreach ($refs as $key => $buildable_ref) { + $buildable_phid = $buildable_ref->getPHID(); + $buildable_builds = idx($build_refs, $buildable_phid, array()); + $results[$key] = $buildable_builds; + } + + yield $this->yieldMap($results); + } + +} diff --git a/src/ref/buildable/ArcanistBuildableRef.php b/src/ref/buildable/ArcanistBuildableRef.php new file mode 100644 index 00000000..b91df702 --- /dev/null +++ b/src/ref/buildable/ArcanistBuildableRef.php @@ -0,0 +1,65 @@ +newTemplateHardpoint( + self::HARDPOINT_BUILDREFS, + $object_list), + ); + } + + public function getRefDisplayName() { + return pht('Buildable "%s"', $this->getMonogram()); + } + + public static function newFromConduit(array $parameters) { + $ref = new self(); + $ref->parameters = $parameters; + return $ref; + } + + public function getID() { + return idx($this->parameters, 'id'); + } + + public function getPHID() { + return idx($this->parameters, 'phid'); + } + + public function getObjectPHID() { + return idxv($this->parameters, array('fields', 'objectPHID')); + } + + public function getMonogram() { + return 'B'.$this->getID(); + } + + protected function buildRefView(ArcanistRefView $view) { + $view + ->setObjectName($this->getMonogram()) + ->setTitle($this->getRefDisplayName()); + } + + public function getBuildRefs() { + return $this->getHardpoint(self::HARDPOINT_BUILDREFS); + } + + public function getURI() { + $uri = idxv($this->parameters, array('fields', 'uri')); + + if ($uri === null) { + $uri = '/'.$this->getMonogram(); + } + + return $uri; + } + +} diff --git a/src/ref/buildable/ArcanistBuildableSymbolRef.php b/src/ref/buildable/ArcanistBuildableSymbolRef.php new file mode 100644 index 00000000..e16df50f --- /dev/null +++ b/src/ref/buildable/ArcanistBuildableSymbolRef.php @@ -0,0 +1,30 @@ +getSymbol()); + } + + protected function getSimpleSymbolPHIDType() { + return 'HMBB'; + } + + public function getSimpleSymbolConduitSearchMethodName() { + return 'harbormaster.buildable.search'; + } + + public function getSimpleSymbolConduitSearchAttachments() { + return array(); + } + + public function getSimpleSymbolInspectFunctionName() { + return 'buildable'; + } + + public function newSimpleSymbolObjectRef() { + return new ArcanistBuildableRef(); + } + +} diff --git a/src/ref/task/ArcanistTaskRef.php b/src/ref/buildplan/ArcanistBuildPlanRef.php similarity index 51% copy from src/ref/task/ArcanistTaskRef.php copy to src/ref/buildplan/ArcanistBuildPlanRef.php index d446b8df..ea0ad609 100644 --- a/src/ref/task/ArcanistTaskRef.php +++ b/src/ref/buildplan/ArcanistBuildPlanRef.php @@ -1,44 +1,43 @@ getMonogram()); + return pht('Build Plan %d', $this->getID()); } public static function newFromConduit(array $parameters) { $ref = new self(); $ref->parameters = $parameters; return $ref; } public function getID() { return idx($this->parameters, 'id'); } public function getPHID() { return idx($this->parameters, 'phid'); } public function getName() { return idxv($this->parameters, array('fields', 'name')); } - public function getMonogram() { - return 'T'.$this->getID(); + public function getBehavior($behavior_key, $default = null) { + return idxv( + $this->parameters, + array('fields', 'behaviors', $behavior_key, 'value'), + $default); } - public function getDisplayRefObjectName() { - return $this->getMonogram(); - } - - public function getDisplayRefTitle() { - return $this->getName(); + protected function buildRefView(ArcanistRefView $view) { + $view + ->setObjectName($this->getRefDisplayName()) + ->setTitle($this->getName()); } } diff --git a/src/ref/buildplan/ArcanistBuildPlanSymbolRef.php b/src/ref/buildplan/ArcanistBuildPlanSymbolRef.php new file mode 100644 index 00000000..9d242268 --- /dev/null +++ b/src/ref/buildplan/ArcanistBuildPlanSymbolRef.php @@ -0,0 +1,30 @@ +getSymbol()); + } + + protected function getSimpleSymbolPHIDType() { + return 'HMCP'; + } + + public function getSimpleSymbolConduitSearchMethodName() { + return 'harbormaster.buildplan.search'; + } + + public function getSimpleSymbolConduitSearchAttachments() { + return array(); + } + + public function getSimpleSymbolInspectFunctionName() { + return 'buildplan'; + } + + public function newSimpleSymbolObjectRef() { + return new ArcanistBuildPlanRef(); + } + +} diff --git a/src/ref/file/ArcanistFileRef.php b/src/ref/file/ArcanistFileRef.php index 37fcf520..615987e9 100644 --- a/src/ref/file/ArcanistFileRef.php +++ b/src/ref/file/ArcanistFileRef.php @@ -1,62 +1,58 @@ getMonogram()); } public static function newFromConduit(array $parameters) { $ref = new self(); $ref->parameters = $parameters; return $ref; } public function getID() { return idx($this->parameters, 'id'); } public function getPHID() { return idx($this->parameters, 'phid'); } public function getName() { return idxv($this->parameters, array('fields', 'name')); } public function getDataURI() { return idxv($this->parameters, array('fields', 'dataURI')); } public function getSize() { return idxv($this->parameters, array('fields', 'size')); } public function getURI() { $uri = idxv($this->parameters, array('fields', 'uri')); if ($uri === null) { $uri = '/'.$this->getMonogram(); } return $uri; } public function getMonogram() { return 'F'.$this->getID(); } - public function getDisplayRefObjectName() { - return $this->getMonogram(); - } - - public function getDisplayRefTitle() { - return $this->getName(); + protected function buildRefView(ArcanistRefView $view) { + $view + ->setObjectName($this->getMonogram()) + ->setTitle($this->getName()); } } diff --git a/src/ref/paste/ArcanistPasteRef.php b/src/ref/paste/ArcanistPasteRef.php index 2c4bb58d..d987656d 100644 --- a/src/ref/paste/ArcanistPasteRef.php +++ b/src/ref/paste/ArcanistPasteRef.php @@ -1,58 +1,54 @@ getMonogram()); } public static function newFromConduit(array $parameters) { $ref = new self(); $ref->parameters = $parameters; return $ref; } public function getID() { return idx($this->parameters, 'id'); } public function getPHID() { return idx($this->parameters, 'phid'); } public function getTitle() { return idxv($this->parameters, array('fields', 'title')); } public function getURI() { $uri = idxv($this->parameters, array('fields', 'uri')); if ($uri === null) { $uri = '/'.$this->getMonogram(); } return $uri; } public function getContent() { return idxv($this->parameters, array('attachments', 'content', 'content')); } public function getMonogram() { return 'P'.$this->getID(); } - public function getDisplayRefObjectName() { - return $this->getMonogram(); - } - - public function getDisplayRefTitle() { - return $this->getTitle(); + protected function buildRefView(ArcanistRefView $view) { + $view + ->setObjectName($this->getMonogram()) + ->setTitle($this->getTitle()); } } diff --git a/src/ref/revision/ArcanistRevisionAuthorHardpointQuery.php b/src/ref/revision/ArcanistRevisionAuthorHardpointQuery.php new file mode 100644 index 00000000..df6e1261 --- /dev/null +++ b/src/ref/revision/ArcanistRevisionAuthorHardpointQuery.php @@ -0,0 +1,35 @@ + $ref) { + $symbols[$key] = id(new ArcanistUserSymbolRef()) + ->setSymbol($ref->getAuthorPHID()); + } + + yield $this->yieldRequests( + $symbols, + array( + ArcanistSymbolRef::HARDPOINT_OBJECT, + )); + + $results = mpull($symbols, 'getObject'); + + yield $this->yieldMap($results); + } + +} diff --git a/src/ref/revision/ArcanistRevisionBuildableHardpointQuery.php b/src/ref/revision/ArcanistRevisionBuildableHardpointQuery.php new file mode 100644 index 00000000..c0ff6e72 --- /dev/null +++ b/src/ref/revision/ArcanistRevisionBuildableHardpointQuery.php @@ -0,0 +1,60 @@ + $revision_ref) { + $diff_phid = $revision_ref->getDiffPHID(); + if ($diff_phid) { + $diff_map[$key] = $diff_phid; + } + } + + if (!$diff_map) { + yield $this->yieldValue($refs, null); + } + + $buildables = (yield $this->yieldConduitSearch( + 'harbormaster.buildable.search', + array( + 'objectPHIDs' => array_values($diff_map), + 'manual' => false, + ))); + + $buildable_refs = array(); + foreach ($buildables as $buildable) { + $buildable_ref = ArcanistBuildableRef::newFromConduit($buildable); + $object_phid = $buildable_ref->getObjectPHID(); + $buildable_refs[$object_phid] = $buildable_ref; + } + + $results = array_fill_keys(array_keys($refs), null); + foreach ($refs as $key => $revision_ref) { + if (!isset($diff_map[$key])) { + continue; + } + + $diff_phid = $diff_map[$key]; + if (!isset($buildable_refs[$diff_phid])) { + continue; + } + + $results[$key] = $buildable_refs[$diff_phid]; + } + + yield $this->yieldMap($results); + } + +} diff --git a/src/ref/revision/ArcanistRevisionParentRevisionsHardpointQuery.php b/src/ref/revision/ArcanistRevisionParentRevisionsHardpointQuery.php new file mode 100644 index 00000000..141e2d64 --- /dev/null +++ b/src/ref/revision/ArcanistRevisionParentRevisionsHardpointQuery.php @@ -0,0 +1,82 @@ + mpull($refs, 'getPHID'), + 'types' => array( + 'revision.parent', + ), + ); + + $data = array(); + while (true) { + $results = (yield $this->yieldConduit( + 'edge.search', + $parameters)); + + foreach ($results['data'] as $item) { + $data[] = $item; + } + + if ($results['cursor']['after'] === null) { + break; + } + + $parameters['after'] = $results['cursor']['after']; + } + + if (!$data) { + yield $this->yieldValue($refs, array()); + } + + $map = array(); + $symbols = array(); + foreach ($data as $edge) { + $src = $edge['sourcePHID']; + $dst = $edge['destinationPHID']; + + $map[$src][$dst] = $dst; + + $symbols[$dst] = id(new ArcanistRevisionSymbolRef()) + ->setSymbol($dst); + } + + yield $this->yieldRequests( + $symbols, + array( + ArcanistSymbolRef::HARDPOINT_OBJECT, + )); + + $objects = array(); + foreach ($symbols as $key => $symbol) { + $object = $symbol->getObject(); + if ($object) { + $objects[$key] = $object; + } + } + + $results = array_fill_keys(array_keys($refs), array()); + foreach ($refs as $ref_key => $ref) { + $revision_phid = $ref->getPHID(); + $parent_phids = idx($map, $revision_phid, array()); + $parent_refs = array_select_keys($objects, $parent_phids); + $results[$ref_key] = $parent_refs; + } + + yield $this->yieldMap($results); + } + +} diff --git a/src/ref/revision/ArcanistRevisionRef.php b/src/ref/revision/ArcanistRevisionRef.php index 96b05d45..6f73390d 100644 --- a/src/ref/revision/ArcanistRevisionRef.php +++ b/src/ref/revision/ArcanistRevisionRef.php @@ -1,125 +1,219 @@ getMonogram()); } protected function newHardpoints() { + $object_list = new ArcanistObjectListHardpoint(); return array( $this->newHardpoint(self::HARDPOINT_COMMITMESSAGE), + $this->newHardpoint(self::HARDPOINT_AUTHORREF), + $this->newHardpoint(self::HARDPOINT_BUILDABLEREF), + $this->newTemplateHardpoint( + self::HARDPOINT_PARENTREVISIONREFS, + $object_list), ); } public static function newFromConduit(array $dict) { $ref = new self(); $ref->parameters = $dict; return $ref; } public static function newFromConduitQuery(array $dict) { // Mangle an older "differential.query" result to look like a modern // "differential.revision.search" result. $status_name = idx($dict, 'statusName'); switch ($status_name) { case 'Abandoned': case 'Closed': $is_closed = true; break; default: $is_closed = false; break; } + $value_map = array( + '0' => 'needs-review', + '1' => 'needs-revision', + '2' => 'accepted', + '3' => 'published', + '4' => 'abandoned', + '5' => 'changes-planned', + ); + + $color_map = array( + 'needs-review' => 'magenta', + 'needs-revision' => 'red', + 'accepted' => 'green', + 'published' => 'cyan', + 'abandoned' => null, + 'changes-planned' => 'red', + ); + + $status_value = idx($value_map, idx($dict, 'status')); + $ansi_color = idx($color_map, $status_value); + + $date_created = null; + if (isset($dict['dateCreated'])) { + $date_created = (int)$dict['dateCreated']; + } + $dict['fields'] = array( 'uri' => idx($dict, 'uri'), 'title' => idx($dict, 'title'), 'authorPHID' => idx($dict, 'authorPHID'), 'status' => array( 'name' => $status_name, 'closed' => $is_closed, + 'value' => $status_value, + 'color.ansi' => $ansi_color, ), + 'dateCreated' => $date_created, ); return self::newFromConduit($dict); } public function getMonogram() { return 'D'.$this->getID(); } + public function getStatusShortDisplayName() { + if ($this->isStatusNeedsReview()) { + return pht('Review'); + } + return idxv($this->parameters, array('fields', 'status', 'name')); + } + public function getStatusDisplayName() { return idxv($this->parameters, array('fields', 'status', 'name')); } + public function getStatusANSIColor() { + return idxv($this->parameters, array('fields', 'status', 'color.ansi')); + } + + public function getDateCreated() { + return idxv($this->parameters, array('fields', 'dateCreated')); + } + + public function isStatusChangesPlanned() { + $status = $this->getStatus(); + return ($status === 'changes-planned'); + } + + public function isStatusAbandoned() { + $status = $this->getStatus(); + return ($status === 'abandoned'); + } + + public function isStatusPublished() { + $status = $this->getStatus(); + return ($status === 'published'); + } + + public function isStatusAccepted() { + $status = $this->getStatus(); + return ($status === 'accepted'); + } + + public function isStatusNeedsReview() { + $status = $this->getStatus(); + return ($status === 'needs-review'); + } + + public function getStatus() { + return idxv($this->parameters, array('fields', 'status', 'value')); + } + public function isClosed() { return idxv($this->parameters, array('fields', 'status', 'closed')); } public function getURI() { $uri = idxv($this->parameters, array('fields', 'uri')); if ($uri === null) { // TODO: The "uri" field was added at the same time as this callsite, // so we may not have it yet if the server is running an older version // of Phabricator. Fake our way through. $uri = '/'.$this->getMonogram(); } return $uri; } public function getFullName() { return pht('%s: %s', $this->getMonogram(), $this->getName()); } public function getID() { return (int)idx($this->parameters, 'id'); } public function getPHID() { return idx($this->parameters, 'phid'); } + public function getDiffPHID() { + return idxv($this->parameters, array('fields', 'diffPHID')); + } + public function getName() { return idxv($this->parameters, array('fields', 'title')); } public function getAuthorPHID() { return idxv($this->parameters, array('fields', 'authorPHID')); } public function addSource(ArcanistRevisionRefSource $source) { $this->sources[] = $source; return $this; } public function getSources() { return $this->sources; } public function getCommitMessage() { return $this->getHardpoint(self::HARDPOINT_COMMITMESSAGE); } - public function getDisplayRefObjectName() { - return $this->getMonogram(); + public function getAuthorRef() { + return $this->getHardpoint(self::HARDPOINT_AUTHORREF); + } + + public function getParentRevisionRefs() { + return $this->getHardpoint(self::HARDPOINT_PARENTREVISIONREFS); + } + + public function getBuildableRef() { + return $this->getHardpoint(self::HARDPOINT_BUILDABLEREF); } - public function getDisplayRefTitle() { - return $this->getName(); + protected function buildRefView(ArcanistRefView $view) { + $view + ->setObjectName($this->getMonogram()) + ->setTitle($this->getName()); } } diff --git a/src/ref/simple/ArcanistSimpleSymbolRef.php b/src/ref/simple/ArcanistSimpleSymbolRef.php index 05f01238..d6b3142f 100644 --- a/src/ref/simple/ArcanistSimpleSymbolRef.php +++ b/src/ref/simple/ArcanistSimpleSymbolRef.php @@ -1,58 +1,65 @@ type), ); } final public function getSymbolType() { return $this->type; } final protected function resolveSymbol($symbol) { $matches = null; $prefix_pattern = $this->getSimpleSymbolPrefixPattern(); + if ($prefix_pattern === null) { + $prefix_pattern = ''; + } + $id_pattern = '(^'.$prefix_pattern.'([1-9]\d*)\z)'; $is_id = preg_match($id_pattern, $symbol, $matches); if ($is_id) { $this->type = self::TYPE_ID; return (int)$matches[1]; } $phid_type = $this->getSimpleSymbolPHIDType(); $phid_type = preg_quote($phid_type); $phid_pattern = '(^PHID-'.$phid_type.'-\S+\z)'; $is_phid = preg_match($phid_pattern, $symbol, $matches); if ($is_phid) { $this->type = self::TYPE_PHID; return $matches[0]; } throw new PhutilArgumentUsageException( pht( 'The format of symbol "%s" is unrecognized. Expected a '. 'monogram like "X123", or an ID like "123", or a PHID.', $symbol)); } - abstract protected function getSimpleSymbolPrefixPattern(); + protected function getSimpleSymbolPrefixPattern() { + return null; + } + abstract protected function getSimpleSymbolPHIDType(); abstract public function getSimpleSymbolConduitSearchMethodName(); abstract public function getSimpleSymbolInspectFunctionName(); public function getSimpleSymbolConduitSearchAttachments() { return array(); } } diff --git a/src/ref/task/ArcanistTaskRef.php b/src/ref/task/ArcanistTaskRef.php index d446b8df..ac918f77 100644 --- a/src/ref/task/ArcanistTaskRef.php +++ b/src/ref/task/ArcanistTaskRef.php @@ -1,44 +1,40 @@ getMonogram()); } public static function newFromConduit(array $parameters) { $ref = new self(); $ref->parameters = $parameters; return $ref; } public function getID() { return idx($this->parameters, 'id'); } public function getPHID() { return idx($this->parameters, 'phid'); } public function getName() { return idxv($this->parameters, array('fields', 'name')); } public function getMonogram() { return 'T'.$this->getID(); } - public function getDisplayRefObjectName() { - return $this->getMonogram(); - } - - public function getDisplayRefTitle() { - return $this->getName(); + protected function buildRefView(ArcanistRefView $view) { + $view + ->setObjectName($this->getMonogram()) + ->setTitle($this->getName()); } } diff --git a/src/ref/user/ArcanistUserRef.php b/src/ref/user/ArcanistUserRef.php index 0d27ca6b..d430682d 100644 --- a/src/ref/user/ArcanistUserRef.php +++ b/src/ref/user/ArcanistUserRef.php @@ -1,66 +1,62 @@ getMonogram()); } public static function newFromConduit(array $parameters) { $ref = new self(); $ref->parameters = $parameters; return $ref; } public static function newFromConduitWhoami(array $parameters) { // NOTE: The "user.whoami" call returns a different structure than // "user.search". Mangle the data so it looks similar. $parameters['fields'] = array( 'username' => idx($parameters, 'userName'), 'realName' => idx($parameters, 'realName'), ); return self::newFromConduit($parameters); } public function getID() { return idx($this->parameters, 'id'); } public function getPHID() { return idx($this->parameters, 'phid'); } public function getMonogram() { return '@'.$this->getUsername(); } public function getUsername() { return idxv($this->parameters, array('fields', 'username')); } public function getRealName() { return idxv($this->parameters, array('fields', 'realName')); } - public function getDisplayRefObjectName() { - return $this->getMonogram(); - } - - public function getDisplayRefTitle() { + protected function buildRefView(ArcanistRefView $view) { $real_name = $this->getRealName(); - if (strlen($real_name)) { $real_name = sprintf('(%s)', $real_name); } - return $real_name; + $view + ->setObjectName($this->getMonogram()) + ->setTitle($real_name); } + } diff --git a/src/repository/api/ArcanistGitAPI.php b/src/repository/api/ArcanistGitAPI.php index d8299f1f..2ce9e41b 100644 --- a/src/repository/api/ArcanistGitAPI.php +++ b/src/repository/api/ArcanistGitAPI.php @@ -1,1742 +1,1823 @@ setCWD($this->getPath()); - return $future; + return newv('ExecFuture', $argv) + ->setCWD($this->getPath()); } - public function execPassthru($pattern /* , ... */) { + public function newPassthru($pattern /* , ... */) { $args = func_get_args(); static $git = null; if ($git === null) { if (phutil_is_windows()) { // NOTE: On Windows, phutil_passthru() uses 'bypass_shell' because // everything goes to hell if we don't. We must provide an absolute // path to Git for this to work properly. $git = Filesystem::resolveBinary('git'); $git = csprintf('%s', $git); } else { $git = 'git'; } } $args[0] = $git.' '.$args[0]; - return call_user_func_array('phutil_passthru', $args); + return newv('PhutilExecPassthru', $args) + ->setCWD($this->getPath()); } - public function getSourceControlSystemName() { return 'git'; } public function getGitVersion() { static $version = null; if ($version === null) { list($stdout) = $this->execxLocal('--version'); $version = rtrim(str_replace('git version ', '', $stdout)); } return $version; } public function getMetadataPath() { static $path = null; if ($path === null) { list($stdout) = $this->execxLocal('rev-parse --git-dir'); $path = rtrim($stdout, "\n"); // the output of git rev-parse --git-dir is an absolute path, unless // the cwd is the root of the repository, in which case it uses the // relative path of .git. If we get this relative path, turn it into // an absolute path. if ($path === '.git') { $path = $this->getPath('.git'); } } return $path; } public function getHasCommits() { return !$this->repositoryHasNoCommits; } /** * Tests if a child commit is descendant of a parent commit. * If child and parent are the same, it returns false. * @param Child commit SHA. * @param Parent commit SHA. * @return bool True if the child is a descendant of the parent. */ private function isDescendant($child, $parent) { list($common_ancestor) = $this->execxLocal( 'merge-base %s %s', $child, $parent); $common_ancestor = trim($common_ancestor); return ($common_ancestor == $parent) && ($common_ancestor != $child); } public function getLocalCommitInformation() { if ($this->repositoryHasNoCommits) { // Zero commits. throw new Exception( pht( "You can't get local commit information for a repository with no ". "commits.")); } else if ($this->getBaseCommit() == self::GIT_MAGIC_ROOT_COMMIT) { // One commit. $against = 'HEAD'; } else { // 2..N commits. We include commits reachable from HEAD which are // not reachable from the base commit; this is consistent with user // expectations even though it is not actually the diff range. // Particularly: // // | // D <----- master branch // | // C Y <- feature branch // | /| // B X // | / // A // | // // If "A, B, C, D" are master, and the user is at Y, when they run // "arc diff B" they want (and get) a diff of B vs Y, but they think about // this as being the commits X and Y. If we log "B..Y", we only show // Y. With "Y --not B", we show X and Y. if ($this->symbolicHeadCommit !== null) { $base_commit = $this->getBaseCommit(); $resolved_base = $this->resolveCommit($base_commit); $head_commit = $this->symbolicHeadCommit; $resolved_head = $this->getHeadCommit(); if (!$this->isDescendant($resolved_head, $resolved_base)) { // NOTE: Since the base commit will have been resolved as the // merge-base of the specified base and the specified HEAD, we can't // easily tell exactly what's wrong with the range. // For example, `arc diff HEAD --head HEAD^^^` is invalid because it // is reversed, but resolving the commit "HEAD" will compute its // merge-base with "HEAD^^^", which is "HEAD^^^", so the range will // appear empty. throw new ArcanistUsageException( pht( 'The specified commit range is empty, backward or invalid: the '. 'base (%s) is not an ancestor of the head (%s). You can not '. 'diff an empty or reversed commit range.', $base_commit, $head_commit)); } } $against = csprintf( '%s --not %s', $this->getHeadCommit(), $this->getBaseCommit()); } // NOTE: Windows escaping of "%" symbols apparently is inherently broken; // when passed through escapeshellarg() they are replaced with spaces. // TODO: Learn how cmd.exe works and find some clever workaround? // NOTE: If we use "%x00", output is truncated in Windows. list($info) = $this->execxLocal( phutil_is_windows() ? 'log %C --format=%C --' : 'log %C --format=%s --', $against, // NOTE: "%B" is somewhat new, use "%s%n%n%b" instead. '%H%x01%T%x01%P%x01%at%x01%an%x01%aE%x01%s%x01%s%n%n%b%x02'); $commits = array(); $info = trim($info, " \n\2"); if (!strlen($info)) { return array(); } $info = explode("\2", $info); foreach ($info as $line) { list($commit, $tree, $parents, $time, $author, $author_email, $title, $message) = explode("\1", trim($line), 8); $message = rtrim($message); $commits[$commit] = array( 'commit' => $commit, 'tree' => $tree, 'parents' => array_filter(explode(' ', $parents)), 'time' => $time, 'author' => $author, 'summary' => $title, 'message' => $message, 'authorEmail' => $author_email, ); } return $commits; } protected function buildBaseCommit($symbolic_commit) { if ($symbolic_commit !== null) { if ($symbolic_commit == self::GIT_MAGIC_ROOT_COMMIT) { $this->setBaseCommitExplanation( pht('you explicitly specified the empty tree.')); return $symbolic_commit; } list($err, $merge_base) = $this->execManualLocal( 'merge-base %s %s', $symbolic_commit, $this->getHeadCommit()); if ($err) { throw new ArcanistUsageException( pht( "Unable to find any git commit named '%s' in this repository.", $symbolic_commit)); } if ($this->symbolicHeadCommit === null) { $this->setBaseCommitExplanation( pht( "it is the merge-base of the explicitly specified base commit ". "'%s' and HEAD.", $symbolic_commit)); } else { $this->setBaseCommitExplanation( pht( "it is the merge-base of the explicitly specified base commit ". "'%s' and the explicitly specified head commit '%s'.", $symbolic_commit, $this->symbolicHeadCommit)); } return trim($merge_base); } // Detect zero-commit or one-commit repositories. There is only one // relative-commit value that makes any sense in these repositories: the // empty tree. list($err) = $this->execManualLocal('rev-parse --verify HEAD^'); if ($err) { list($err) = $this->execManualLocal('rev-parse --verify HEAD'); if ($err) { $this->repositoryHasNoCommits = true; } if ($this->repositoryHasNoCommits) { $this->setBaseCommitExplanation(pht('the repository has no commits.')); } else { $this->setBaseCommitExplanation( pht('the repository has only one commit.')); } return self::GIT_MAGIC_ROOT_COMMIT; } if ($this->getBaseCommitArgumentRules() || $this->getConfigurationManager()->getConfigFromAnySource('base')) { $base = $this->resolveBaseCommit(); if (!$base) { throw new ArcanistUsageException( pht( "None of the rules in your 'base' configuration matched a valid ". "commit. Adjust rules or specify which commit you want to use ". "explicitly.")); } return $base; } $do_write = false; $default_relative = null; $working_copy = $this->getWorkingCopyIdentity(); if ($working_copy) { $default_relative = $working_copy->getProjectConfig( 'git.default-relative-commit'); $this->setBaseCommitExplanation( pht( "it is the merge-base of '%s' and HEAD, as specified in '%s' in ". "'%s'. This setting overrides other settings.", $default_relative, 'git.default-relative-commit', '.arcconfig')); } if (!$default_relative) { list($err, $upstream) = $this->execManualLocal( 'rev-parse --abbrev-ref --symbolic-full-name %s', '@{upstream}'); if (!$err) { $default_relative = trim($upstream); $this->setBaseCommitExplanation( pht( "it is the merge-base of '%s' (the Git upstream ". "of the current branch) HEAD.", $default_relative)); } } if (!$default_relative) { $default_relative = $this->readScratchFile('default-relative-commit'); $default_relative = trim($default_relative); if ($default_relative) { $this->setBaseCommitExplanation( pht( "it is the merge-base of '%s' and HEAD, as specified in '%s'.", $default_relative, '.git/arc/default-relative-commit')); } } if (!$default_relative) { // TODO: Remove the history lesson soon. echo phutil_console_format( "** %s **\n\n", pht('Select a Default Commit Range')); echo phutil_console_wrap( pht( "You're running a command which operates on a range of revisions ". "(usually, from some revision to HEAD) but have not specified the ". "revision that should determine the start of the range.\n\n". "Previously, arc assumed you meant '%s' when you did not specify ". "a start revision, but this behavior does not make much sense in ". "most workflows outside of Facebook's historic %s workflow.\n\n". "arc no longer assumes '%s'. You must specify a relative commit ". "explicitly when you invoke a command (e.g., `%s`, not just `%s`) ". "or select a default for this working copy.\n\nIn most cases, the ". "best default is '%s'. You can also select '%s' to preserve the ". "old behavior, or some other remote or branch. But you almost ". "certainly want to select 'origin/master'.\n\n". "(Technically: the merge-base of the selected revision and HEAD is ". "used to determine the start of the commit range.)", 'HEAD^', 'git-svn', 'HEAD^', 'arc diff HEAD^', 'arc diff', 'origin/master', 'HEAD^')); $prompt = pht('What default do you want to use? [origin/master]'); $default = phutil_console_prompt($prompt); if (!strlen(trim($default))) { $default = 'origin/master'; } $default_relative = $default; $do_write = true; } list($object_type) = $this->execxLocal( 'cat-file -t %s', $default_relative); if (trim($object_type) !== 'commit') { throw new Exception( pht( "Relative commit '%s' is not the name of a commit!", $default_relative)); } if ($do_write) { // Don't perform this write until we've verified that the object is a // valid commit name. $this->writeScratchFile('default-relative-commit', $default_relative); $this->setBaseCommitExplanation( pht( "it is the merge-base of '%s' and HEAD, as you just specified.", $default_relative)); } list($merge_base) = $this->execxLocal( 'merge-base %s HEAD', $default_relative); return trim($merge_base); } public function getHeadCommit() { if ($this->resolvedHeadCommit === null) { $this->resolvedHeadCommit = $this->resolveCommit( coalesce($this->symbolicHeadCommit, 'HEAD')); } return $this->resolvedHeadCommit; } public function setHeadCommit($symbolic_commit) { $this->symbolicHeadCommit = $symbolic_commit; $this->reloadCommitRange(); return $this; } /** * Translates a symbolic commit (like "HEAD^") to a commit identifier. * @param string_symbol commit. * @return string the commit SHA. */ private function resolveCommit($symbolic_commit) { list($err, $commit_hash) = $this->execManualLocal( 'rev-parse %s', $symbolic_commit); if ($err) { throw new ArcanistUsageException( pht( "Unable to find any git commit named '%s' in this repository.", $symbolic_commit)); } return trim($commit_hash); } private function getDiffFullOptions($detect_moves_and_renames = true) { $options = array( self::getDiffBaseOptions(), '--no-color', '--src-prefix=a/', '--dst-prefix=b/', '-U'.$this->getDiffLinesOfContext(), ); if ($detect_moves_and_renames) { $options[] = '-M'; $options[] = '-C'; } return implode(' ', $options); } private function getDiffBaseOptions() { $options = array( // Disable external diff drivers, like graphical differs, since Arcanist // needs to capture the diff text. '--no-ext-diff', // Disable textconv so we treat binary files as binary, even if they have // an alternative textual representation. TODO: Ideally, Differential // would ship up the binaries for 'arc patch' but display the textconv // output in the visual diff. '--no-textconv', // Provide a standard view of submodule changes; the 'log' and 'diff' // values do not parse by the diff parser. '--submodule=short', ); return implode(' ', $options); } /** * @param the base revision * @param head revision. If this is null, the generated diff will include the * working copy */ public function getFullGitDiff($base, $head = null) { $options = $this->getDiffFullOptions(); $config_options = array(); // See T13432. Disable the rare "diff.suppressBlankEmpty" configuration // option, which discards the " " (space) change type prefix on unchanged // blank lines. At time of writing the parser does not handle these // properly, but generating a more-standard diff is generally desirable // even if a future parser handles this case more gracefully. $config_options[] = '-c'; $config_options[] = 'diff.suppressBlankEmpty=false'; if ($head !== null) { list($stdout) = $this->execxLocal( "%LR diff {$options} %s %s --", $config_options, $base, $head); } else { list($stdout) = $this->execxLocal( "%LR diff {$options} %s --", $config_options, $base); } return $stdout; } /** * @param string Path to generate a diff for. * @param bool If true, detect moves and renames. Otherwise, ignore * moves/renames; this is useful because it prompts git to * generate real diff text. */ public function getRawDiffText($path, $detect_moves_and_renames = true) { $options = $this->getDiffFullOptions($detect_moves_and_renames); list($stdout) = $this->execxLocal( "diff {$options} %s -- %s", $this->getBaseCommit(), $path); return $stdout; } private function getBranchNameFromRef($ref) { $count = 0; $branch = preg_replace('/^refs\/heads\//', '', $ref, 1, $count); if ($count !== 1) { return null; } if (!strlen($branch)) { return null; } return $branch; } public function getBranchName() { list($err, $stdout, $stderr) = $this->execManualLocal( 'symbolic-ref --quiet HEAD'); if ($err === 0) { // We expect the branch name to come qualified with a refs/heads/ prefix. // Verify this, and strip it. $ref = rtrim($stdout); $branch = $this->getBranchNameFromRef($ref); if ($branch === null) { throw new Exception( pht('Failed to parse %s output!', 'git symbolic-ref')); } return $branch; } else if ($err === 1) { // Exit status 1 with --quiet indicates that HEAD is detached. return null; } else { throw new Exception( pht('Command %s failed: %s', 'git symbolic-ref', $stderr)); } } public function getRemoteURI() { // Determine which remote to examine; default to 'origin' $remote = 'origin'; $branch = $this->getBranchName(); if ($branch) { $path = $this->getPathToUpstream($branch); if ($path->isConnectedToRemote()) { $remote = $path->getRemoteRemoteName(); } } return $this->getGitRemoteFetchURI($remote); } public function getSourceControlPath() { // TODO: Try to get something useful here. return null; } public function getGitCommitLog() { $relative = $this->getBaseCommit(); if ($this->repositoryHasNoCommits) { // No commits yet. return ''; } else if ($relative == self::GIT_MAGIC_ROOT_COMMIT) { // First commit. list($stdout) = $this->execxLocal( 'log --format=medium HEAD'); } else { // 2..N commits. list($stdout) = $this->execxLocal( 'log --first-parent --format=medium %s..%s', $this->getBaseCommit(), $this->getHeadCommit()); } return $stdout; } public function getGitHistoryLog() { list($stdout) = $this->execxLocal( 'log --format=medium -n%d %s', self::SEARCH_LENGTH_FOR_PARENT_REVISIONS, $this->getBaseCommit()); return $stdout; } public function getSourceControlBaseRevision() { list($stdout) = $this->execxLocal( 'rev-parse %s', $this->getBaseCommit()); return rtrim($stdout, "\n"); } public function getCanonicalRevisionName($string) { $match = null; + if (preg_match('/@([0-9]+)$/', $string, $match)) { $stdout = $this->getHashFromFromSVNRevisionNumber($match[1]); } else { list($stdout) = $this->execxLocal( - phutil_is_windows() - ? 'show -s --format=%C %s --' - : 'show -s --format=%s %s --', + 'show -s --format=%s %s --', '%H', $string); } + return rtrim($stdout); } private function executeSVNFindRev($input, $vcs) { $match = array(); list($stdout) = $this->execxLocal( 'svn find-rev %s', $input); if (!$stdout) { throw new ArcanistUsageException( pht( 'Cannot find the %s equivalent of %s.', $vcs, $input)); } // When git performs a partial-rebuild during svn // look-up, we need to parse the final line $lines = explode("\n", $stdout); $stdout = $lines[count($lines) - 2]; return rtrim($stdout); } // Convert svn revision number to git hash public function getHashFromFromSVNRevisionNumber($revision_id) { return $this->executeSVNFindRev('r'.$revision_id, 'Git'); } // Convert a git hash to svn revision number public function getSVNRevisionNumberFromHash($hash) { return $this->executeSVNFindRev($hash, 'SVN'); } private function buildUncommittedStatusViaStatus() { $status = $this->buildLocalFuture( array( 'status --porcelain=2 -z', )); list($stdout) = $status->resolvex(); $result = new PhutilArrayWithDefaultValue(); $parts = explode("\0", $stdout); while (count($parts) > 1) { $entry = array_shift($parts); $entry_parts = explode(' ', $entry, 2); if ($entry_parts[0] == '1') { $entry_parts = explode(' ', $entry, 9); $path = $entry_parts[8]; } else if ($entry_parts[0] == '2') { $entry_parts = explode(' ', $entry, 10); $path = $entry_parts[9]; } else if ($entry_parts[0] == 'u') { $entry_parts = explode(' ', $entry, 11); $path = $entry_parts[10]; } else if ($entry_parts[0] == '?') { $entry_parts = explode(' ', $entry, 2); $result[$entry_parts[1]] = self::FLAG_UNTRACKED; continue; } $result[$path] |= self::FLAG_UNCOMMITTED; $index_state = substr($entry_parts[1], 0, 1); $working_state = substr($entry_parts[1], 1, 1); if ($index_state == 'A') { $result[$path] |= self::FLAG_ADDED; } else if ($index_state == 'M') { $result[$path] |= self::FLAG_MODIFIED; } else if ($index_state == 'D') { $result[$path] |= self::FLAG_DELETED; } if ($working_state != '.') { $result[$path] |= self::FLAG_UNSTAGED; if ($index_state == '.') { if ($working_state == 'A') { $result[$path] |= self::FLAG_ADDED; } else if ($working_state == 'M') { $result[$path] |= self::FLAG_MODIFIED; } else if ($working_state == 'D') { $result[$path] |= self::FLAG_DELETED; } } } $submodule_tracked = substr($entry_parts[2], 2, 1); $submodule_untracked = substr($entry_parts[2], 3, 1); if ($submodule_tracked == 'M' || $submodule_untracked == 'U') { $result[$path] |= self::FLAG_EXTERNALS; } if ($entry_parts[0] == '2') { $result[array_shift($parts)] = $result[$path] | self::FLAG_DELETED; $result[$path] |= self::FLAG_ADDED; } } return $result->toArray(); } protected function buildUncommittedStatus() { if (version_compare($this->getGitVersion(), '2.11.0', '>=')) { return $this->buildUncommittedStatusViaStatus(); } $diff_options = $this->getDiffBaseOptions(); if ($this->repositoryHasNoCommits) { $diff_base = self::GIT_MAGIC_ROOT_COMMIT; } else { $diff_base = 'HEAD'; } // Find uncommitted changes. $uncommitted_future = $this->buildLocalFuture( array( 'diff %C --raw %s --', $diff_options, $diff_base, )); $untracked_future = $this->buildLocalFuture( array( 'ls-files --others --exclude-standard', )); // Unstaged changes $unstaged_future = $this->buildLocalFuture( array( 'diff-files --name-only', )); $futures = array( $uncommitted_future, $untracked_future, // NOTE: `git diff-files` races with each of these other commands // internally, and resolves with inconsistent results if executed // in parallel. To work around this, DO NOT run it at the same time. // After the other commands exit, we can start the `diff-files` command. ); id(new FutureIterator($futures))->resolveAll(); // We're clear to start the `git diff-files` now. $unstaged_future->start(); $result = new PhutilArrayWithDefaultValue(); list($stdout) = $uncommitted_future->resolvex(); $uncommitted_files = $this->parseGitRawDiff($stdout); foreach ($uncommitted_files as $path => $mask) { $result[$path] |= ($mask | self::FLAG_UNCOMMITTED); } list($stdout) = $untracked_future->resolvex(); $stdout = rtrim($stdout, "\n"); if (strlen($stdout)) { $stdout = explode("\n", $stdout); foreach ($stdout as $path) { $result[$path] |= self::FLAG_UNTRACKED; } } list($stdout, $stderr) = $unstaged_future->resolvex(); $stdout = rtrim($stdout, "\n"); if (strlen($stdout)) { $stdout = explode("\n", $stdout); foreach ($stdout as $path) { $result[$path] |= self::FLAG_UNSTAGED; } } return $result->toArray(); } protected function buildCommitRangeStatus() { list($stdout, $stderr) = $this->execxLocal( 'diff %C --raw %s HEAD --', $this->getDiffBaseOptions(), $this->getBaseCommit()); return $this->parseGitRawDiff($stdout); } public function getGitConfig($key, $default = null) { list($err, $stdout) = $this->execManualLocal('config %s', $key); if ($err) { return $default; } return rtrim($stdout); } public function getAuthor() { list($stdout) = $this->execxLocal('var GIT_AUTHOR_IDENT'); return preg_replace('/\s+<.*/', '', rtrim($stdout, "\n")); } public function addToCommit(array $paths) { $this->execxLocal( 'add -A -- %Ls', $paths); $this->reloadWorkingCopy(); return $this; } public function doCommit($message) { $tmp_file = new TempFile(); Filesystem::writeFile($tmp_file, $message); // NOTE: "--allow-empty-message" was introduced some time after 1.7.0.4, // so we do not provide it and thus require a message. $this->execxLocal( 'commit -F %s', $tmp_file); $this->reloadWorkingCopy(); return $this; } public function amendCommit($message = null) { if ($message === null) { $this->execxLocal('commit --amend --allow-empty -C HEAD'); } else { $tmp_file = new TempFile(); Filesystem::writeFile($tmp_file, $message); $this->execxLocal( 'commit --amend --allow-empty -F %s', $tmp_file); } $this->reloadWorkingCopy(); return $this; } private function parseGitRawDiff($status, $full = false) { static $flags = array( 'A' => self::FLAG_ADDED, 'M' => self::FLAG_MODIFIED, 'D' => self::FLAG_DELETED, ); $status = trim($status); $lines = array(); foreach (explode("\n", $status) as $line) { if ($line) { $lines[] = preg_split("/[ \t]/", $line, 6); } } $files = array(); foreach ($lines as $line) { $mask = 0; // "git diff --raw" lines begin with a ":" character. $old_mode = ltrim($line[0], ':'); $new_mode = $line[1]; // The hashes may be padded with "." characters for alignment. Discard // them. $old_hash = rtrim($line[2], '.'); $new_hash = rtrim($line[3], '.'); $flag = $line[4]; $file = $line[5]; $new_value = intval($new_mode, 8); $is_submodule = (($new_value & 0160000) === 0160000); if (($is_submodule) && ($flag == 'M') && ($old_hash === $new_hash) && ($old_mode === $new_mode)) { // See T9455. We see this submodule as "modified", but the old and new // hashes are the same and the old and new modes are the same, so we // don't directly see a modification. // We can end up here if we have a submodule which has uncommitted // changes inside of it (for example, the user has added untracked // files or made uncommitted changes to files in the submodule). In // this case, we set a different flag because we can't meaningfully // give users the same prompt. // Note that if the submodule has real changes from the parent // perspective (the base commit has changed) and also has uncommitted // changes, we'll only see the real changes and miss the uncommitted // changes. At the time of writing, there is no reasonable porcelain // for finding those changes, and the impact of this error seems small. $mask |= self::FLAG_EXTERNALS; } else if (isset($flags[$flag])) { $mask |= $flags[$flag]; } else if ($flag[0] == 'R') { $both = explode("\t", $file); if ($full) { $files[$both[0]] = array( 'mask' => $mask | self::FLAG_DELETED, 'ref' => str_repeat('0', 40), ); } else { $files[$both[0]] = $mask | self::FLAG_DELETED; } $file = $both[1]; $mask |= self::FLAG_ADDED; } else if ($flag[0] == 'C') { $both = explode("\t", $file); $file = $both[1]; $mask |= self::FLAG_ADDED; } if ($full) { $files[$file] = array( 'mask' => $mask, 'ref' => $new_hash, ); } else { $files[$file] = $mask; } } return $files; } public function getAllFiles() { $future = $this->buildLocalFuture(array('ls-files -z')); return id(new LinesOfALargeExecFuture($future)) ->setDelimiter("\0"); } public function getChangedFiles($since_commit) { list($stdout) = $this->execxLocal( 'diff --raw %s', $since_commit); return $this->parseGitRawDiff($stdout); } public function getBlame($path) { list($stdout) = $this->execxLocal( 'blame --porcelain -w -M %s -- %s', $this->getBaseCommit(), $path); // the --porcelain format prints at least one header line per source line, // then the source line prefixed by a tab character $blame_info = preg_split('/^\t.*\n/m', rtrim($stdout)); // commit info is not repeated in these headers, so cache it $revision_data = array(); $blame = array(); foreach ($blame_info as $line_info) { $revision = substr($line_info, 0, 40); $data = idx($revision_data, $revision, array()); if (empty($data)) { $matches = array(); if (!preg_match('/^author (.*)$/m', $line_info, $matches)) { throw new Exception( pht( 'Unexpected output from %s: no author for commit %s', 'git blame', $revision)); } $data['author'] = $matches[1]; $data['from_first_commit'] = preg_match('/^boundary$/m', $line_info); $revision_data[$revision] = $data; } // Ignore lines predating the git repository (on a boundary commit) // rather than blaming them on the oldest diff's unfortunate author if (!$data['from_first_commit']) { $blame[] = array($data['author'], $revision); } } return $blame; } public function getOriginalFileData($path) { return $this->getFileDataAtRevision($path, $this->getBaseCommit()); } public function getCurrentFileData($path) { return $this->getFileDataAtRevision($path, 'HEAD'); } private function parseGitTree($stdout) { $result = array(); $stdout = trim($stdout); if (!strlen($stdout)) { return $result; } $lines = explode("\n", $stdout); foreach ($lines as $line) { $matches = array(); $ok = preg_match( '/^(\d{6}) (blob|tree|commit) ([a-z0-9]{40})[\t](.*)$/', $line, $matches); if (!$ok) { throw new Exception(pht('Failed to parse %s output!', 'git ls-tree')); } $result[$matches[4]] = array( 'mode' => $matches[1], 'type' => $matches[2], 'ref' => $matches[3], ); } return $result; } private function getFileDataAtRevision($path, $revision) { // NOTE: We don't want to just "git show {$revision}:{$path}" since if the // path was a directory at the given revision we'll get a list of its files // and treat it as though it as a file containing a list of other files, // which is silly. if (!strlen($path)) { // No filename, so there's no content (Probably new/deleted file). return null; } list($stdout) = $this->execxLocal( 'ls-tree %s -- %s', $revision, $path); $info = $this->parseGitTree($stdout); if (empty($info[$path])) { // No such path, or the path is a directory and we executed 'ls-tree dir/' // and got a list of its contents back. return null; } if ($info[$path]['type'] != 'blob') { // Path is or was a directory, not a file. return null; } list($stdout) = $this->execxLocal( 'cat-file blob %s', $info[$path]['ref']); return $stdout; } /** * Returns names of all the branches in the current repository. * * @return list> Dictionary of branch information. */ - public function getAllBranches() { + private function getAllBranches() { $field_list = array( '%(refname)', '%(objectname)', '%(committerdate:raw)', '%(tree)', '%(subject)', '%(subject)%0a%0a%(body)', '%02', ); list($stdout) = $this->execxLocal( 'for-each-ref --format=%s -- refs/heads', implode('%01', $field_list)); $current = $this->getBranchName(); $result = array(); $lines = explode("\2", $stdout); foreach ($lines as $line) { $line = trim($line); if (!strlen($line)) { continue; } $fields = explode("\1", $line, 6); list($ref, $hash, $epoch, $tree, $desc, $text) = $fields; $branch = $this->getBranchNameFromRef($ref); if ($branch !== null) { $result[] = array( 'current' => ($branch === $current), 'name' => $branch, 'ref' => $ref, 'hash' => $hash, 'tree' => $tree, 'epoch' => (int)$epoch, 'desc' => $desc, 'text' => $text, ); } } return $result; } - public function getAllBranchRefs() { - $branches = $this->getAllBranches(); - - $refs = array(); - foreach ($branches as $branch) { - $commit_ref = $this->newCommitRef() - ->setCommitHash($branch['hash']) - ->setTreeHash($branch['tree']) - ->setCommitEpoch($branch['epoch']) - ->attachMessage($branch['text']); - - $refs[] = $this->newBranchRef() - ->setBranchName($branch['name']) - ->setRefName($branch['ref']) - ->setIsCurrentBranch($branch['current']) - ->attachCommitRef($commit_ref); - } - - return $refs; - } - public function getBaseCommitRef() { $base_commit = $this->getBaseCommit(); if ($base_commit === self::GIT_MAGIC_ROOT_COMMIT) { return null; } $base_message = $this->getCommitMessage($base_commit); // TODO: We should also pull the tree hash. return $this->newCommitRef() ->setCommitHash($base_commit) ->attachMessage($base_message); } public function getWorkingCopyRevision() { list($stdout) = $this->execxLocal('rev-parse HEAD'); return rtrim($stdout, "\n"); } public function isHistoryDefaultImmutable() { return false; } public function supportsAmend() { return true; } public function supportsCommitRanges() { return true; } public function supportsLocalCommits() { return true; } public function hasLocalCommit($commit) { try { if (!$this->getCanonicalRevisionName($commit)) { return false; } } catch (CommandException $exception) { return false; } return true; } public function getAllLocalChanges() { $diff = $this->getFullGitDiff($this->getBaseCommit()); if (!strlen(trim($diff))) { return array(); } $parser = new ArcanistDiffParser(); return $parser->parseDiff($diff); } public function getFinalizedRevisionMessage() { return pht( "You may now push this commit upstream, as appropriate (e.g. with ". "'%s', or '%s', or by printing and faxing it).", 'git push', 'git svn dcommit'); } public function getCommitMessage($commit) { list($message) = $this->execxLocal( 'log -n1 --format=%C %s --', '%s%n%n%b', $commit); return $message; } public function loadWorkingCopyDifferentialRevisions( ConduitClient $conduit, array $query) { $messages = $this->getGitCommitLog(); if (!strlen($messages)) { return array(); } $parser = new ArcanistDiffParser(); $messages = $parser->parseDiff($messages); // First, try to find revisions by explicit revision IDs in commit messages. $reason_map = array(); $revision_ids = array(); foreach ($messages as $message) { $object = ArcanistDifferentialCommitMessage::newFromRawCorpus( $message->getMetadata('message')); if ($object->getRevisionID()) { $revision_ids[] = $object->getRevisionID(); $reason_map[$object->getRevisionID()] = $message->getCommitHash(); } } if ($revision_ids) { $results = $conduit->callMethodSynchronous( 'differential.query', $query + array( 'ids' => $revision_ids, )); foreach ($results as $key => $result) { $hash = substr($reason_map[$result['id']], 0, 16); $results[$key]['why'] = pht( "Commit message for '%s' has explicit 'Differential Revision'.", $hash); } return $results; } // If we didn't succeed, try to find revisions by hash. $hashes = array(); foreach ($this->getLocalCommitInformation() as $commit) { $hashes[] = array('gtcm', $commit['commit']); $hashes[] = array('gttr', $commit['tree']); } $results = $conduit->callMethodSynchronous( 'differential.query', $query + array( 'commitHashes' => $hashes, )); foreach ($results as $key => $result) { $results[$key]['why'] = pht( 'A git commit or tree hash in the commit range is already attached '. 'to the Differential revision.'); } return $results; } public function updateWorkingCopy() { $this->execxLocal('pull'); $this->reloadWorkingCopy(); } public function getCommitSummary($commit) { if ($commit == self::GIT_MAGIC_ROOT_COMMIT) { return pht('(The Empty Tree)'); } list($summary) = $this->execxLocal( 'log -n 1 --format=%C %s', '%s', $commit); return trim($summary); } public function isGitSubversionRepo() { return Filesystem::pathExists($this->getPath('.git/svn')); } public function resolveBaseCommitRule($rule, $source) { list($type, $name) = explode(':', $rule, 2); switch ($type) { case 'git': $matches = null; if (preg_match('/^merge-base\((.+)\)$/', $name, $matches)) { list($err, $merge_base) = $this->execManualLocal( 'merge-base %s HEAD', $matches[1]); if (!$err) { $this->setBaseCommitExplanation( pht( "it is the merge-base of '%s' and HEAD, as specified by ". "'%s' in your %s 'base' configuration.", $matches[1], $rule, $source)); return trim($merge_base); } } else if (preg_match('/^branch-unique\((.+)\)$/', $name, $matches)) { list($err, $merge_base) = $this->execManualLocal( 'merge-base %s HEAD', $matches[1]); if ($err) { return null; } $merge_base = trim($merge_base); list($commits) = $this->execxLocal( 'log --format=%C %s..HEAD --', '%H', $merge_base); $commits = array_filter(explode("\n", $commits)); if (!$commits) { return null; } $commits[] = $merge_base; $head_branch_count = null; $all_branch_names = ipull($this->getAllBranches(), 'name'); foreach ($commits as $commit) { // Ideally, we would use something like "for-each-ref --contains" // to get a filtered list of branches ready for script consumption. // Instead, try to get predictable output from "branch --contains". $flags = array(); $flags[] = '--no-color'; // NOTE: The "--no-column" flag was introduced in Git 1.7.11, so // don't pass it if we're running an older version. See T9953. $version = $this->getGitVersion(); if (version_compare($version, '1.7.11', '>=')) { $flags[] = '--no-column'; } list($branches) = $this->execxLocal( 'branch %Ls --contains %s', $flags, $commit); $branches = array_filter(explode("\n", $branches)); // Filter the list, removing the "current" marker (*) and ignoring // anything other than known branch names (mainly, any possible // "detached HEAD" or "no branch" line). foreach ($branches as $key => $branch) { $branch = trim($branch, ' *'); if (in_array($branch, $all_branch_names)) { $branches[$key] = $branch; } else { unset($branches[$key]); } } if ($head_branch_count === null) { // If this is the first commit, it's HEAD. Count how many // branches it is on; we want to include commits on the same // number of branches. This covers a case where this branch // has sub-branches and we're running "arc diff" here again // for whatever reason. $head_branch_count = count($branches); } else if (count($branches) > $head_branch_count) { $branches = implode(', ', $branches); $this->setBaseCommitExplanation( pht( "it is the first commit between '%s' (the merge-base of ". "'%s' and HEAD) which is also contained by another branch ". "(%s).", $merge_base, $matches[1], $branches)); return $commit; } } } else { list($err) = $this->execManualLocal( 'cat-file -t %s', $name); if (!$err) { $this->setBaseCommitExplanation( pht( "it is specified by '%s' in your %s 'base' configuration.", $rule, $source)); return $name; } } break; case 'arc': switch ($name) { case 'empty': $this->setBaseCommitExplanation( pht( "you specified '%s' in your %s 'base' configuration.", $rule, $source)); return self::GIT_MAGIC_ROOT_COMMIT; case 'amended': $text = $this->getCommitMessage('HEAD'); $message = ArcanistDifferentialCommitMessage::newFromRawCorpus( $text); if ($message->getRevisionID()) { $this->setBaseCommitExplanation( pht( "HEAD has been amended with 'Differential Revision:', ". "as specified by '%s' in your %s 'base' configuration.", $rule, $source)); return 'HEAD^'; } break; case 'upstream': list($err, $upstream) = $this->execManualLocal( 'rev-parse --abbrev-ref --symbolic-full-name %s', '@{upstream}'); if (!$err) { $upstream = rtrim($upstream); list($upstream_merge_base) = $this->execxLocal( 'merge-base %s HEAD', $upstream); $upstream_merge_base = rtrim($upstream_merge_base); $this->setBaseCommitExplanation( pht( "it is the merge-base of the upstream of the current branch ". "and HEAD, and matched the rule '%s' in your %s ". "'base' configuration.", $rule, $source)); return $upstream_merge_base; } break; case 'this': $this->setBaseCommitExplanation( pht( "you specified '%s' in your %s 'base' configuration.", $rule, $source)); return 'HEAD^'; } default: return null; } return null; } public function canStashChanges() { return true; } public function stashChanges() { $this->execxLocal('stash'); $this->reloadWorkingCopy(); } public function unstashChanges() { $this->execxLocal('stash pop'); } protected function didReloadCommitRange() { // After an amend, the symbolic head may resolve to a different commit. $this->resolvedHeadCommit = null; } /** * Follow the chain of tracking branches upstream until we reach a remote * or cycle locally. * * @param string Ref to start from. * @return ArcanistGitUpstreamPath Path to an upstream. */ public function getPathToUpstream($start) { $cursor = $start; $path = new ArcanistGitUpstreamPath(); while (true) { list($err, $upstream) = $this->execManualLocal( 'rev-parse --symbolic-full-name %s@{upstream}', $cursor); if ($err) { // We ended up somewhere with no tracking branch, so we're done. break; } $upstream = trim($upstream); if (preg_match('(^refs/heads/)', $upstream)) { $upstream = preg_replace('(^refs/heads/)', '', $upstream); $is_cycle = $path->getUpstream($upstream); $path->addUpstream( $cursor, array( 'type' => ArcanistGitUpstreamPath::TYPE_LOCAL, 'name' => $upstream, 'cycle' => $is_cycle, )); if ($is_cycle) { // We ran into a local cycle, so we're done. break; } // We found another local branch, so follow that one upriver. $cursor = $upstream; continue; } if (preg_match('(^refs/remotes/)', $upstream)) { $upstream = preg_replace('(^refs/remotes/)', '', $upstream); list($remote, $branch) = explode('/', $upstream, 2); $path->addUpstream( $cursor, array( 'type' => ArcanistGitUpstreamPath::TYPE_REMOTE, 'name' => $branch, 'remote' => $remote, )); // We found a remote, so we're done. break; } throw new Exception( pht( 'Got unrecognized upstream format ("%s") from Git, expected '. '"refs/heads/..." or "refs/remotes/...".', $upstream)); } return $path; } public function isPerforceRemote($remote_name) { // See T13434. In Perforce workflows, "git p4 clone" creates "p4" refs // under "refs/remotes/", but does not define a real remote named "p4". // We treat this remote as though it were a real remote during "arc land", // but it does not respond to commands like "git remote show p4", so we // need to handle it specially. if ($remote_name !== 'p4') { return false; } $remote_dir = $this->getMetadataPath().'/refs/remotes/p4'; if (!Filesystem::pathExists($remote_dir)) { return false; } return true; } public function isPushableRemote($remote_name) { $uri = $this->getGitRemotePushURI($remote_name); return ($uri !== null); } + public function isFetchableRemote($remote_name) { + $uri = $this->getGitRemoteFetchURI($remote_name); + return ($uri !== null); + } + private function getGitRemoteFetchURI($remote_name) { return $this->getGitRemoteURI($remote_name, $for_push = false); } private function getGitRemotePushURI($remote_name) { return $this->getGitRemoteURI($remote_name, $for_push = true); } private function getGitRemoteURI($remote_name, $for_push) { $remote_uri = $this->loadGitRemoteURI($remote_name, $for_push); if ($remote_uri !== null) { $remote_uri = rtrim($remote_uri); if (!strlen($remote_uri)) { $remote_uri = null; } } return $remote_uri; } private function loadGitRemoteURI($remote_name, $for_push) { // Try to identify the best URI for a given remote. This is complicated // because remotes may have different "push" and "fetch" URIs, may // rewrite URIs with "insteadOf" configuration, and different versions // of Git support different URI resolution commands. // Remotes may also have more than one URI of a given type, but we ignore // those cases here. // Start with "git remote get-url [--push]". This is the simplest and // most accurate command, but was introduced most recently in Git's // history. $argv = array(); if ($for_push) { $argv[] = '--push'; } list($err, $stdout) = $this->execManualLocal( 'remote get-url %Ls -- %s', $argv, $remote_name); if (!$err) { return $stdout; } // See T13481. If "git remote get-url [--push]" failed, it might be because // the remote does not exist, but it might also be because the version of // Git is too old to support "git remote get-url", which was introduced // in Git 2.7 (circa late 2015). $git_version = $this->getGitVersion(); if (version_compare($git_version, '2.7', '>=')) { // This version of Git should support "git remote get-url --push", but // the command failed, so conclude this is not a valid remote and thus // there is no remote URI. return null; } // If we arrive here, we're in a version of Git which is too old to // support "git remote get-url [--push]". We're going to fall back to // older and less accurate mechanisms for figuring out the remote URI. // The first mechanism we try is "git ls-remote --get-url". This exists // in Git 1.7.5 or newer. It only gives us the fetch URI, so this result // will be incorrect if a remote has different fetch and push URIs. // However, this is very rare, and this result is almost always correct. // Note that some old versions of Git do not parse "--" in this command // properly. We omit it since it doesn't seem like there's anything // dangerous an attacker can do even if they can choose a remote name to // intentionally cause an argument misparse. // This will cause the command to behave incorrectly for remotes with // names which are also valid flags, like "--quiet". list($err, $stdout) = $this->execManualLocal( 'ls-remote --get-url %s', $remote_name); if (!$err) { // The "git ls-remote --get-url" command just echoes the remote name // (like "origin") if no remote URI is found. Treat this like a failure. $output_is_input = (rtrim($stdout) === $remote_name); if (!$output_is_input) { return $stdout; } } if (version_compare($git_version, '1.7.5', '>=')) { // This version of Git should support "git ls-remote --get-url", but // the command failed (or echoed the input), so conclude the remote // really does not exist. return null; } // Fall back to the very old "git config -- remote.origin.url" command. // This does not give us push URLs and does not resolve "insteadOf" // aliases, but still works in the simplest (and most common) cases. list($err, $stdout) = $this->execManualLocal( 'config -- %s', sprintf('remote.%s.url', $remote_name)); if (!$err) { return $stdout; } return null; } protected function newCurrentCommitSymbol() { return 'HEAD'; } public function isGitLFSWorkingCopy() { // We're going to run: // // $ git ls-files -z -- ':(attr:filter=lfs)' // // ...and exit as soon as it generates any field terminated with a "\0". // // If this command generates any such output, that means this working copy // contains at least one LFS file, so it's an LFS working copy. If it // exits with no error and no output, this is not an LFS working copy. // // If it exits with an error, we're in trouble. $future = $this->buildLocalFuture( array( 'ls-files -z -- %s', ':(attr:filter=lfs)', )); $lfs_list = id(new LinesOfALargeExecFuture($future)) ->setDelimiter("\0"); try { foreach ($lfs_list as $lfs_file) { // We have our answer, so we can throw the subprocess away. $future->resolveKill(); return true; } return false; } catch (CommandException $ex) { // This is probably an older version of Git. Continue below. } // In older versions of Git, the first command will fail with an error // ("Invalid pathspec magic..."). See PHI1718. // // Some other tests we could use include: // // (1) Look for ".gitattributes" at the repository root. This approach is // a rough approximation because ".gitattributes" may be global or in a // subdirectory. See D21190. // // (2) Use "git check-attr" and pipe a bunch of files into it, roughly // like this: // // $ git ls-files -z -- | git check-attr --stdin -z filter -- // // However, the best version of this check I could come up with is fairly // slow in even moderately large repositories (~200ms in a repository with // 10K paths). See D21190. // // (3) Use "git lfs ls-files". This is even worse than piping "ls-files" // to "check-attr" in PHP (~600ms in a repository with 10K paths). // // (4) Give up and just assume the repository isn't LFS. This is the // current behavior. return false; } + protected function newLandEngine() { + return new ArcanistGitLandEngine(); + } + + protected function newWorkEngine() { + return new ArcanistGitWorkEngine(); + } + + public function newLocalState() { + return id(new ArcanistGitLocalState()) + ->setRepositoryAPI($this); + } + + public function readRawCommit($hash) { + list($stdout) = $this->execxLocal( + 'cat-file commit -- %s', + $hash); + + return ArcanistGitRawCommit::newFromRawBlob($stdout); + } + + public function writeRawCommit(ArcanistGitRawCommit $commit) { + $blob = $commit->getRawBlob(); + + $future = $this->execFutureLocal('hash-object -t commit --stdin -w'); + $future->write($blob); + list($stdout) = $future->resolvex(); + + return trim($stdout); + } + + protected function newSupportedMarkerTypes() { + return array( + ArcanistMarkerRef::TYPE_BRANCH, + ); + } + + protected function newMarkerRefQueryTemplate() { + return new ArcanistGitRepositoryMarkerQuery(); + } + + protected function newRemoteRefQueryTemplate() { + return new ArcanistGitRepositoryRemoteQuery(); + } + + protected function newNormalizedURI($uri) { + return new ArcanistRepositoryURINormalizer( + ArcanistRepositoryURINormalizer::TYPE_GIT, + $uri); + } + + protected function newPublishedCommitHashes() { + $remotes = $this->newRemoteRefQuery() + ->execute(); + if (!$remotes) { + return array(); + } + + $markers = $this->newMarkerRefQuery() + ->withIsRemoteCache(true) + ->execute(); + + if (!$markers) { + return array(); + } + + $runtime = $this->getRuntime(); + $workflow = $runtime->getCurrentWorkflow(); + + $workflow->loadHardpoints( + $remotes, + ArcanistRemoteRef::HARDPOINT_REPOSITORYREFS); + + $remotes = mpull($remotes, null, 'getRemoteName'); + + $hashes = array(); + + foreach ($markers as $marker) { + $remote_name = $marker->getRemoteName(); + $remote = idx($remotes, $remote_name); + if (!$remote) { + continue; + } + + if (!$remote->isPermanentRef($marker)) { + continue; + } + + $hashes[] = $marker->getCommitHash(); + } + + return $hashes; + } + + protected function newCommitGraphQueryTemplate() { + return new ArcanistGitCommitGraphQuery(); + } + } diff --git a/src/repository/api/ArcanistMercurialAPI.php b/src/repository/api/ArcanistMercurialAPI.php index 1a2db585..cffb9306 100644 --- a/src/repository/api/ArcanistMercurialAPI.php +++ b/src/repository/api/ArcanistMercurialAPI.php @@ -1,1111 +1,1058 @@ getMercurialEnvironmentVariables(); $argv[0] = 'hg '.$argv[0]; $future = newv('ExecFuture', $argv) ->setEnv($env) ->setCWD($this->getPath()); return $future; } - public function execPassthru($pattern /* , ... */) { + public function newPassthru($pattern /* , ... */) { $args = func_get_args(); $env = $this->getMercurialEnvironmentVariables(); $args[0] = 'hg '.$args[0]; - $passthru = newv('PhutilExecPassthru', $args) + return newv('PhutilExecPassthru', $args) ->setEnv($env) ->setCWD($this->getPath()); - - return $passthru->resolve(); } public function getSourceControlSystemName() { return 'hg'; } public function getMetadataPath() { return $this->getPath('.hg'); } public function getSourceControlBaseRevision() { return $this->getCanonicalRevisionName($this->getBaseCommit()); } public function getCanonicalRevisionName($string) { - $match = null; - if ($this->isHgSubversionRepo() && - preg_match('/@([0-9]+)$/', $string, $match)) { - $string = hgsprintf('svnrev(%s)', $match[1]); - } - list($stdout) = $this->execxLocal( 'log -l 1 --template %s -r %s --', '{node}', $string); - return $stdout; - } - - public function getHashFromFromSVNRevisionNumber($revision_id) { - $matches = array(); - $string = hgsprintf('svnrev(%s)', $revision_id); - list($stdout) = $this->execxLocal( - 'log -l 1 --template %s -r %s --', - '{node}', - $string); - if (!$stdout) { - throw new ArcanistUsageException( - pht('Cannot find the HG equivalent of %s given.', $revision_id)); - } - return $stdout; - } - - public function getSVNRevisionNumberFromHash($hash) { - $matches = array(); - list($stdout) = $this->execxLocal( - 'log -r %s --template {svnrev}', $hash); - if (!$stdout) { - throw new ArcanistUsageException( - pht('Cannot find the SVN equivalent of %s given.', $hash)); - } return $stdout; } public function getSourceControlPath() { return '/'; } public function getBranchName() { if (!$this->branch) { list($stdout) = $this->execxLocal('branch'); $this->branch = trim($stdout); } return $this->branch; } protected function didReloadCommitRange() { $this->localCommitInfo = null; } protected function buildBaseCommit($symbolic_commit) { if ($symbolic_commit !== null) { try { $commit = $this->getCanonicalRevisionName( hgsprintf('ancestor(%s,.)', $symbolic_commit)); } catch (Exception $ex) { // Try it as a revset instead of a commit id try { $commit = $this->getCanonicalRevisionName( hgsprintf('ancestor(%R,.)', $symbolic_commit)); } catch (Exception $ex) { throw new ArcanistUsageException( pht( "Commit '%s' is not a valid Mercurial commit identifier.", $symbolic_commit)); } } $this->setBaseCommitExplanation( pht( 'it is the greatest common ancestor of the working directory '. 'and the commit you specified explicitly.')); return $commit; } if ($this->getBaseCommitArgumentRules() || $this->getConfigurationManager()->getConfigFromAnySource('base')) { $base = $this->resolveBaseCommit(); if (!$base) { throw new ArcanistUsageException( pht( "None of the rules in your 'base' configuration matched a valid ". "commit. Adjust rules or specify which commit you want to use ". "explicitly.")); } return $base; } - // Mercurial 2.1 and up have phases which indicate if something is - // published or not. To find which revs are outgoing, it's much - // faster to check the phase instead of actually checking the server. - if ($this->supportsPhases()) { - list($err, $stdout) = $this->execManualLocal( - 'log --branch %s -r %s --style default', - $this->getBranchName(), - 'draft()'); - } else { - list($err, $stdout) = $this->execManualLocal( - 'outgoing --branch %s --style default', - $this->getBranchName()); - } + list($err, $stdout) = $this->execManualLocal( + 'log --branch %s -r %s --style default', + $this->getBranchName(), + 'draft()'); if (!$err) { $logs = ArcanistMercurialParser::parseMercurialLog($stdout); } else { // Mercurial (in some versions?) raises an error when there's nothing // outgoing. $logs = array(); } if (!$logs) { $this->setBaseCommitExplanation( pht( 'you have no outgoing commits, so arc assumes you intend to submit '. 'uncommitted changes in the working copy.')); return $this->getWorkingCopyRevision(); } $outgoing_revs = ipull($logs, 'rev'); // This is essentially an implementation of a theoretical `hg merge-base` // command. $against = $this->getWorkingCopyRevision(); while (true) { // NOTE: The "^" and "~" syntaxes were only added in hg 1.9, which is // new as of July 2011, so do this in a compatible way. Also, "hg log" // and "hg outgoing" don't necessarily show parents (even if given an // explicit template consisting of just the parents token) so we need // to separately execute "hg parents". list($stdout) = $this->execxLocal( 'parents --style default --rev %s', $against); $parents_logs = ArcanistMercurialParser::parseMercurialLog($stdout); list($p1, $p2) = array_merge($parents_logs, array(null, null)); if ($p1 && !in_array($p1['rev'], $outgoing_revs)) { $against = $p1['rev']; break; } else if ($p2 && !in_array($p2['rev'], $outgoing_revs)) { $against = $p2['rev']; break; } else if ($p1) { $against = $p1['rev']; } else { // This is the case where you have a new repository and the entire // thing is outgoing; Mercurial literally accepts "--rev null" as // meaning "diff against the empty state". $against = 'null'; break; } } if ($against == 'null') { $this->setBaseCommitExplanation( pht('this is a new repository (all changes are outgoing).')); } else { $this->setBaseCommitExplanation( pht( 'it is the first commit reachable from the working copy state '. 'which is not outgoing.')); } return $against; } public function getLocalCommitInformation() { if ($this->localCommitInfo === null) { $base_commit = $this->getBaseCommit(); list($info) = $this->execxLocal( 'log --template %s --rev %s --branch %s --', "{node}\1{rev}\1{author}\1". "{date|rfc822date}\1{branch}\1{tag}\1{parents}\1{desc}\2", hgsprintf('(%s::. - %s)', $base_commit, $base_commit), $this->getBranchName()); $logs = array_filter(explode("\2", $info)); $last_node = null; $futures = array(); $commits = array(); foreach ($logs as $log) { list($node, $rev, $full_author, $date, $branch, $tag, $parents, $desc) = explode("\1", $log, 9); list($author, $author_email) = $this->parseFullAuthor($full_author); // NOTE: If a commit has only one parent, {parents} returns empty. // If it has two parents, {parents} returns revs and short hashes, not // full hashes. Try to avoid making calls to "hg parents" because it's // relatively expensive. $commit_parents = null; if (!$parents) { if ($last_node) { $commit_parents = array($last_node); } } if (!$commit_parents) { // We didn't get a cheap hit on previous commit, so do the full-cost // "hg parents" call. We can run these in parallel, at least. $futures[$node] = $this->execFutureLocal( 'parents --template %s --rev %s', '{node}\n', $node); } $commits[$node] = array( 'author' => $author, 'time' => strtotime($date), 'branch' => $branch, 'tag' => $tag, 'commit' => $node, 'rev' => $node, // TODO: Remove eventually. 'local' => $rev, 'parents' => $commit_parents, 'summary' => head(explode("\n", $desc)), 'message' => $desc, 'authorEmail' => $author_email, ); $last_node = $node; } $futures = id(new FutureIterator($futures)) ->limit(4); foreach ($futures as $node => $future) { list($parents) = $future->resolvex(); $parents = array_filter(explode("\n", $parents)); $commits[$node]['parents'] = $parents; } // Put commits in newest-first order, to be consistent with Git and the // expected order of "hg log" and "git log" under normal circumstances. // The order of ancestors() is oldest-first. $commits = array_reverse($commits); $this->localCommitInfo = $commits; } return $this->localCommitInfo; } public function getAllFiles() { // TODO: Handle paths with newlines. $future = $this->buildLocalFuture(array('manifest')); return new LinesOfALargeExecFuture($future); } public function getChangedFiles($since_commit) { list($stdout) = $this->execxLocal( 'status --rev %s', $since_commit); return ArcanistMercurialParser::parseMercurialStatus($stdout); } public function getBlame($path) { list($stdout) = $this->execxLocal( 'annotate -u -v -c --rev %s -- %s', $this->getBaseCommit(), $path); $lines = phutil_split_lines($stdout, $retain_line_endings = true); $blame = array(); foreach ($lines as $line) { if (!strlen($line)) { continue; } $matches = null; $ok = preg_match('/^\s*([^:]+?) ([a-f0-9]{12}):/', $line, $matches); if (!$ok) { throw new Exception( pht( 'Unable to parse Mercurial blame line: %s', $line)); } $revision = $matches[2]; $author = trim($matches[1]); $blame[] = array($author, $revision); } return $blame; } protected function buildUncommittedStatus() { list($stdout) = $this->execxLocal('status'); $results = new PhutilArrayWithDefaultValue(); $working_status = ArcanistMercurialParser::parseMercurialStatus($stdout); foreach ($working_status as $path => $mask) { if (!($mask & parent::FLAG_UNTRACKED)) { // Mark tracked files as uncommitted. $mask |= self::FLAG_UNCOMMITTED; } $results[$path] |= $mask; } return $results->toArray(); } protected function buildCommitRangeStatus() { list($stdout) = $this->execxLocal( 'status --rev %s --rev tip', $this->getBaseCommit()); $results = new PhutilArrayWithDefaultValue(); $working_status = ArcanistMercurialParser::parseMercurialStatus($stdout); foreach ($working_status as $path => $mask) { $results[$path] |= $mask; } return $results->toArray(); } protected function didReloadWorkingCopy() { // Diffs are against ".", so we need to drop the cache if we change the // working copy. $this->rawDiffCache = array(); $this->branch = null; } private function getDiffOptions() { $options = array( '--git', '-U'.$this->getDiffLinesOfContext(), ); return implode(' ', $options); } public function getRawDiffText($path) { $options = $this->getDiffOptions(); $range = $this->getBaseCommit(); $raw_diff_cache_key = $options.' '.$range.' '.$path; if (idx($this->rawDiffCache, $raw_diff_cache_key)) { return idx($this->rawDiffCache, $raw_diff_cache_key); } list($stdout) = $this->execxLocal( 'diff %C --rev %s -- %s', $options, $range, $path); $this->rawDiffCache[$raw_diff_cache_key] = $stdout; return $stdout; } public function getFullMercurialDiff() { return $this->getRawDiffText(''); } public function getOriginalFileData($path) { return $this->getFileDataAtRevision($path, $this->getBaseCommit()); } public function getCurrentFileData($path) { return $this->getFileDataAtRevision( $path, $this->getWorkingCopyRevision()); } public function getBulkOriginalFileData($paths) { return $this->getBulkFileDataAtRevision($paths, $this->getBaseCommit()); } public function getBulkCurrentFileData($paths) { return $this->getBulkFileDataAtRevision( $paths, $this->getWorkingCopyRevision()); } private function getBulkFileDataAtRevision($paths, $revision) { // Calling 'hg cat' on each file individually is slow (1 second per file // on a large repo) because mercurial has to decompress and parse the // entire manifest every time. Do it in one large batch instead. // hg cat will write the file data to files in a temp directory $tmpdir = Filesystem::createTemporaryDirectory(); // Mercurial doesn't create the directories for us :( foreach ($paths as $path) { $tmppath = $tmpdir.'/'.$path; Filesystem::createDirectory(dirname($tmppath), 0755, true); } // NOTE: The "%s%%p" construction passes a literal "%p" to Mercurial, // which is a formatting directive for a repo-relative filepath. The // particulars of the construction avoid Windows escaping issues. See // PHI904. list($err, $stdout) = $this->execManualLocal( 'cat --rev %s --output %s%%p -- %Ls', $revision, $tmpdir.DIRECTORY_SEPARATOR, $paths); $filedata = array(); foreach ($paths as $path) { $tmppath = $tmpdir.'/'.$path; if (Filesystem::pathExists($tmppath)) { $filedata[$path] = Filesystem::readFile($tmppath); } } Filesystem::remove($tmpdir); return $filedata; } private function getFileDataAtRevision($path, $revision) { list($err, $stdout) = $this->execManualLocal( 'cat --rev %s -- %s', $revision, $path); if ($err) { // Assume this is "no file at revision", i.e. a deleted or added file. return null; } else { return $stdout; } } public function getWorkingCopyRevision() { return '.'; } public function isHistoryDefaultImmutable() { return true; } public function supportsAmend() { list($err, $stdout) = $this->execManualLocal('help commit'); if ($err) { return false; } else { return (strpos($stdout, 'amend') !== false); } } - public function supportsRebase() { - if ($this->supportsRebase === null) { - list($err) = $this->execManualLocal('help rebase'); - $this->supportsRebase = $err === 0; - } - - return $this->supportsRebase; - } - - public function supportsPhases() { - if ($this->supportsPhases === null) { - list($err) = $this->execManualLocal('help phase'); - $this->supportsPhases = $err === 0; - } - - return $this->supportsPhases; - } - public function supportsCommitRanges() { return true; } public function supportsLocalCommits() { return true; } - public function getAllBranches() { - list($branch_info) = $this->execxLocal('bookmarks'); - if (trim($branch_info) == 'no bookmarks set') { - return array(); - } - - $matches = null; - preg_match_all( - '/^\s*(\*?)\s*(.+)\s(\S+)$/m', - $branch_info, - $matches, - PREG_SET_ORDER); - - $return = array(); - foreach ($matches as $match) { - list(, $current, $name) = $match; - $return[] = array( - 'current' => (bool)$current, - 'name' => rtrim($name), - ); - } - return $return; - } - - public function getAllBranchRefs() { - $branches = $this->getAllBranches(); - - $refs = array(); - foreach ($branches as $branch) { - $refs[] = $this->newBranchRef() - ->setBranchName($branch['name']) - ->setIsCurrentBranch($branch['current']); - } - - return $refs; - } - public function getBaseCommitRef() { $base_commit = $this->getBaseCommit(); if ($base_commit === 'null') { return null; } $base_message = $this->getCommitMessage($base_commit); return $this->newCommitRef() ->setCommitHash($base_commit) ->attachMessage($base_message); } public function hasLocalCommit($commit) { try { $this->getCanonicalRevisionName($commit); return true; } catch (Exception $ex) { return false; } } public function getCommitMessage($commit) { list($message) = $this->execxLocal( 'log --template={desc} --rev %s', $commit); return $message; } public function getAllLocalChanges() { $diff = $this->getFullMercurialDiff(); if (!strlen(trim($diff))) { return array(); } $parser = new ArcanistDiffParser(); return $parser->parseDiff($diff); } public function getFinalizedRevisionMessage() { return pht( "You may now push this commit upstream, as appropriate (e.g. with ". "'%s' or by printing and faxing it).", 'hg push'); } public function getCommitMessageLog() { $base_commit = $this->getBaseCommit(); list($stdout) = $this->execxLocal( 'log --template %s --rev %s --branch %s --', "{node}\1{desc}\2", hgsprintf('(%s::. - %s)', $base_commit, $base_commit), $this->getBranchName()); $map = array(); $logs = explode("\2", trim($stdout)); foreach (array_filter($logs) as $log) { list($node, $desc) = explode("\1", $log); $map[$node] = $desc; } return array_reverse($map); } public function loadWorkingCopyDifferentialRevisions( ConduitClient $conduit, array $query) { $messages = $this->getCommitMessageLog(); $parser = new ArcanistDiffParser(); // First, try to find revisions by explicit revision IDs in commit messages. $reason_map = array(); $revision_ids = array(); foreach ($messages as $node_id => $message) { $object = ArcanistDifferentialCommitMessage::newFromRawCorpus($message); if ($object->getRevisionID()) { $revision_ids[] = $object->getRevisionID(); $reason_map[$object->getRevisionID()] = $node_id; } } if ($revision_ids) { $results = $conduit->callMethodSynchronous( 'differential.query', $query + array( 'ids' => $revision_ids, )); foreach ($results as $key => $result) { $hash = substr($reason_map[$result['id']], 0, 16); $results[$key]['why'] = pht( "Commit message for '%s' has explicit 'Differential Revision'.", $hash); } return $results; } // Try to find revisions by hash. $hashes = array(); foreach ($this->getLocalCommitInformation() as $commit) { $hashes[] = array('hgcm', $commit['commit']); } if ($hashes) { // NOTE: In the case of "arc diff . --uncommitted" in a Mercurial working // copy with dirty changes, there may be no local commits. $results = $conduit->callMethodSynchronous( 'differential.query', $query + array( 'commitHashes' => $hashes, )); foreach ($results as $key => $hash) { $results[$key]['why'] = pht( 'A mercurial commit hash in the commit range is already attached '. 'to the Differential revision.'); } return $results; } return array(); } public function updateWorkingCopy() { $this->execxLocal('up'); $this->reloadWorkingCopy(); } private function getMercurialConfig($key, $default = null) { list($stdout) = $this->execxLocal('showconfig %s', $key); if ($stdout == '') { return $default; } return rtrim($stdout); } public function getAuthor() { $full_author = $this->getMercurialConfig('ui.username'); list($author, $author_email) = $this->parseFullAuthor($full_author); return $author; } /** * Parse the Mercurial author field. * * Not everyone enters their email address as a part of the username * field. Try to make it work when it's obvious. * * @param string $full_author * @return array */ protected function parseFullAuthor($full_author) { if (strpos($full_author, '@') === false) { $author = $full_author; $author_email = null; } else { $email = new PhutilEmailAddress($full_author); $author = $email->getDisplayName(); $author_email = $email->getAddress(); } return array($author, $author_email); } public function addToCommit(array $paths) { $this->execxLocal( 'addremove -- %Ls', $paths); $this->reloadWorkingCopy(); } public function doCommit($message) { $tmp_file = new TempFile(); Filesystem::writeFile($tmp_file, $message); $this->execxLocal('commit -l %s', $tmp_file); $this->reloadWorkingCopy(); } public function amendCommit($message = null) { if ($message === null) { $message = $this->getCommitMessage('.'); } $tmp_file = new TempFile(); Filesystem::writeFile($tmp_file, $message); try { $this->execxLocal( 'commit --amend -l %s', $tmp_file); } catch (CommandException $ex) { if (preg_match('/nothing changed/', $ex->getStdout())) { // NOTE: Mercurial considers it an error to make a no-op amend. Although // we generally defer to the underlying VCS to dictate behavior, this // one seems a little goofy, and we use amend as part of various // workflows under the assumption that no-op amends are fine. If this // amend failed because it's a no-op, just continue. } else { throw $ex; } } $this->reloadWorkingCopy(); } public function getCommitSummary($commit) { if ($commit == 'null') { return pht('(The Empty Void)'); } list($summary) = $this->execxLocal( 'log --template {desc} --limit 1 --rev %s', $commit); $summary = head(explode("\n", $summary)); return trim($summary); } public function resolveBaseCommitRule($rule, $source) { list($type, $name) = explode(':', $rule, 2); // NOTE: This function MUST return node hashes or symbolic commits (like // branch names or the word "tip"), not revsets. This includes ".^" and // similar, which a revset, not a symbolic commit identifier. If you return // a revset it will be escaped later and looked up literally. switch ($type) { case 'hg': $matches = null; if (preg_match('/^gca\((.+)\)$/', $name, $matches)) { list($err, $merge_base) = $this->execManualLocal( 'log --template={node} --rev %s', sprintf('ancestor(., %s)', $matches[1])); if (!$err) { $this->setBaseCommitExplanation( pht( "it is the greatest common ancestor of '%s' and %s, as ". "specified by '%s' in your %s 'base' configuration.", $matches[1], '.', $rule, $source)); return trim($merge_base); } } else { list($err, $commit) = $this->execManualLocal( 'log --template {node} --rev %s', hgsprintf('%s', $name)); if ($err) { list($err, $commit) = $this->execManualLocal( 'log --template {node} --rev %s', $name); } if (!$err) { $this->setBaseCommitExplanation( pht( "it is specified by '%s' in your %s 'base' configuration.", $rule, $source)); return trim($commit); } } break; case 'arc': switch ($name) { case 'empty': $this->setBaseCommitExplanation( pht( "you specified '%s' in your %s 'base' configuration.", $rule, $source)); return 'null'; case 'outgoing': list($err, $outgoing_base) = $this->execManualLocal( 'log --template={node} --rev %s', 'limit(reverse(ancestors(.) - outgoing()), 1)'); if (!$err) { $this->setBaseCommitExplanation( pht( "it is the first ancestor of the working copy that is not ". "outgoing, and it matched the rule %s in your %s ". "'base' configuration.", $rule, $source)); return trim($outgoing_base); } case 'amended': $text = $this->getCommitMessage('.'); $message = ArcanistDifferentialCommitMessage::newFromRawCorpus( $text); if ($message->getRevisionID()) { $this->setBaseCommitExplanation( pht( "'%s' has been amended with 'Differential Revision:', ". "as specified by '%s' in your %s 'base' configuration.", '.'. $rule, $source)); // NOTE: This should be safe because Mercurial doesn't support // amend until 2.2. return $this->getCanonicalRevisionName('.^'); } break; case 'bookmark': $revset = 'limit('. ' sort('. ' (ancestors(.) and bookmark() - .) or'. ' (ancestors(.) - outgoing()), '. ' -rev),'. '1)'; list($err, $bookmark_base) = $this->execManualLocal( 'log --template={node} --rev %s', $revset); if (!$err) { $this->setBaseCommitExplanation( pht( "it is the first ancestor of %s that either has a bookmark, ". "or is already in the remote and it matched the rule %s in ". "your %s 'base' configuration", '.', $rule, $source)); return trim($bookmark_base); } break; case 'this': $this->setBaseCommitExplanation( pht( "you specified '%s' in your %s 'base' configuration.", $rule, $source)); return $this->getCanonicalRevisionName('.^'); default: if (preg_match('/^nodiff\((.+)\)$/', $name, $matches)) { list($results) = $this->execxLocal( 'log --template %s --rev %s', "{node}\1{desc}\2", sprintf('ancestor(.,%s)::.^', $matches[1])); $results = array_reverse(explode("\2", trim($results))); foreach ($results as $result) { if (empty($result)) { continue; } list($node, $desc) = explode("\1", $result, 2); $message = ArcanistDifferentialCommitMessage::newFromRawCorpus( $desc); if ($message->getRevisionID()) { $this->setBaseCommitExplanation( pht( "it is the first ancestor of %s that has a diff and is ". "the gca or a descendant of the gca with '%s', ". "specified by '%s' in your %s 'base' configuration.", '.', $matches[1], $rule, $source)); return $node; } } } break; } break; default: return null; } return null; } - public function isHgSubversionRepo() { - return file_exists($this->getPath('.hg/svn/rev_map')); - } - public function getSubversionInfo() { $info = array(); $base_path = null; $revision = null; list($err, $raw_info) = $this->execManualLocal('svn info'); if (!$err) { foreach (explode("\n", trim($raw_info)) as $line) { list($key, $value) = explode(': ', $line, 2); switch ($key) { case 'URL': $info['base_path'] = $value; $base_path = $value; break; case 'Repository UUID': $info['uuid'] = $value; break; case 'Revision': $revision = $value; break; default: break; } } if ($base_path && $revision) { $info['base_revision'] = $base_path.'@'.$revision; } } return $info; } public function getActiveBookmark() { - $bookmarks = $this->getBookmarks(); - foreach ($bookmarks as $bookmark) { - if ($bookmark['is_active']) { - return $bookmark['name']; - } - } - - return null; - } - - public function isBookmark($name) { - $bookmarks = $this->getBookmarks(); - foreach ($bookmarks as $bookmark) { - if ($bookmark['name'] === $name) { - return true; - } - } - - return false; - } - - public function isBranch($name) { - $branches = $this->getBranches(); - foreach ($branches as $branch) { - if ($branch['name'] === $name) { - return true; - } - } - - return false; - } - - public function getBranches() { - list($stdout) = $this->execxLocal('--debug branches'); - $lines = ArcanistMercurialParser::parseMercurialBranches($stdout); - - $branches = array(); - foreach ($lines as $name => $spec) { - $branches[] = array( - 'name' => $name, - 'revision' => $spec['rev'], - ); - } - - return $branches; - } - - public function getBookmarks() { - $bookmarks = array(); - - list($raw_output) = $this->execxLocal('bookmarks'); - $raw_output = trim($raw_output); - if ($raw_output !== 'no bookmarks set') { - foreach (explode("\n", $raw_output) as $line) { - // example line: * mybook 2:6b274d49be97 - list($name, $revision) = $this->splitBranchOrBookmarkLine($line); + $bookmark = $this->newMarkerRefQuery() + ->withMarkerTypes(ArcanistMarkerRef::TYPE_BOOKMARK) + ->withIsActive(true) + ->executeOne(); - $is_active = false; - if ('*' === $name[0]) { - $is_active = true; - $name = substr($name, 2); - } - - $bookmarks[] = array( - 'is_active' => $is_active, - 'name' => $name, - 'revision' => $revision, - ); - } + if (!$bookmark) { + return null; } - return $bookmarks; - } - - private function splitBranchOrBookmarkLine($line) { - // branches and bookmarks are printed in the format: - // default 0:a5ead76cdf85 (inactive) - // * mybook 2:6b274d49be97 - // this code divides the name half from the revision half - // it does not parse the * and (inactive) bits - $colon_index = strrpos($line, ':'); - $before_colon = substr($line, 0, $colon_index); - $start_rev_index = strrpos($before_colon, ' '); - $name = substr($line, 0, $start_rev_index); - $rev = substr($line, $start_rev_index); - - return array(trim($name), trim($rev)); + return $bookmark->getName(); } public function getRemoteURI() { + // TODO: Remove this method in favor of RemoteRefQuery. + list($stdout) = $this->execxLocal('paths default'); $stdout = trim($stdout); if (strlen($stdout)) { return $stdout; } return null; } private function getMercurialEnvironmentVariables() { $env = array(); // Mercurial has a "defaults" feature which basically breaks automation by // allowing the user to add random flags to any command. This feature is // "deprecated" and "a bad idea" that you should "forget ... existed" // according to project lead Matt Mackall: // // http://markmail.org/message/hl3d6eprubmkkqh5 // // There is an HGPLAIN environmental variable which enables "plain mode" // and hopefully disables this stuff. $env['HGPLAIN'] = 1; return $env; } + protected function newLandEngine() { + return new ArcanistMercurialLandEngine(); + } + + protected function newWorkEngine() { + return new ArcanistMercurialWorkEngine(); + } + + public function newLocalState() { + return id(new ArcanistMercurialLocalState()) + ->setRepositoryAPI($this); + } + + public function willTestMercurialFeature($feature) { + $this->executeMercurialFeatureTest($feature, false); + return $this; + } + + public function getMercurialFeature($feature) { + return $this->executeMercurialFeatureTest($feature, true); + } + + private function executeMercurialFeatureTest($feature, $resolve) { + if (array_key_exists($feature, $this->featureResults)) { + return $this->featureResults[$feature]; + } + + if (!array_key_exists($feature, $this->featureFutures)) { + $future = $this->newMercurialFeatureFuture($feature); + $future->start(); + $this->featureFutures[$feature] = $future; + } + + if (!$resolve) { + return; + } + + $future = $this->featureFutures[$feature]; + $result = $this->resolveMercurialFeatureFuture($feature, $future); + $this->featureResults[$feature] = $result; + + return $result; + } + + private function newMercurialFeatureFuture($feature) { + switch ($feature) { + case 'shelve': + return $this->execFutureLocal( + '--config extensions.shelve= shelve --help --'); + case 'evolve': + return $this->execFutureLocal('prune --help --'); + default: + throw new Exception( + pht( + 'Unknown Mercurial feature "%s".', + $feature)); + } + } + + private function resolveMercurialFeatureFuture($feature, $future) { + // By default, assume the feature is a simple capability test and the + // capability is present if the feature resolves without an error. + + list($err) = $future->resolve(); + return !$err; + } + + protected function newSupportedMarkerTypes() { + return array( + ArcanistMarkerRef::TYPE_BRANCH, + ArcanistMarkerRef::TYPE_BOOKMARK, + ); + } + + protected function newMarkerRefQueryTemplate() { + return new ArcanistMercurialRepositoryMarkerQuery(); + } + + protected function newRemoteRefQueryTemplate() { + return new ArcanistMercurialRepositoryRemoteQuery(); + } + + public function getMercurialExtensionArguments() { + $path = phutil_get_library_root('arcanist'); + $path = dirname($path); + $path = $path.'/support/hg/arc-hg.py'; + + return array( + '--config', + 'extensions.arc-hg='.$path, + ); + } + + protected function newNormalizedURI($uri) { + return new ArcanistRepositoryURINormalizer( + ArcanistRepositoryURINormalizer::TYPE_MERCURIAL, + $uri); + } + + protected function newCommitGraphQueryTemplate() { + return new ArcanistMercurialCommitGraphQuery(); + } + + protected function newPublishedCommitHashes() { + $future = $this->newFuture( + 'log --rev %s --template %s', + hgsprintf('parents(draft()) - draft()'), + '{node}\n'); + list($lines) = $future->resolve(); + + $lines = phutil_split_lines($lines, false); + + $hashes = array(); + foreach ($lines as $line) { + if (!strlen(trim($line))) { + continue; + } + $hashes[] = $line; + } + + return $hashes; + } + } diff --git a/src/repository/api/ArcanistRepositoryAPI.php b/src/repository/api/ArcanistRepositoryAPI.php index 5d2c748f..48b44f66 100644 --- a/src/repository/api/ArcanistRepositoryAPI.php +++ b/src/repository/api/ArcanistRepositoryAPI.php @@ -1,737 +1,837 @@ diffLinesOfContext; } public function setDiffLinesOfContext($lines) { $this->diffLinesOfContext = $lines; return $this; } public function getWorkingCopyIdentity() { return $this->configurationManager->getWorkingCopyIdentity(); } public function getConfigurationManager() { return $this->configurationManager; } public static function newAPIFromConfigurationManager( ArcanistConfigurationManager $configuration_manager) { $working_copy = $configuration_manager->getWorkingCopyIdentity(); if (!$working_copy) { throw new Exception( pht( 'Trying to create a %s without a working copy!', __CLASS__)); } $root = $working_copy->getProjectRoot(); switch ($working_copy->getVCSType()) { case 'svn': $api = new ArcanistSubversionAPI($root); break; case 'hg': $api = new ArcanistMercurialAPI($root); break; case 'git': $api = new ArcanistGitAPI($root); break; default: throw new Exception( pht( 'The current working directory is not part of a working copy for '. 'a supported version control system (Git, Subversion or '. 'Mercurial).')); } $api->configurationManager = $configuration_manager; return $api; } public function __construct($path) { $this->path = $path; } public function getPath($to_file = null) { if ($to_file !== null) { return $this->path.DIRECTORY_SEPARATOR. ltrim($to_file, DIRECTORY_SEPARATOR); } else { return $this->path.DIRECTORY_SEPARATOR; } } /* -( Path Status )-------------------------------------------------------- */ abstract protected function buildUncommittedStatus(); abstract protected function buildCommitRangeStatus(); /** * Get a list of uncommitted paths in the working copy that have been changed * or are affected by other status effects, like conflicts or untracked * files. * * Convenience methods @{method:getUntrackedChanges}, * @{method:getUnstagedChanges}, @{method:getUncommittedChanges}, * @{method:getMergeConflicts}, and @{method:getIncompleteChanges} allow * simpler selection of paths in a specific state. * * This method returns a map of paths to bitmasks with status, using * `FLAG_` constants. For example: * * array( * 'some/uncommitted/file.txt' => ArcanistRepositoryAPI::FLAG_UNSTAGED, * ); * * A file may be in several states. Not all states are possible with all * version control systems. * * @return map Map of paths, see above. * @task status */ final public function getUncommittedStatus() { if ($this->uncommittedStatusCache === null) { $status = $this->buildUncommittedStatus(); ksort($status); $this->uncommittedStatusCache = $status; } return $this->uncommittedStatusCache; } /** * @task status */ final public function getUntrackedChanges() { return $this->getUncommittedPathsWithMask(self::FLAG_UNTRACKED); } /** * @task status */ final public function getUnstagedChanges() { return $this->getUncommittedPathsWithMask(self::FLAG_UNSTAGED); } /** * @task status */ final public function getUncommittedChanges() { return $this->getUncommittedPathsWithMask(self::FLAG_UNCOMMITTED); } /** * @task status */ final public function getMergeConflicts() { return $this->getUncommittedPathsWithMask(self::FLAG_CONFLICT); } /** * @task status */ final public function getIncompleteChanges() { return $this->getUncommittedPathsWithMask(self::FLAG_INCOMPLETE); } /** * @task status */ final public function getMissingChanges() { return $this->getUncommittedPathsWithMask(self::FLAG_MISSING); } /** * @task status */ final public function getDirtyExternalChanges() { return $this->getUncommittedPathsWithMask(self::FLAG_EXTERNALS); } /** * @task status */ private function getUncommittedPathsWithMask($mask) { $match = array(); foreach ($this->getUncommittedStatus() as $path => $flags) { if ($flags & $mask) { $match[] = $path; } } return $match; } /** * Get a list of paths affected by the commits in the current commit range. * * See @{method:getUncommittedStatus} for a description of the return value. * * @return map Map from paths to status. * @task status */ final public function getCommitRangeStatus() { if ($this->commitRangeStatusCache === null) { $status = $this->buildCommitRangeStatus(); ksort($status); $this->commitRangeStatusCache = $status; } return $this->commitRangeStatusCache; } /** * Get a list of paths affected by commits in the current commit range, or * uncommitted changes in the working copy. See @{method:getUncommittedStatus} * or @{method:getCommitRangeStatus} to retrieve smaller parts of the status. * * See @{method:getUncommittedStatus} for a description of the return value. * * @return map Map from paths to status. * @task status */ final public function getWorkingCopyStatus() { $range_status = $this->getCommitRangeStatus(); $uncommitted_status = $this->getUncommittedStatus(); $result = new PhutilArrayWithDefaultValue($range_status); foreach ($uncommitted_status as $path => $mask) { $result[$path] |= $mask; } $result = $result->toArray(); ksort($result); return $result; } /** * Drops caches after changes to the working copy. By default, some queries * against the working copy are cached. They * * @return this * @task status */ final public function reloadWorkingCopy() { $this->uncommittedStatusCache = null; $this->commitRangeStatusCache = null; $this->didReloadWorkingCopy(); $this->reloadCommitRange(); return $this; } /** * Hook for implementations to dirty working copy caches after the working * copy has been updated. * * @return void * @task status */ protected function didReloadWorkingCopy() { return; } /** * Fetches the original file data for each path provided. * * @return map Map from path to file data. */ public function getBulkOriginalFileData($paths) { $filedata = array(); foreach ($paths as $path) { $filedata[$path] = $this->getOriginalFileData($path); } return $filedata; } /** * Fetches the current file data for each path provided. * * @return map Map from path to file data. */ public function getBulkCurrentFileData($paths) { $filedata = array(); foreach ($paths as $path) { $filedata[$path] = $this->getCurrentFileData($path); } return $filedata; } /** * @return Traversable */ abstract public function getAllFiles(); abstract public function getBlame($path); abstract public function getRawDiffText($path); abstract public function getOriginalFileData($path); abstract public function getCurrentFileData($path); abstract public function getLocalCommitInformation(); abstract public function getSourceControlBaseRevision(); abstract public function getCanonicalRevisionName($string); abstract public function getBranchName(); abstract public function getSourceControlPath(); abstract public function isHistoryDefaultImmutable(); abstract public function supportsAmend(); abstract public function getWorkingCopyRevision(); abstract public function updateWorkingCopy(); abstract public function getMetadataPath(); abstract public function loadWorkingCopyDifferentialRevisions( ConduitClient $conduit, array $query); abstract public function getRemoteURI(); public function getChangedFiles($since_commit) { throw new ArcanistCapabilityNotSupportedException($this); } public function getAuthor() { throw new ArcanistCapabilityNotSupportedException($this); } public function addToCommit(array $paths) { throw new ArcanistCapabilityNotSupportedException($this); } abstract public function supportsLocalCommits(); public function doCommit($message) { throw new ArcanistCapabilityNotSupportedException($this); } public function amendCommit($message = null) { throw new ArcanistCapabilityNotSupportedException($this); } - public function getAllBranches() { - // TODO: Implement for Mercurial/SVN and make abstract. - return array(); - } - - public function getAllBranchRefs() { - throw new ArcanistCapabilityNotSupportedException($this); - } - public function getBaseCommitRef() { throw new ArcanistCapabilityNotSupportedException($this); } public function hasLocalCommit($commit) { throw new ArcanistCapabilityNotSupportedException($this); } public function getCommitMessage($commit) { throw new ArcanistCapabilityNotSupportedException($this); } public function getCommitSummary($commit) { throw new ArcanistCapabilityNotSupportedException($this); } public function getAllLocalChanges() { throw new ArcanistCapabilityNotSupportedException($this); } public function getFinalizedRevisionMessage() { throw new ArcanistCapabilityNotSupportedException($this); } public function execxLocal($pattern /* , ... */) { $args = func_get_args(); return $this->buildLocalFuture($args)->resolvex(); } public function execManualLocal($pattern /* , ... */) { $args = func_get_args(); return $this->buildLocalFuture($args)->resolve(); } public function execFutureLocal($pattern /* , ... */) { $args = func_get_args(); return $this->buildLocalFuture($args); } abstract protected function buildLocalFuture(array $argv); public function canStashChanges() { return false; } public function stashChanges() { throw new ArcanistCapabilityNotSupportedException($this); } public function unstashChanges() { throw new ArcanistCapabilityNotSupportedException($this); } /* -( Scratch Files )------------------------------------------------------ */ /** * Try to read a scratch file, if it exists and is readable. * * @param string Scratch file name. * @return mixed String for file contents, or false for failure. * @task scratch */ public function readScratchFile($path) { $full_path = $this->getScratchFilePath($path); if (!$full_path) { return false; } if (!Filesystem::pathExists($full_path)) { return false; } try { $result = Filesystem::readFile($full_path); } catch (FilesystemException $ex) { return false; } return $result; } /** * Try to write a scratch file, if there's somewhere to put it and we can * write there. * * @param string Scratch file name to write. * @param string Data to write. * @return bool True on success, false on failure. * @task scratch */ public function writeScratchFile($path, $data) { $dir = $this->getScratchFilePath(''); if (!$dir) { return false; } if (!Filesystem::pathExists($dir)) { try { Filesystem::createDirectory($dir); } catch (Exception $ex) { return false; } } try { Filesystem::writeFile($this->getScratchFilePath($path), $data); } catch (FilesystemException $ex) { return false; } return true; } /** * Try to remove a scratch file. * * @param string Scratch file name to remove. * @return bool True if the file was removed successfully. * @task scratch */ public function removeScratchFile($path) { $full_path = $this->getScratchFilePath($path); if (!$full_path) { return false; } try { Filesystem::remove($full_path); } catch (FilesystemException $ex) { return false; } return true; } /** * Get a human-readable description of the scratch file location. * * @param string Scratch file name. * @return mixed String, or false on failure. * @task scratch */ public function getReadableScratchFilePath($path) { $full_path = $this->getScratchFilePath($path); if ($full_path) { return Filesystem::readablePath( $full_path, $this->getPath()); } else { return false; } } /** * Get the path to a scratch file, if possible. * * @param string Scratch file name. * @return mixed File path, or false on failure. * @task scratch */ public function getScratchFilePath($path) { $new_scratch_path = Filesystem::resolvePath( 'arc', $this->getMetadataPath()); static $checked = false; if (!$checked) { $checked = true; $old_scratch_path = $this->getPath('.arc'); // we only want to do the migration once // unfortunately, people have checked in .arc directories which // means that the old one may get recreated after we delete it if (Filesystem::pathExists($old_scratch_path) && !Filesystem::pathExists($new_scratch_path)) { Filesystem::createDirectory($new_scratch_path); $existing_files = Filesystem::listDirectory($old_scratch_path, true); foreach ($existing_files as $file) { $new_path = Filesystem::resolvePath($file, $new_scratch_path); $old_path = Filesystem::resolvePath($file, $old_scratch_path); Filesystem::writeFile( $new_path, Filesystem::readFile($old_path)); } Filesystem::remove($old_scratch_path); } } return Filesystem::resolvePath($path, $new_scratch_path); } /* -( Base Commits )------------------------------------------------------- */ abstract public function supportsCommitRanges(); final public function setBaseCommit($symbolic_commit) { if (!$this->supportsCommitRanges()) { throw new ArcanistCapabilityNotSupportedException($this); } $this->symbolicBaseCommit = $symbolic_commit; $this->reloadCommitRange(); return $this; } public function setHeadCommit($symbolic_commit) { throw new ArcanistCapabilityNotSupportedException($this); } final public function getBaseCommit() { if (!$this->supportsCommitRanges()) { throw new ArcanistCapabilityNotSupportedException($this); } if ($this->resolvedBaseCommit === null) { $commit = $this->buildBaseCommit($this->symbolicBaseCommit); $this->resolvedBaseCommit = $commit; } return $this->resolvedBaseCommit; } public function getHeadCommit() { throw new ArcanistCapabilityNotSupportedException($this); } final public function reloadCommitRange() { $this->resolvedBaseCommit = null; $this->baseCommitExplanation = null; $this->didReloadCommitRange(); return $this; } protected function didReloadCommitRange() { return; } protected function buildBaseCommit($symbolic_commit) { throw new ArcanistCapabilityNotSupportedException($this); } public function getBaseCommitExplanation() { return $this->baseCommitExplanation; } public function setBaseCommitExplanation($explanation) { $this->baseCommitExplanation = $explanation; return $this; } public function resolveBaseCommitRule($rule, $source) { return null; } public function setBaseCommitArgumentRules($base_commit_argument_rules) { $this->baseCommitArgumentRules = $base_commit_argument_rules; return $this; } public function getBaseCommitArgumentRules() { return $this->baseCommitArgumentRules; } public function resolveBaseCommit() { $base_commit_rules = array( 'runtime' => $this->getBaseCommitArgumentRules(), 'local' => '', 'project' => '', 'user' => '', 'system' => '', ); $all_sources = $this->configurationManager->getConfigFromAllSources('base'); $base_commit_rules = $all_sources + $base_commit_rules; $parser = new ArcanistBaseCommitParser($this); $commit = $parser->resolveBaseCommit($base_commit_rules); return $commit; } public function getRepositoryUUID() { return null; } final public function newFuture($pattern /* , ... */) { $args = func_get_args(); return $this->buildLocalFuture($args) ->setResolveOnError(false); } + public function newPassthru($pattern /* , ... */) { + throw new PhutilMethodNotImplementedException(); + } + + final public function execPassthru($pattern /* , ... */) { + $args = func_get_args(); + + $future = call_user_func_array( + array($this, 'newPassthru'), + $args); + + return $future->resolve(); + } + final public function setRuntime(ArcanistRuntime $runtime) { $this->runtime = $runtime; return $this; } final public function getRuntime() { return $this->runtime; } final protected function getSymbolEngine() { return $this->getRuntime()->getSymbolEngine(); } final public function getCurrentWorkingCopyStateRef() { if ($this->currentWorkingCopyStateRef === false) { $ref = $this->newCurrentWorkingCopyStateRef(); $this->currentWorkingCopyStateRef = $ref; } return $this->currentWorkingCopyStateRef; } protected function newCurrentWorkingCopyStateRef() { $commit_ref = $this->getCurrentCommitRef(); if (!$commit_ref) { return null; } return id(new ArcanistWorkingCopyStateRef()) ->setCommitRef($commit_ref); } final public function getCurrentCommitRef() { if ($this->currentCommitRef === false) { $this->currentCommitRef = $this->newCurrentCommitRef(); } return $this->currentCommitRef; } protected function newCurrentCommitRef() { $symbols = $this->getSymbolEngine(); $commit_symbol = $this->newCurrentCommitSymbol(); return $symbols->loadCommitForSymbol($commit_symbol); } protected function newCurrentCommitSymbol() { throw new ArcanistCapabilityNotSupportedException($this); } final public function newCommitRef() { return new ArcanistCommitRef(); } - final public function newBranchRef() { - return new ArcanistBranchRef(); + final public function newMarkerRef() { + return new ArcanistMarkerRef(); + } + + final public function getLandEngine() { + $engine = $this->newLandEngine(); + + if ($engine) { + $engine->setRepositoryAPI($this); + } + + return $engine; + } + + protected function newLandEngine() { + return null; + } + + final public function getWorkEngine() { + $engine = $this->newWorkEngine(); + + if ($engine) { + $engine->setRepositoryAPI($this); + } + + return $engine; + } + + protected function newWorkEngine() { + return null; + } + + final public function getSupportedMarkerTypes() { + return $this->newSupportedMarkerTypes(); } + + protected function newSupportedMarkerTypes() { + return array(); + } + + final public function newMarkerRefQuery() { + return id($this->newMarkerRefQueryTemplate()) + ->setRepositoryAPI($this); + } + + protected function newMarkerRefQueryTemplate() { + throw new PhutilMethodNotImplementedException(); + } + + final public function newRemoteRefQuery() { + return id($this->newRemoteRefQueryTemplate()) + ->setRepositoryAPI($this); + } + + protected function newRemoteRefQueryTemplate() { + throw new PhutilMethodNotImplementedException(); + } + + final public function newCommitGraphQuery() { + return id($this->newCommitGraphQueryTemplate()); + } + + protected function newCommitGraphQueryTemplate() { + throw new PhutilMethodNotImplementedException(); + } + + final public function getDisplayHash($hash) { + return substr($hash, 0, 12); + } + + + final public function getNormalizedURI($uri) { + $normalized_uri = $this->newNormalizedURI($uri); + return $normalized_uri->getNormalizedURI(); + } + + protected function newNormalizedURI($uri) { + return $uri; + } + + final public function getPublishedCommitHashes() { + return $this->newPublishedCommitHashes(); + } + + protected function newPublishedCommitHashes() { + return array(); + } + + final public function getGraph() { + if (!$this->graph) { + $this->graph = id(new ArcanistCommitGraph()) + ->setRepositoryAPI($this); + } + + return $this->graph; + } + } diff --git a/src/repository/graph/ArcanistCommitGraph.php b/src/repository/graph/ArcanistCommitGraph.php new file mode 100644 index 00000000..67d80458 --- /dev/null +++ b/src/repository/graph/ArcanistCommitGraph.php @@ -0,0 +1,55 @@ +repositoryAPI = $api; + return $this; + } + + public function getRepositoryAPI() { + return $this->repositoryAPI; + } + + public function getNode($hash) { + if (isset($this->nodes[$hash])) { + return $this->nodes[$hash]; + } else { + return null; + } + } + + public function getNodes() { + return $this->nodes; + } + + public function newQuery() { + $api = $this->getRepositoryAPI(); + return $api->newCommitGraphQuery() + ->setGraph($this); + } + + public function newNode($hash) { + if (isset($this->nodes[$hash])) { + throw new Exception( + pht( + 'Graph already has a node "%s"!', + $hash)); + } + + $this->nodes[$hash] = id(new ArcanistCommitNode()) + ->setCommitHash($hash); + + return $this->nodes[$hash]; + } + + public function newPartitionQuery() { + return id(new ArcanistCommitGraphPartitionQuery()) + ->setGraph($this); + } + +} diff --git a/src/repository/graph/ArcanistCommitGraphPartition.php b/src/repository/graph/ArcanistCommitGraphPartition.php new file mode 100644 index 00000000..b072f6bd --- /dev/null +++ b/src/repository/graph/ArcanistCommitGraphPartition.php @@ -0,0 +1,62 @@ +graph = $graph; + return $this; + } + + public function getGraph() { + return $this->graph; + } + + public function setHashes(array $hashes) { + $this->hashes = $hashes; + return $this; + } + + public function getHashes() { + return $this->hashes; + } + + public function setHeads(array $heads) { + $this->heads = $heads; + return $this; + } + + public function getHeads() { + return $this->heads; + } + + public function setTails($tails) { + $this->tails = $tails; + return $this; + } + + public function getTails() { + return $this->tails; + } + + public function setWaypoints($waypoints) { + $this->waypoints = $waypoints; + return $this; + } + + public function getWaypoints() { + return $this->waypoints; + } + + public function newSetQuery() { + return id(new ArcanistCommitGraphSetQuery()) + ->setPartition($this); + } + +} diff --git a/src/repository/graph/ArcanistCommitGraphPartitionQuery.php b/src/repository/graph/ArcanistCommitGraphPartitionQuery.php new file mode 100644 index 00000000..2a20566a --- /dev/null +++ b/src/repository/graph/ArcanistCommitGraphPartitionQuery.php @@ -0,0 +1,153 @@ +graph = $graph; + return $this; + } + + public function getGraph() { + return $this->graph; + } + + public function withHeads(array $heads) { + $this->heads = $heads; + return $this; + } + + public function withHashes(array $hashes) { + $this->hashes = $hashes; + return $this; + } + + public function execute() { + $graph = $this->getGraph(); + + $heads = $this->heads; + $heads = array_fuse($heads); + if (!$heads) { + throw new Exception(pht('Partition query requires heads.')); + } + + $waypoints = $heads; + + $stack = array(); + $partitions = array(); + $partition_identities = array(); + $n = 0; + foreach ($heads as $hash) { + $node = $graph->getNode($hash); + + if (!$node) { + echo "TODO: WARNING: Bad hash {$hash}\n"; + continue; + } + + $partitions[$hash] = $n; + $partition_identities[$n] = array($n => $n); + $n++; + + $stack[] = $node; + } + + $scope = null; + if ($this->hashes) { + $scope = array_fuse($this->hashes); + } + + $leaves = array(); + while ($stack) { + $node = array_pop($stack); + + $node_hash = $node->getCommitHash(); + $node_partition = $partition_identities[$partitions[$node_hash]]; + + $saw_parent = false; + foreach ($node->getParentNodes() as $parent) { + $parent_hash = $parent->getCommitHash(); + + if ($scope !== null) { + if (!isset($scope[$parent_hash])) { + continue; + } + } + + $saw_parent = true; + + if (isset($partitions[$parent_hash])) { + $parent_partition = $partition_identities[$partitions[$parent_hash]]; + + // If we've reached this node from a child, it clearly is not a + // head. + unset($heads[$parent_hash]); + + // If we've reached a node which is already part of another + // partition, we can stop following it and merge the partitions. + + $new_partition = $node_partition + $parent_partition; + ksort($new_partition); + + if ($node_partition !== $new_partition) { + foreach ($node_partition as $partition_id) { + $partition_identities[$partition_id] = $new_partition; + } + } + + if ($parent_partition !== $new_partition) { + foreach ($parent_partition as $partition_id) { + $partition_identities[$partition_id] = $new_partition; + } + } + continue; + } else { + $partitions[$parent_hash] = $partitions[$node_hash]; + } + + $stack[] = $parent; + } + + if (!$saw_parent) { + $leaves[$node_hash] = true; + } + } + + $partition_lists = array(); + $partition_heads = array(); + $partition_waypoints = array(); + $partition_leaves = array(); + foreach ($partitions as $hash => $partition) { + $partition = reset($partition_identities[$partition]); + $partition_lists[$partition][] = $hash; + if (isset($heads[$hash])) { + $partition_heads[$partition][] = $hash; + } + if (isset($waypoints[$hash])) { + $partition_waypoints[$partition][] = $hash; + } + if (isset($leaves[$hash])) { + $partition_leaves[$partition][] = $hash; + } + } + + $results = array(); + foreach ($partition_lists as $partition_id => $partition_list) { + $partition_set = array_fuse($partition_list); + + $results[] = id(new ArcanistCommitGraphPartition()) + ->setGraph($graph) + ->setHashes($partition_set) + ->setHeads($partition_heads[$partition_id]) + ->setWaypoints($partition_waypoints[$partition_id]) + ->setTails($partition_leaves[$partition_id]); + } + + return $results; + } + +} diff --git a/src/repository/graph/ArcanistCommitGraphSet.php b/src/repository/graph/ArcanistCommitGraphSet.php new file mode 100644 index 00000000..f8ce61b1 --- /dev/null +++ b/src/repository/graph/ArcanistCommitGraphSet.php @@ -0,0 +1,97 @@ +color = $color; + return $this; + } + + public function getColor() { + return $this->color; + } + + public function setHashes($hashes) { + $this->hashes = $hashes; + return $this; + } + + public function getHashes() { + return $this->hashes; + } + + public function setSetID($set_id) { + $this->setID = $set_id; + return $this; + } + + public function getSetID() { + return $this->setID; + } + + public function setParentHashes($parent_hashes) { + $this->parentHashes = $parent_hashes; + return $this; + } + + public function getParentHashes() { + return $this->parentHashes; + } + + public function setChildHashes($child_hashes) { + $this->childHashes = $child_hashes; + return $this; + } + + public function getChildHashes() { + return $this->childHashes; + } + + public function setParentSets($parent_sets) { + $this->parentSets = $parent_sets; + return $this; + } + + public function getParentSets() { + return $this->parentSets; + } + + public function setChildSets($child_sets) { + $this->childSets = $child_sets; + return $this; + } + + public function getChildSets() { + return $this->childSets; + } + + public function setDisplayDepth($display_depth) { + $this->displayDepth = $display_depth; + return $this; + } + + public function getDisplayDepth() { + return $this->displayDepth; + } + + public function setDisplayChildSets(array $display_child_sets) { + $this->displayChildSets = $display_child_sets; + return $this; + } + + public function getDisplayChildSets() { + return $this->displayChildSets; + } + +} diff --git a/src/repository/graph/ArcanistCommitGraphSetQuery.php b/src/repository/graph/ArcanistCommitGraphSetQuery.php new file mode 100644 index 00000000..2b5df45f --- /dev/null +++ b/src/repository/graph/ArcanistCommitGraphSetQuery.php @@ -0,0 +1,305 @@ +partition = $partition; + return $this; + } + + public function getPartition() { + return $this->partition; + } + + public function setWaypointMap(array $waypoint_map) { + $this->waypointMap = $waypoint_map; + return $this; + } + + public function getWaypointMap() { + return $this->waypointMap; + } + + public function execute() { + $partition = $this->getPartition(); + $graph = $partition->getGraph(); + + $waypoint_color = array(); + $color = array(); + + $waypoints = $this->getWaypointMap(); + foreach ($waypoints as $waypoint => $colors) { + // TODO: Validate that "$waypoint" is in the partition. + // TODO: Validate that "$colors" is a list of scalars. + $waypoint_color[$waypoint] = $this->newColorFromRaw($colors); + } + + $stack = array(); + + $hashes = $partition->getTails(); + foreach ($hashes as $hash) { + $stack[] = $graph->getNode($hash); + + if (isset($waypoint_color[$hash])) { + $color[$hash] = $waypoint_color[$hash]; + } else { + $color[$hash] = true; + } + } + + $partition_map = $partition->getHashes(); + + $wait = array(); + foreach ($partition_map as $hash) { + $node = $graph->getNode($hash); + + $incoming = $node->getParentNodes(); + if (count($incoming) < 2) { + // If the node has one or fewer incoming edges, we can paint it as soon + // as we reach it. + continue; + } + + // Discard incoming edges which aren't in the partition. + $need = array(); + foreach ($incoming as $incoming_node) { + $incoming_hash = $incoming_node->getCommitHash(); + + if (!isset($partition_map[$incoming_hash])) { + continue; + } + + $need[] = $incoming_hash; + } + + $need_count = count($need); + if ($need_count < 2) { + // If we have one or fewer incoming edges in the partition, we can + // paint as soon as we reach the node. + continue; + } + + $wait[$hash] = $need_count; + } + + while ($stack) { + $node = array_pop($stack); + $node_hash = $node->getCommitHash(); + + $node_color = $color[$node_hash]; + + $outgoing_nodes = $node->getChildNodes(); + + foreach ($outgoing_nodes as $outgoing_node) { + $outgoing_hash = $outgoing_node->getCommitHash(); + + if (isset($waypoint_color[$outgoing_hash])) { + $color[$outgoing_hash] = $waypoint_color[$outgoing_hash]; + } else if (isset($color[$outgoing_hash])) { + $color[$outgoing_hash] = $this->newColorFromColors( + $color[$outgoing_hash], + $node_color); + } else { + $color[$outgoing_hash] = $node_color; + } + + if (isset($wait[$outgoing_hash])) { + $wait[$outgoing_hash]--; + if ($wait[$outgoing_hash]) { + continue; + } + unset($wait[$outgoing_hash]); + } + + $stack[] = $outgoing_node; + } + } + + if ($wait) { + throw new Exception( + pht( + 'Did not reach every wait node??')); + } + + // Now, we've colored the entire graph. Collect contiguous pieces of it + // with the same color into sets. + + static $set_n = 1; + + $seen = array(); + $sets = array(); + foreach ($color as $hash => $node_color) { + if (isset($seen[$hash])) { + continue; + } + + $seen[$hash] = true; + + $in_set = array(); + $in_set[$hash] = true; + + $stack = array(); + $stack[] = $graph->getNode($hash); + + while ($stack) { + $node = array_pop($stack); + $node_hash = $node->getCommitHash(); + + $nearby = array(); + foreach ($node->getParentNodes() as $nearby_node) { + $nearby[] = $nearby_node; + } + foreach ($node->getChildNodes() as $nearby_node) { + $nearby[] = $nearby_node; + } + + foreach ($nearby as $nearby_node) { + $nearby_hash = $nearby_node->getCommitHash(); + + if (isset($seen[$nearby_hash])) { + continue; + } + + if (idx($color, $nearby_hash) !== $node_color) { + continue; + } + + $seen[$nearby_hash] = true; + $in_set[$nearby_hash] = true; + $stack[] = $nearby_node; + } + } + + $set = id(new ArcanistCommitGraphSet()) + ->setSetID($set_n++) + ->setColor($node_color) + ->setHashes(array_keys($in_set)); + + $sets[] = $set; + } + + $set_map = array(); + foreach ($sets as $set) { + foreach ($set->getHashes() as $hash) { + $set_map[$hash] = $set; + } + } + + foreach ($sets as $set) { + $parents = array(); + $children = array(); + + foreach ($set->getHashes() as $hash) { + $node = $graph->getNode($hash); + + foreach ($node->getParentNodes() as $edge => $ignored) { + if (isset($set_map[$edge])) { + if ($set_map[$edge] === $set) { + continue; + } + } + + $parents[$edge] = true; + } + + foreach ($node->getChildNodes() as $edge => $ignored) { + if (isset($set_map[$edge])) { + if ($set_map[$edge] === $set) { + continue; + } + } + + $children[$edge] = true; + } + + $parent_sets = array(); + foreach ($parents as $edge => $ignored) { + if (!isset($set_map[$edge])) { + continue; + } + + $adjacent_set = $set_map[$edge]; + $parent_sets[$adjacent_set->getSetID()] = $adjacent_set; + } + + $child_sets = array(); + foreach ($children as $edge => $ignored) { + if (!isset($set_map[$edge])) { + continue; + } + + $adjacent_set = $set_map[$edge]; + $child_sets[$adjacent_set->getSetID()] = $adjacent_set; + } + } + + $set + ->setParentHashes(array_keys($parents)) + ->setChildHashes(array_keys($children)) + ->setParentSets($parent_sets) + ->setChildSets($child_sets); + } + + $this->buildDisplayLayout($sets); + + return $sets; + } + + private function newColorFromRaw($color) { + return array_fuse($color); + } + + private function newColorFromColors($u, $v) { + if ($u === true) { + return $v; + } + + if ($v === true) { + return $u; + } + + return $u + $v; + } + + private function buildDisplayLayout(array $sets) { + $this->visitedDisplaySets = array(); + foreach ($sets as $set) { + if (!$set->getParentSets()) { + $this->visitDisplaySet($set); + } + } + } + + private function visitDisplaySet(ArcanistCommitGraphSet $set) { + // If at least one parent has not been visited yet, don't visit this + // set. We want to put the set at the deepest depth it is reachable + // from. + foreach ($set->getParentSets() as $parent_id => $parent_set) { + if (!isset($this->visitedDisplaySets[$parent_id])) { + return false; + } + } + + $set_id = $set->getSetID(); + $this->visitedDisplaySets[$set_id] = true; + + $display_children = array(); + foreach ($set->getChildSets() as $child_id => $child_set) { + $visited = $this->visitDisplaySet($child_set); + if ($visited) { + $display_children[$child_id] = $child_set; + } + } + + $set->setDisplayChildSets($display_children); + + return true; + } + + +} diff --git a/src/repository/graph/ArcanistCommitNode.php b/src/repository/graph/ArcanistCommitNode.php new file mode 100644 index 00000000..318ca43a --- /dev/null +++ b/src/repository/graph/ArcanistCommitNode.php @@ -0,0 +1,78 @@ +commitHash = $commit_hash; + return $this; + } + + public function getCommitHash() { + return $this->commitHash; + } + + public function addChildNode(ArcanistCommitNode $node) { + $this->childNodes[$node->getCommitHash()] = $node; + return $this; + } + + public function setChildNodes(array $nodes) { + $this->childNodes = $nodes; + return $this; + } + + public function getChildNodes() { + return $this->childNodes; + } + + public function addParentNode(ArcanistCommitNode $node) { + $this->parentNodes[$node->getCommitHash()] = $node; + return $this; + } + + public function setParentNodes(array $nodes) { + $this->parentNodes = $nodes; + return $this; + } + + public function getParentNodes() { + return $this->parentNodes; + } + + public function setCommitMessage($commit_message) { + $this->commitMessage = $commit_message; + return $this; + } + + public function getCommitMessage() { + return $this->commitMessage; + } + + public function getCommitRef() { + if ($this->commitRef === null) { + $this->commitRef = id(new ArcanistCommitRef()) + ->setCommitHash($this->getCommitHash()) + ->attachMessage($this->getCommitMessage()); + } + + return $this->commitRef; + } + + public function setCommitEpoch($commit_epoch) { + $this->commitEpoch = $commit_epoch; + return $this; + } + + public function getCommitEpoch() { + return $this->commitEpoch; + } + +} diff --git a/src/repository/graph/__tests__/ArcanistCommitGraphTestCase.php b/src/repository/graph/__tests__/ArcanistCommitGraphTestCase.php new file mode 100644 index 00000000..cb50777c --- /dev/null +++ b/src/repository/graph/__tests__/ArcanistCommitGraphTestCase.php @@ -0,0 +1,56 @@ +assertPartitionCount( + 1, + pht('Simple Graph'), + array('D'), + 'A>B B>C C>D'); + + $this->assertPartitionCount( + 1, + pht('Multiple Heads'), + array('D', 'E'), + 'A>B B>C C>D C>E'); + + $this->assertPartitionCount( + 1, + pht('Disjoint Graph, One Head'), + array('B'), + 'A>B C>D'); + + $this->assertPartitionCount( + 2, + pht('Disjoint Graph, Two Heads'), + array('B', 'D'), + 'A>B C>D'); + + $this->assertPartitionCount( + 1, + pht('Complex Graph'), + array('A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K'), + 'A>B B>C B>D B>E E>F E>G E>H C>H A>I C>I B>J J>K I>K'); + } + + private function assertPartitionCount($expect, $name, $heads, $corpus) { + $graph = new ArcanistCommitGraph(); + + $query = id(new ArcanistSimpleCommitGraphQuery()) + ->setGraph($graph); + + $query->setCorpus($corpus)->execute(); + + $partitions = $graph->newPartitionQuery() + ->withHeads($heads) + ->execute(); + + $this->assertEqual( + $expect, + count($partitions), + pht('Partition Count for "%s"', $name)); + } + +} diff --git a/src/repository/graph/query/ArcanistCommitGraphQuery.php b/src/repository/graph/query/ArcanistCommitGraphQuery.php new file mode 100644 index 00000000..6369c4b5 --- /dev/null +++ b/src/repository/graph/query/ArcanistCommitGraphQuery.php @@ -0,0 +1,79 @@ +graph = $graph; + return $this; + } + + final public function getGraph() { + return $this->graph; + } + + final public function withHeadHashes(array $hashes) { + $this->headHashes = $hashes; + return $this; + } + + final protected function getHeadHashes() { + return $this->headHashes; + } + + final public function withTailHashes(array $hashes) { + $this->tailHashes = $hashes; + return $this; + } + + final protected function getTailHashes() { + return $this->tailHashes; + } + + final public function withExactHashes(array $hashes) { + $this->exactHashes = $hashes; + return $this; + } + + final protected function getExactHashes() { + return $this->exactHashes; + } + + final public function setLimit($limit) { + $this->limit = $limit; + return $this; + } + + final protected function getLimit() { + return $this->limit; + } + + final public function withEpochRange($min, $max) { + $this->minimumEpoch = $min; + $this->maximumEpoch = $max; + return $this; + } + + final public function getMinimumEpoch() { + return $this->minimumEpoch; + } + + final public function getMaximumEpoch() { + return $this->maximumEpoch; + } + + final public function getRepositoryAPI() { + return $this->getGraph()->getRepositoryAPI(); + } + + abstract public function execute(); + +} diff --git a/src/repository/graph/query/ArcanistGitCommitGraphQuery.php b/src/repository/graph/query/ArcanistGitCommitGraphQuery.php new file mode 100644 index 00000000..2491a549 --- /dev/null +++ b/src/repository/graph/query/ArcanistGitCommitGraphQuery.php @@ -0,0 +1,209 @@ +newFutures(); + + $this->executeIterators(); + + return $this->seen; + } + + private function newFutures() { + $head_hashes = $this->getHeadHashes(); + $exact_hashes = $this->getExactHashes(); + + if (!$head_hashes && !$exact_hashes) { + throw new Exception(pht('Need head hashes or exact hashes!')); + } + + $api = $this->getRepositoryAPI(); + $ref_lists = array(); + + if ($head_hashes) { + $refs = array(); + if ($head_hashes !== null) { + foreach ($head_hashes as $hash) { + $refs[] = $hash; + } + } + + $tail_hashes = $this->getTailHashes(); + if ($tail_hashes !== null) { + foreach ($tail_hashes as $tail_hash) { + $refs[] = sprintf('^%s^@', $tail_hash); + } + } + + $ref_lists[] = $refs; + } + + if ($exact_hashes !== null) { + foreach ($exact_hashes as $exact_hash) { + $ref_list = array(); + $ref_list[] = $exact_hash; + $ref_list[] = sprintf('^%s^@', $exact_hash); + $ref_list[] = '--'; + $ref_lists[] = $ref_list; + } + } + + $flags = array(); + + $min_epoch = $this->getMinimumEpoch(); + if ($min_epoch !== null) { + $flags[] = '--after'; + $flags[] = date('c', $min_epoch); + } + + $max_epoch = $this->getMaximumEpoch(); + if ($max_epoch !== null) { + $flags[] = '--before'; + $flags[] = date('c', $max_epoch); + } + + foreach ($ref_lists as $ref_list) { + $ref_blob = implode("\n", $ref_list)."\n"; + + $fields = array( + '%e', + '%H', + '%P', + '%ct', + '%B', + ); + + $format = implode('%x02', $fields).'%x01'; + + $future = $api->newFuture( + 'log --format=%s %Ls --stdin', + $format, + $flags); + $future->write($ref_blob); + $future->setResolveOnError(true); + + $this->futures[] = $future; + } + } + + private function executeIterators() { + while ($this->futures || $this->iterators) { + $iterator_limit = 8; + + while (count($this->iterators) < $iterator_limit) { + if (!$this->futures) { + break; + } + + $future = array_pop($this->futures); + $future->startFuture(); + + $iterator = id(new LinesOfALargeExecFuture($future)) + ->setDelimiter("\1"); + $iterator->rewind(); + + $iterator_key = $this->getNextIteratorKey(); + $this->iterators[$iterator_key] = $iterator; + } + + $limit = $this->getLimit(); + + foreach ($this->iterators as $iterator_key => $iterator) { + $this->executeIterator($iterator_key, $iterator); + + if ($limit) { + if (count($this->seen) >= $limit) { + return; + } + } + } + } + } + + private function getNextIteratorKey() { + return $this->iteratorKey++; + } + + private function executeIterator($iterator_key, $lines) { + $graph = $this->getGraph(); + $limit = $this->getLimit(); + + $is_done = false; + + while (true) { + if (!$lines->valid()) { + $is_done = true; + break; + } + + $line = $lines->current(); + $lines->next(); + + if ($line === "\n") { + continue; + } + + $fields = explode("\2", $line); + + if (count($fields) !== 5) { + throw new Exception( + pht( + 'Failed to split line "%s" from "git log".', + $line)); + } + + list($encoding, $hash, $parents, $commit_epoch, $message) = $fields; + + // TODO: Handle encoding, see DiffusionLowLevelCommitQuery. + + $node = $graph->getNode($hash); + if (!$node) { + $node = $graph->newNode($hash); + } + + $this->seen[$hash] = $node; + + $node + ->setCommitMessage($message) + ->setCommitEpoch((int)$commit_epoch); + + if (strlen($parents)) { + $parents = explode(' ', $parents); + + $parent_nodes = array(); + foreach ($parents as $parent) { + $parent_node = $graph->getNode($parent); + if (!$parent_node) { + $parent_node = $graph->newNode($parent); + } + + $parent_nodes[$parent] = $parent_node; + $parent_node->addChildNode($node); + + } + $node->setParentNodes($parent_nodes); + } else { + $parents = array(); + } + + if ($limit) { + if (count($this->seen) >= $limit) { + break; + } + } + } + + if ($is_done) { + unset($this->iterators[$iterator_key]); + } + } + +} diff --git a/src/repository/graph/query/ArcanistMercurialCommitGraphQuery.php b/src/repository/graph/query/ArcanistMercurialCommitGraphQuery.php new file mode 100644 index 00000000..4a87f162 --- /dev/null +++ b/src/repository/graph/query/ArcanistMercurialCommitGraphQuery.php @@ -0,0 +1,216 @@ +beginExecute(); + $this->continueExecute(); + + return $this->seen; + } + + protected function beginExecute() { + $head_hashes = $this->getHeadHashes(); + $exact_hashes = $this->getExactHashes(); + + if (!$head_hashes && !$exact_hashes) { + throw new Exception(pht('Need head hashes or exact hashes!')); + } + + $api = $this->getRepositoryAPI(); + + $revsets = array(); + if ($head_hashes !== null) { + $revs = array(); + foreach ($head_hashes as $hash) { + $revs[] = hgsprintf( + 'ancestors(%s)', + $hash); + } + $revsets[] = $this->joinOrRevsets($revs); + } + + $tail_hashes = $this->getTailHashes(); + if ($tail_hashes !== null) { + $revs = array(); + foreach ($tail_hashes as $tail_hash) { + $revs[] = hgsprintf( + 'descendants(%s)', + $tail_hash); + } + $revsets[] = $this->joinOrRevsets($revs); + } + + if ($revsets) { + $revsets = array( + $this->joinAndRevsets($revsets), + ); + } + + if ($exact_hashes !== null) { + $revs = array(); + foreach ($exact_hashes as $exact_hash) { + $revs[] = hgsprintf( + '%s', + $exact_hash); + } + $revsets[] = $this->joinOrRevsets($revs); + } + + $revsets = $this->joinOrRevsets($revsets); + + $fields = array( + '', // Placeholder for "encoding". + '{node}', + '{p1node} {p2node}', + '{date|rfc822date}', + '{desc|utf8}', + ); + + $template = implode("\2", $fields)."\1"; + + $flags = array(); + + $min_epoch = $this->getMinimumEpoch(); + $max_epoch = $this->getMaximumEpoch(); + if ($min_epoch !== null || $max_epoch !== null) { + $flags[] = '--date'; + + if ($min_epoch !== null) { + $min_epoch = date('c', $min_epoch); + } + + if ($max_epoch !== null) { + $max_epoch = date('c', $max_epoch); + } + + if ($min_epoch !== null && $max_epoch !== null) { + $flags[] = sprintf( + '%s to %s', + $min_epoch, + $max_epoch); + } else if ($min_epoch) { + $flags[] = sprintf( + '>%s', + $min_epoch); + } else { + $flags[] = sprintf( + '<%s', + $max_epoch); + } + } + + $future = $api->newFuture( + 'log --rev %s --template %s %Ls --', + $revsets, + $template, + $flags); + $future->setResolveOnError(true); + $future->start(); + + $lines = id(new LinesOfALargeExecFuture($future)) + ->setDelimiter("\1"); + $lines->rewind(); + + $this->queryFuture = $lines; + } + + protected function continueExecute() { + $graph = $this->getGraph(); + $lines = $this->queryFuture; + $limit = $this->getLimit(); + + $no_parent = str_repeat('0', 40); + + while (true) { + if (!$lines->valid()) { + return false; + } + + $line = $lines->current(); + $lines->next(); + + if ($line === "\n") { + continue; + } + + $fields = explode("\2", $line); + + if (count($fields) !== 5) { + throw new Exception( + pht( + 'Failed to split line "%s" from "git log".', + $line)); + } + + list($encoding, $hash, $parents, $commit_epoch, $message) = $fields; + + $node = $graph->getNode($hash); + if (!$node) { + $node = $graph->newNode($hash); + } + + $this->seen[$hash] = $node; + + $node + ->setCommitMessage($message) + ->setCommitEpoch((int)strtotime($commit_epoch)); + + if (strlen($parents)) { + $parents = explode(' ', $parents); + $parent_nodes = array(); + foreach ($parents as $parent) { + if ($parent === $no_parent) { + continue; + } + + $parent_node = $graph->getNode($parent); + if (!$parent_node) { + $parent_node = $graph->newNode($parent); + } + + $parent_nodes[$parent] = $parent_node; + $parent_node->addChildNode($node); + } + $node->setParentNodes($parent_nodes); + } else { + $parents = array(); + } + + if ($limit) { + if (count($this->seen) >= $limit) { + break; + } + } + } + } + + private function joinOrRevsets(array $revsets) { + return $this->joinRevsets($revsets, false); + } + + private function joinAndRevsets(array $revsets) { + return $this->joinRevsets($revsets, true); + } + + private function joinRevsets(array $revsets, $is_and) { + if (!$revsets) { + return array(); + } + + if (count($revsets) === 1) { + return head($revsets); + } + + if ($is_and) { + return '('.implode(' and ', $revsets).')'; + } else { + return '('.implode(' or ', $revsets).')'; + } + } + +} diff --git a/src/repository/graph/query/ArcanistSimpleCommitGraphQuery.php b/src/repository/graph/query/ArcanistSimpleCommitGraphQuery.php new file mode 100644 index 00000000..1931486f --- /dev/null +++ b/src/repository/graph/query/ArcanistSimpleCommitGraphQuery.php @@ -0,0 +1,50 @@ +corpus = $corpus; + return $this; + } + + public function getCorpus() { + return $this->corpus; + } + + public function execute() { + $graph = $this->getGraph(); + $corpus = $this->getCorpus(); + + $edges = preg_split('(\s+)', trim($corpus)); + foreach ($edges as $edge) { + $matches = null; + $ok = preg_match('(^(?P\S+)>(?P\S+)\z)', $edge, $matches); + if (!$ok) { + throw new Exception( + pht( + 'Failed to match SimpleCommitGraph directive "%s".', + $edge)); + } + + $parent = $matches['parent']; + $child = $matches['child']; + + $pnode = $graph->getNode($parent); + if (!$pnode) { + $pnode = $graph->newNode($parent); + } + + $cnode = $graph->getNode($child); + if (!$cnode) { + $cnode = $graph->newNode($child); + } + + $cnode->addParentNode($pnode); + $pnode->addChildNode($cnode); + } + } + +} diff --git a/src/repository/graph/view/ArcanistCommitGraphSetTreeView.php b/src/repository/graph/view/ArcanistCommitGraphSetTreeView.php new file mode 100644 index 00000000..a67db3e2 --- /dev/null +++ b/src/repository/graph/view/ArcanistCommitGraphSetTreeView.php @@ -0,0 +1,246 @@ +rootSet = $root_set; + return $this; + } + + public function getRootSet() { + return $this->rootSet; + } + + public function setMarkers($markers) { + $this->markers = $markers; + $this->markerGroups = mgroup($markers, 'getCommitHash'); + return $this; + } + + public function getMarkers() { + return $this->markers; + } + + public function setStateRefs($state_refs) { + $this->stateRefs = $state_refs; + return $this; + } + + public function getStateRefs() { + return $this->stateRefs; + } + + public function setRepositoryAPI($repository_api) { + $this->repositoryAPI = $repository_api; + return $this; + } + + public function getRepositoryAPI() { + return $this->repositoryAPI; + } + + public function draw() { + $set = $this->getRootSet(); + + $this->setViews = array(); + $view_root = $this->newSetViews($set); + $view_list = $this->setViews; + + $api = $this->getRepositoryAPI(); + + foreach ($view_list as $view) { + $view_set = $view->getSet(); + $hashes = $view_set->getHashes(); + + $commit_refs = $this->getCommitRefs($hashes); + $revision_refs = $this->getRevisionRefs(head($hashes)); + $marker_refs = $this->getMarkerRefs($hashes); + + $view + ->setRepositoryAPI($api) + ->setCommitRefs($commit_refs) + ->setRevisionRefs($revision_refs) + ->setMarkerRefs($marker_refs); + } + + $view_list = $this->collapseViews($view_root, $view_list); + + $rows = array(); + foreach ($view_list as $view) { + $rows[] = $view->newCellViews(); + } + + return $rows; + } + + private function newSetViews(ArcanistCommitGraphSet $set) { + $set_view = $this->newSetView($set); + + $this->setViews[] = $set_view; + + foreach ($set->getDisplayChildSets() as $child_set) { + $child_view = $this->newSetViews($child_set); + $child_view->setParentView($set_view); + $set_view->addChildView($child_view); + } + + return $set_view; + } + + private function newSetView(ArcanistCommitGraphSet $set) { + return id(new ArcanistCommitGraphSetView()) + ->setSet($set); + } + + private function getStateRef($hash) { + $state_refs = $this->getStateRefs(); + + if (!isset($state_refs[$hash])) { + throw new Exception( + pht( + 'Found no state ref for hash "%s".', + $hash)); + } + + return $state_refs[$hash]; + } + + private function getRevisionRefs($hash) { + $state_ref = $this->getStateRef($hash); + return $state_ref->getRevisionRefs(); + } + + private function getCommitRefs(array $hashes) { + $results = array(); + foreach ($hashes as $hash) { + $state_ref = $this->getStateRef($hash); + $results[$hash] = $state_ref->getCommitRef(); + } + + return $results; + } + + private function getMarkerRefs(array $hashes) { + $results = array(); + foreach ($hashes as $hash) { + $results[$hash] = idx($this->markerGroups, $hash, array()); + } + return $results; + } + + private function collapseViews($view_root, array $view_list) { + $this->groupViews($view_root); + + foreach ($view_list as $view) { + $group = $view->getGroupView(); + $group->addMemberView($view); + } + + foreach ($view_list as $view) { + $member_views = $view->getMemberViews(); + + // Break small groups apart. + $count = count($member_views); + if ($count > 1 && $count < 4) { + foreach ($member_views as $member_view) { + $member_view->setGroupView($member_view); + $member_view->setMemberViews(array($member_view)); + } + } + } + + foreach ($view_list as $view) { + $parent_view = $view->getParentView(); + if (!$parent_view) { + $depth = 0; + } else { + $parent_group = $parent_view->getGroupView(); + + $member_views = $parent_group->getMemberViews(); + if (count($member_views) > 1) { + $depth = $parent_group->getViewDepth() + 2; + } else { + $depth = $parent_group->getViewDepth() + 1; + } + } + + $view->setViewDepth($depth); + } + + foreach ($view_list as $key => $view) { + if (!$view->getMemberViews()) { + unset($view_list[$key]); + } + } + + return $view_list; + } + + private function groupViews($view) { + $group_view = $this->getGroupForView($view); + $view->setGroupView($group_view); + + + + $children = $view->getChildViews(); + foreach ($children as $child) { + $this->groupViews($child); + } + } + + private function getGroupForView($view) { + $revision_refs = $view->getRevisionRefs(); + if ($revision_refs) { + $has_unpublished_revision = false; + + foreach ($revision_refs as $revision_ref) { + if (!$revision_ref->isStatusPublished()) { + $has_unpublished_revision = true; + break; + } + } + + if ($has_unpublished_revision) { + return $view; + } + } + + $marker_lists = $view->getMarkerRefs(); + foreach ($marker_lists as $marker_refs) { + if ($marker_refs) { + return $view; + } + } + + // If a view has no children, it is never grouped with other views. + $children = $view->getChildViews(); + if (!$children) { + return $view; + } + + // If a view is a root, we can't group it. + $parent = $view->getParentView(); + if (!$parent) { + return $view; + } + + // If a view has siblings, we can't group it with other views. + $siblings = $parent->getChildViews(); + if (count($siblings) !== 1) { + return $view; + } + + // The view has no children and no other siblings, so add it to the + // parent's group. + return $parent->getGroupView(); + } + +} diff --git a/src/repository/graph/view/ArcanistCommitGraphSetView.php b/src/repository/graph/view/ArcanistCommitGraphSetView.php new file mode 100644 index 00000000..46e65595 --- /dev/null +++ b/src/repository/graph/view/ArcanistCommitGraphSetView.php @@ -0,0 +1,568 @@ +repositoryAPI = $repository_api; + return $this; + } + + public function getRepositoryAPI() { + return $this->repositoryAPI; + } + + public function setSet(ArcanistCommitGraphSet $set) { + $this->set = $set; + return $this; + } + + public function getSet() { + return $this->set; + } + + public function setParentView(ArcanistCommitGraphSetView $parent_view) { + $this->parentView = $parent_view; + return $this; + } + + public function getParentView() { + return $this->parentView; + } + + public function setGroupView(ArcanistCommitGraphSetView $group_view) { + $this->groupView = $group_view; + return $this; + } + + public function getGroupView() { + return $this->groupView; + } + + public function addMemberView(ArcanistCommitGraphSetView $member_view) { + $this->memberViews[] = $member_view; + return $this; + } + + public function getMemberViews() { + return $this->memberViews; + } + + public function setMemberViews(array $member_views) { + $this->memberViews = $member_views; + return $this; + } + + public function addChildView(ArcanistCommitGraphSetView $child_view) { + $this->childViews[] = $child_view; + return $this; + } + + public function setChildViews(array $child_views) { + assert_instances_of($child_views, __CLASS__); + $this->childViews = $child_views; + return $this; + } + + public function getChildViews() { + return $this->childViews; + } + + public function setCommitRefs($commit_refs) { + $this->commitRefs = $commit_refs; + return $this; + } + + public function getCommitRefs() { + return $this->commitRefs; + } + + public function setRevisionRefs($revision_refs) { + $this->revisionRefs = $revision_refs; + return $this; + } + + public function getRevisionRefs() { + return $this->revisionRefs; + } + + public function setMarkerRefs($marker_refs) { + $this->markerRefs = $marker_refs; + return $this; + } + + public function getMarkerRefs() { + return $this->markerRefs; + } + + public function setViewDepth($view_depth) { + $this->viewDepth = $view_depth; + return $this; + } + + public function getViewDepth() { + return $this->viewDepth; + } + + public function newCellViews() { + $set = $this->getSet(); + $api = $this->getRepositoryAPI(); + + $commit_refs = $this->getCommitRefs(); + $revision_refs = $this->getRevisionRefs(); + $marker_refs = $this->getMarkerRefs(); + + $merge_strings = array(); + foreach ($revision_refs as $revision_ref) { + $summary = $revision_ref->getName(); + $merge_key = substr($summary, 0, 32); + $merge_key = phutil_utf8_strtolower($merge_key); + + $merge_strings[$merge_key][] = $revision_ref; + } + + $merge_map = array(); + foreach ($commit_refs as $commit_ref) { + $summary = $commit_ref->getSummary(); + + $merge_with = null; + if (count($revision_refs) === 1) { + $merge_with = head($revision_refs); + } else { + $merge_key = substr($summary, 0, 32); + $merge_key = phutil_utf8_strtolower($merge_key); + if (isset($merge_strings[$merge_key])) { + $merge_refs = $merge_strings[$merge_key]; + if (count($merge_refs) === 1) { + $merge_with = head($merge_refs); + } + } + } + + if ($merge_with) { + $revision_phid = $merge_with->getPHID(); + $merge_map[$revision_phid][] = $commit_ref; + } + } + + $revision_map = mpull($revision_refs, null, 'getPHID'); + + $result_map = array(); + foreach ($merge_map as $merge_phid => $merge_refs) { + if (count($merge_refs) !== 1) { + continue; + } + + $merge_ref = head($merge_refs); + $commit_hash = $merge_ref->getCommitHash(); + + $result_map[$commit_hash] = $revision_map[$merge_phid]; + } + + $object_layout = array(); + + $merged_map = array_flip(mpull($result_map, 'getPHID')); + foreach ($revision_refs as $revision_ref) { + $revision_phid = $revision_ref->getPHID(); + if (isset($merged_map[$revision_phid])) { + continue; + } + + $object_layout[] = array( + 'revision' => $revision_ref, + ); + } + + foreach ($commit_refs as $commit_ref) { + $commit_hash = $commit_ref->getCommitHash(); + $revision_ref = idx($result_map, $commit_hash); + + $object_layout[] = array( + 'commit' => $commit_ref, + 'revision' => $revision_ref, + ); + } + + $items = array(); + foreach ($object_layout as $layout) { + $commit_ref = idx($layout, 'commit'); + if (!$commit_ref) { + $items[] = $layout; + continue; + } + + $commit_hash = $commit_ref->getCommitHash(); + $markers = idx($marker_refs, $commit_hash); + if (!$markers) { + $items[] = $layout; + continue; + } + + $head_marker = array_shift($markers); + $layout['marker'] = $head_marker; + $items[] = $layout; + + if (!$markers) { + continue; + } + + foreach ($markers as $marker) { + $items[] = array( + 'marker' => $marker, + ); + } + } + + $items = $this->collapseItems($items); + + $marker_view = $this->drawMarkerCell($items); + $commits_view = $this->drawCommitsCell($items); + $status_view = $this->drawStatusCell($items); + $revisions_view = $this->drawRevisionsCell($items); + $messages_view = $this->drawMessagesCell($items); + + return array( + id(new ArcanistGridCell()) + ->setKey('marker') + ->setContent($marker_view), + id(new ArcanistGridCell()) + ->setKey('commits') + ->setContent($commits_view), + id(new ArcanistGridCell()) + ->setKey('status') + ->setContent($status_view), + id(new ArcanistGridCell()) + ->setKey('revisions') + ->setContent($revisions_view), + id(new ArcanistGridCell()) + ->setKey('messages') + ->setContent($messages_view), + ); + } + + private function drawMarkerCell(array $items) { + $api = $this->getRepositoryAPI(); + + $marker_refs = $this->getMarkerRefs(); + $commit_refs = $this->getCommitRefs(); + + if (count($commit_refs) === 1) { + $commit_ref = head($commit_refs); + + $commit_hash = $commit_ref->getCommitHash(); + $commit_hash = tsprintf( + '%s', + substr($commit_hash, 0, 7)); + + $commit_label = $commit_hash; + } else { + $min = head($commit_refs); + $max = last($commit_refs); + $commit_label = tsprintf( + '%s..%s', + substr($min->getCommitHash(), 0, 7), + substr($max->getCommitHash(), 0, 7)); + } + + $member_views = $this->getMemberViews(); + $member_count = count($member_views); + if ($member_count > 1) { + $items[] = array( + 'group' => $member_views, + ); + } + + $terminal_width = phutil_console_get_terminal_width(); + $max_depth = (int)floor(3 + (max(0, $terminal_width - 72) / 6)); + + $depth = $this->getViewDepth(); + + if ($depth <= $max_depth) { + $display_depth = ($depth * 2); + $is_squished = false; + } else { + $display_depth = ($max_depth * 2); + $is_squished = true; + } + + $max_width = ($max_depth * 2) + 16; + $available_width = $max_width - $display_depth; + + $mark_ne = "\xE2\x94\x97"; + $mark_ew = "\xE2\x94\x81"; + $mark_esw = "\xE2\x94\xB3"; + $mark_sw = "\xE2\x94\x93"; + $mark_bullet = "\xE2\x80\xA2"; + $mark_ns_light = "\xE2\x94\x82"; + $mark_ne_light = "\xE2\x94\x94"; + $mark_esw_light = "\xE2\x94\xAF"; + + $has_children = $this->getChildViews(); + + $is_first = true; + $last_key = last_key($items); + $cell = array(); + foreach ($items as $item_key => $item) { + $marker_ref = idx($item, 'marker'); + $group_ref = idx($item, 'group'); + + $is_last = ($item_key === $last_key); + + if ($marker_ref) { + $marker_name = $marker_ref->getName(); + $is_active = $marker_ref->getIsActive(); + + if ($is_active) { + $marker_width = $available_width - 4; + } else { + $marker_width = $available_width; + } + + $marker_name = id(new PhutilUTF8StringTruncator()) + ->setMaximumGlyphs($marker_width) + ->truncateString($marker_name); + + if ($marker_ref->getIsActive()) { + $label = tsprintf( + '**%s** **%s**', + ' * ', + $marker_name); + } else { + $label = tsprintf( + '**%s**', + $marker_name); + } + } else if ($group_ref) { + $label = pht( + '(... %s more revisions ...)', + new PhutilNumber(count($group_ref) - 1)); + } else if ($is_first) { + $label = $commit_label; + } else { + $label = ''; + } + + if ($display_depth > 2) { + $indent = str_repeat(' ', $display_depth - 2); + } else { + $indent = ''; + } + + if ($is_first) { + if ($display_depth === 0) { + $path = $mark_bullet.' '; + } else { + if ($has_children) { + $path = $mark_ne.$mark_ew.$mark_esw.' '; + } else if (!$is_last) { + $path = $mark_ne.$mark_ew.$mark_esw_light.' '; + } else { + $path = $mark_ne.$mark_ew.$mark_ew.' '; + } + } + } else if ($group_ref) { + $path = $mark_ne.'/'.$mark_sw.' '; + } else { + if ($is_last && !$has_children) { + $path = $mark_ne_light.' '; + } else { + $path = $mark_ns_light.' '; + } + if ($display_depth > 0) { + $path = ' '.$path; + } + } + + $indent_text = sprintf( + '%s%s', + $indent, + $path); + + $cell[] = tsprintf( + "%s%s\n", + $indent_text, + $label); + + $is_first = false; + } + + return $cell; + } + + private function drawCommitsCell(array $items) { + $cell = array(); + foreach ($items as $item) { + $count = idx($item, 'collapseCount'); + if ($count) { + $cell[] = tsprintf(" : \n"); + continue; + } + + $commit_ref = idx($item, 'commit'); + if (!$commit_ref) { + $cell[] = tsprintf("\n"); + continue; + } + + $commit_label = $this->drawCommitLabel($commit_ref); + $cell[] = tsprintf("%s\n", $commit_label); + } + + return $cell; + } + + private function drawCommitLabel(ArcanistCommitRef $commit_ref) { + $api = $this->getRepositoryAPI(); + + $hash = $commit_ref->getCommitHash(); + $hash = substr($hash, 0, 7); + + return tsprintf('%s', $hash); + } + + private function drawRevisionsCell(array $items) { + $cell = array(); + + foreach ($items as $item) { + $revision_ref = idx($item, 'revision'); + if (!$revision_ref) { + $cell[] = tsprintf("\n"); + continue; + } + $revision_label = $this->drawRevisionLabel($revision_ref); + $cell[] = tsprintf("%s\n", $revision_label); + } + + return $cell; + } + + private function drawRevisionLabel(ArcanistRevisionRef $revision_ref) { + $api = $this->getRepositoryAPI(); + + $monogram = $revision_ref->getMonogram(); + + return tsprintf('%s', $monogram); + } + + private function drawMessagesCell(array $items) { + $cell = array(); + + foreach ($items as $item) { + $count = idx($item, 'collapseCount'); + if ($count) { + $cell[] = tsprintf( + "%s\n", + pht( + '<... %s more commits ...>', + new PhutilNumber($count))); + continue; + } + + $revision_ref = idx($item, 'revision'); + if ($revision_ref) { + $cell[] = tsprintf("%s\n", $revision_ref->getName()); + continue; + } + + $commit_ref = idx($item, 'commit'); + if ($commit_ref) { + $cell[] = tsprintf("%s\n", $commit_ref->getSummary()); + continue; + } + + $cell[] = tsprintf("\n"); + } + + return $cell; + } + + private function drawStatusCell(array $items) { + $cell = array(); + + foreach ($items as $item) { + $revision_ref = idx($item, 'revision'); + + if (!$revision_ref) { + $cell[] = tsprintf("\n"); + continue; + } + + $revision_label = $this->drawRevisionStatus($revision_ref); + $cell[] = tsprintf("%s\n", $revision_label); + } + + return $cell; + } + + + private function drawRevisionStatus(ArcanistRevisionRef $revision_ref) { + if (phutil_console_get_terminal_width() < 120) { + $status = $revision_ref->getStatusShortDisplayName(); + } else { + $status = $revision_ref->getStatusDisplayName(); + } + + $ansi_color = $revision_ref->getStatusANSIColor(); + if ($ansi_color) { + $status = tsprintf( + sprintf('%%s', $ansi_color), + $status); + } + + return tsprintf('%s', $status); + } + + private function collapseItems(array $items) { + $show_context = 3; + + $map = array(); + foreach ($items as $key => $item) { + $can_collapse = + (isset($item['commit'])) && + (!isset($item['revision'])) && + (!isset($item['marker'])); + $map[$key] = $can_collapse; + } + + $map = phutil_partition($map); + foreach ($map as $partition) { + $value = head($partition); + + if (!$value) { + break; + } + + $count = count($partition); + if ($count < ($show_context * 2) + 3) { + continue; + } + + $partition = array_slice($partition, $show_context, -$show_context, true); + + $is_first = true; + foreach ($partition as $key => $value) { + if ($is_first) { + $items[$key]['collapseCount'] = $count; + } else { + unset($items[$key]); + } + + $is_first = false; + } + } + + return $items; + } + +} diff --git a/src/repository/marker/ArcanistGitRepositoryMarkerQuery.php b/src/repository/marker/ArcanistGitRepositoryMarkerQuery.php new file mode 100644 index 00000000..2c634b66 --- /dev/null +++ b/src/repository/marker/ArcanistGitRepositoryMarkerQuery.php @@ -0,0 +1,195 @@ +getRepositoryAPI(); + + $future = $this->newCurrentBranchNameFuture()->start(); + + $field_list = array( + '%(refname)', + '%(objectname)', + '%(committerdate:raw)', + '%(tree)', + '%(*objectname)', + '%(subject)', + '%(subject)%0a%0a%(body)', + '%02', + ); + $expect_count = count($field_list); + + $branch_prefix = 'refs/heads/'; + $branch_length = strlen($branch_prefix); + + $remote_prefix = 'refs/remotes/'; + $remote_length = strlen($remote_prefix); + + list($stdout) = $api->newFuture( + 'for-each-ref --format %s -- refs/', + implode('%01', $field_list))->resolve(); + + $markers = array(); + + $lines = explode("\2", $stdout); + foreach ($lines as $line) { + $line = trim($line); + if (!strlen($line)) { + continue; + } + + $fields = explode("\1", $line, $expect_count); + $actual_count = count($fields); + if ($actual_count !== $expect_count) { + throw new Exception( + pht( + 'Unexpected field count when parsing line "%s", got %s but '. + 'expected %s.', + $line, + new PhutilNumber($actual_count), + new PhutilNumber($expect_count))); + } + + list($ref, $hash, $epoch, $tree, $dst_hash, $summary, $text) = $fields; + + $remote_name = null; + + if (!strncmp($ref, $branch_prefix, $branch_length)) { + $type = ArcanistMarkerRef::TYPE_BRANCH; + $name = substr($ref, $branch_length); + } else if (!strncmp($ref, $remote_prefix, $remote_length)) { + // This isn't entirely correct: the ref may be a tag, etc. + $type = ArcanistMarkerRef::TYPE_BRANCH; + + $label = substr($ref, $remote_length); + $parts = explode('/', $label, 2); + + $remote_name = $parts[0]; + $name = $parts[1]; + } else { + // For now, discard other refs. + continue; + } + + $marker = id(new ArcanistMarkerRef()) + ->setName($name) + ->setMarkerType($type) + ->setEpoch((int)$epoch) + ->setMarkerHash($hash) + ->setTreeHash($tree) + ->setSummary($summary) + ->setMessage($text); + + if ($remote_name !== null) { + $marker->setRemoteName($remote_name); + } + + if (strlen($dst_hash)) { + $commit_hash = $dst_hash; + } else { + $commit_hash = $hash; + } + + $marker->setCommitHash($commit_hash); + + $commit_ref = $api->newCommitRef() + ->setCommitHash($commit_hash) + ->attachMessage($text); + + $marker->attachCommitRef($commit_ref); + + $markers[] = $marker; + } + + $current = $this->resolveCurrentBranchNameFuture($future); + + if ($current !== null) { + foreach ($markers as $marker) { + if ($marker->getName() === $current) { + $marker->setIsActive(true); + } + } + } + + return $markers; + } + + private function newCurrentBranchNameFuture() { + $api = $this->getRepositoryAPI(); + return $api->newFuture('symbolic-ref --quiet HEAD --') + ->setResolveOnError(true); + } + + private function resolveCurrentBranchNameFuture($future) { + list($err, $stdout) = $future->resolve(); + + if ($err) { + return null; + } + + $matches = null; + if (!preg_match('(^refs/heads/(.*)\z)', trim($stdout), $matches)) { + return null; + } + + return $matches[1]; + } + + protected function newRemoteRefMarkers(ArcanistRemoteRef $remote) { + $api = $this->getRepositoryAPI(); + + // NOTE: Since we only care about branches today, we only list branches. + + $future = $api->newFuture( + 'ls-remote --refs %s %s', + $remote->getRemoteName(), + 'refs/heads/*'); + list($stdout) = $future->resolve(); + + $branch_prefix = 'refs/heads/'; + $branch_length = strlen($branch_prefix); + + $pattern = '(^(?P\S+)\t(?P\S+)\z)'; + $markers = array(); + + $lines = phutil_split_lines($stdout, false); + foreach ($lines as $line) { + $matches = null; + $ok = preg_match($pattern, $line, $matches); + if (!$ok) { + throw new Exception( + pht( + 'Failed to match "ls-remote" pattern against line "%s".', + $line)); + } + + $hash = $matches['hash']; + $ref = $matches['ref']; + + if (!strncmp($ref, $branch_prefix, $branch_length)) { + $type = ArcanistMarkerRef::TYPE_BRANCH; + $name = substr($ref, $branch_length); + } else { + // For now, discard other refs. + continue; + } + + $marker = id(new ArcanistMarkerRef()) + ->setName($name) + ->setMarkerType($type) + ->setMarkerHash($hash) + ->setCommitHash($hash); + + $commit_ref = $api->newCommitRef() + ->setCommitHash($hash); + + $marker->attachCommitRef($commit_ref); + + $markers[] = $marker; + } + + return $markers; + } + +} diff --git a/src/repository/marker/ArcanistMarkerRef.php b/src/repository/marker/ArcanistMarkerRef.php new file mode 100644 index 00000000..c580ab56 --- /dev/null +++ b/src/repository/marker/ArcanistMarkerRef.php @@ -0,0 +1,186 @@ +getMarkerType()) { + case self::TYPE_BRANCH: + return pht('Branch "%s"', $this->getName()); + case self::TYPE_BOOKMARK: + return pht('Bookmark "%s"', $this->getName()); + default: + return pht('Marker "%s"', $this->getName()); + } + } + + protected function newHardpoints() { + return array( + $this->newHardpoint(self::HARDPOINT_COMMITREF), + $this->newHardpoint(self::HARDPOINT_WORKINGCOPYSTATEREF), + $this->newHardpoint(self::HARDPOINT_REMOTEREF), + ); + } + + public function setName($name) { + $this->name = $name; + return $this; + } + + public function getName() { + return $this->name; + } + + public function setMarkerType($marker_type) { + $this->markerType = $marker_type; + return $this; + } + + public function getMarkerType() { + return $this->markerType; + } + + public function setEpoch($epoch) { + $this->epoch = $epoch; + return $this; + } + + public function getEpoch() { + return $this->epoch; + } + + public function setMarkerHash($marker_hash) { + $this->markerHash = $marker_hash; + return $this; + } + + public function getMarkerHash() { + return $this->markerHash; + } + + public function setDisplayHash($display_hash) { + $this->displayHash = $display_hash; + return $this; + } + + public function getDisplayHash() { + return $this->displayHash; + } + + public function setCommitHash($commit_hash) { + $this->commitHash = $commit_hash; + return $this; + } + + public function getCommitHash() { + return $this->commitHash; + } + + public function setTreeHash($tree_hash) { + $this->treeHash = $tree_hash; + return $this; + } + + public function getTreeHash() { + return $this->treeHash; + } + + public function setSummary($summary) { + $this->summary = $summary; + return $this; + } + + public function getSummary() { + return $this->summary; + } + + public function setMessage($message) { + $this->message = $message; + return $this; + } + + public function getMessage() { + return $this->message; + } + + public function setIsActive($is_active) { + $this->isActive = $is_active; + return $this; + } + + public function getIsActive() { + return $this->isActive; + } + + public function setRemoteName($remote_name) { + $this->remoteName = $remote_name; + return $this; + } + + public function getRemoteName() { + return $this->remoteName; + } + + public function isBookmark() { + return ($this->getMarkerType() === self::TYPE_BOOKMARK); + } + + public function isBranch() { + return ($this->getMarkerType() === self::TYPE_BRANCH); + } + + public function attachCommitRef(ArcanistCommitRef $ref) { + return $this->attachHardpoint(self::HARDPOINT_COMMITREF, $ref); + } + + public function getCommitRef() { + return $this->getHardpoint(self::HARDPOINT_COMMITREF); + } + + public function attachWorkingCopyStateRef(ArcanistWorkingCopyStateRef $ref) { + return $this->attachHardpoint(self::HARDPOINT_WORKINGCOPYSTATEREF, $ref); + } + + public function getWorkingCopyStateRef() { + return $this->getHardpoint(self::HARDPOINT_WORKINGCOPYSTATEREF); + } + + public function attachRemoteRef(ArcanistRemoteRef $ref = null) { + return $this->attachHardpoint(self::HARDPOINT_REMOTEREF, $ref); + } + + public function getRemoteRef() { + return $this->getHardpoint(self::HARDPOINT_REMOTEREF); + } + + protected function buildRefView(ArcanistRefView $view) { + $title = pht( + '%s %s', + $this->getDisplayHash(), + $this->getSummary()); + + $view + ->setObjectName($this->getRefDisplayName()) + ->setTitle($title); + } + +} diff --git a/src/repository/marker/ArcanistMercurialRepositoryMarkerQuery.php b/src/repository/marker/ArcanistMercurialRepositoryMarkerQuery.php new file mode 100644 index 00000000..0cca7d76 --- /dev/null +++ b/src/repository/marker/ArcanistMercurialRepositoryMarkerQuery.php @@ -0,0 +1,128 @@ +newMarkers(); + } + + protected function newRemoteRefMarkers(ArcanistRemoteRef $remote = null) { + return $this->newMarkers($remote); + } + + private function newMarkers(ArcanistRemoteRef $remote = null) { + $api = $this->getRepositoryAPI(); + + // In native Mercurial it is difficult to identify remote markers, and + // complicated to identify local markers efficiently. We use an extension + // to provide a command which works like "git for-each-ref" locally and + // "git ls-remote" when given a remote. + + $argv = array(); + foreach ($api->getMercurialExtensionArguments() as $arg) { + $argv[] = $arg; + } + $argv[] = 'arc-ls-markers'; + + // NOTE: In remote mode, we're using passthru and a tempfile on this + // because it's a remote command and may prompt the user to provide + // credentials interactively. In local mode, we can just read stdout. + + if ($remote !== null) { + $tmpfile = new TempFile(); + Filesystem::remove($tmpfile); + + $argv[] = '--output'; + $argv[] = phutil_string_cast($tmpfile); + } + + $argv[] = '--'; + + if ($remote !== null) { + $argv[] = $remote->getRemoteName(); + } + + if ($remote !== null) { + $passthru = $api->newPassthru('%Ls', $argv); + + $err = $passthru->execute(); + if ($err) { + throw new Exception( + pht( + 'Call to "hg arc-ls-markers" failed with error "%s".', + $err)); + } + + $raw_data = Filesystem::readFile($tmpfile); + unset($tmpfile); + } else { + $future = $api->newFuture('%Ls', $argv); + list($raw_data) = $future->resolve(); + } + + $items = phutil_json_decode($raw_data); + + $markers = array(); + foreach ($items as $item) { + if (!empty($item['isClosed'])) { + // NOTE: For now, we ignore closed branch heads. + continue; + } + + $node = $item['node']; + if (!$node) { + // NOTE: For now, we ignore the virtual "current branch" marker. + continue; + } + + switch ($item['type']) { + case 'branch': + $marker_type = ArcanistMarkerRef::TYPE_BRANCH; + break; + case 'bookmark': + $marker_type = ArcanistMarkerRef::TYPE_BOOKMARK; + break; + case 'commit': + $marker_type = null; + break; + default: + throw new Exception( + pht( + 'Call to "hg arc-ls-markers" returned marker of unknown '. + 'type "%s".', + $item['type'])); + } + + if ($marker_type === null) { + // NOTE: For now, we ignore the virtual "head" marker. + continue; + } + + $commit_ref = $api->newCommitRef() + ->setCommitHash($node); + + $marker_ref = id(new ArcanistMarkerRef()) + ->setName($item['name']) + ->setCommitHash($node) + ->attachCommitRef($commit_ref); + + if (isset($item['description'])) { + $description = $item['description']; + $commit_ref->attachMessage($description); + + $description_lines = phutil_split_lines($description, false); + $marker_ref->setSummary(head($description_lines)); + } + + $marker_ref + ->setMarkerType($marker_type) + ->setIsActive(!empty($item['isActive'])); + + $markers[] = $marker_ref; + } + + return $markers; + } + +} diff --git a/src/repository/marker/ArcanistRepositoryMarkerQuery.php b/src/repository/marker/ArcanistRepositoryMarkerQuery.php new file mode 100644 index 00000000..e72ea78f --- /dev/null +++ b/src/repository/marker/ArcanistRepositoryMarkerQuery.php @@ -0,0 +1,121 @@ +markerTypes = array_fuse($types); + return $this; + } + + final public function withNames(array $names) { + $this->names = array_fuse($names); + return $this; + } + + final public function withRemotes(array $remotes) { + assert_instances_of($remotes, 'ArcanistRemoteRef'); + $this->remotes = $remotes; + return $this; + } + + final public function withIsRemoteCache($is_cache) { + $this->isRemoteCache = $is_cache; + return $this; + } + + final public function withIsActive($active) { + $this->isActive = $active; + return $this; + } + + final public function execute() { + $remotes = $this->remotes; + if ($remotes !== null) { + $marker_lists = array(); + foreach ($remotes as $remote) { + $marker_list = $this->newRemoteRefMarkers($remote); + foreach ($marker_list as $marker) { + $marker->attachRemoteRef($remote); + } + $marker_lists[] = $marker_list; + } + $markers = array_mergev($marker_lists); + } else { + $markers = $this->newLocalRefMarkers(); + foreach ($markers as $marker) { + $marker->attachRemoteRef(null); + } + } + + $api = $this->getRepositoryAPI(); + foreach ($markers as $marker) { + $state_ref = id(new ArcanistWorkingCopyStateRef()) + ->setCommitRef($marker->getCommitRef()); + + $marker->attachWorkingCopyStateRef($state_ref); + + $hash = $marker->getCommitHash(); + $hash = $api->getDisplayHash($hash); + $marker->setDisplayHash($hash); + } + + $types = $this->markerTypes; + if ($types !== null) { + foreach ($markers as $key => $marker) { + if (!isset($types[$marker->getMarkerType()])) { + unset($markers[$key]); + } + } + } + + $names = $this->names; + if ($names !== null) { + foreach ($markers as $key => $marker) { + if (!isset($names[$marker->getName()])) { + unset($markers[$key]); + } + } + } + + if ($this->isActive !== null) { + foreach ($markers as $key => $marker) { + if ($marker->getIsActive() !== $this->isActive) { + unset($markers[$key]); + } + } + } + + if ($this->isRemoteCache !== null) { + $want_cache = $this->isRemoteCache; + foreach ($markers as $key => $marker) { + $is_cache = ($marker->getRemoteName() !== null); + if ($is_cache !== $want_cache) { + unset($markers[$key]); + } + } + } + + return $markers; + } + + final protected function shouldQueryMarkerType($marker_type) { + if ($this->markerTypes === null) { + return true; + } + + return isset($this->markerTypes[$marker_type]); + } + + abstract protected function newLocalRefMarkers(); + abstract protected function newRemoteRefMarkers(ArcanistRemoteRef $remote); + +} diff --git a/src/repository/query/ArcanistRepositoryQuery.php b/src/repository/query/ArcanistRepositoryQuery.php new file mode 100644 index 00000000..23b20aef --- /dev/null +++ b/src/repository/query/ArcanistRepositoryQuery.php @@ -0,0 +1,35 @@ +repositoryAPI = $api; + return $this; + } + + final public function getRepositoryAPI() { + return $this->repositoryAPI; + } + + abstract public function execute(); + + final public function executeOne() { + $refs = $this->execute(); + + if (!$refs) { + return null; + } + + if (count($refs) > 1) { + throw new Exception( + pht( + 'Query matched multiple refs, expected zero or one.')); + } + + return head($refs); + } + +} diff --git a/src/repository/raw/ArcanistGitRawCommit.php b/src/repository/raw/ArcanistGitRawCommit.php new file mode 100644 index 00000000..7fa01de1 --- /dev/null +++ b/src/repository/raw/ArcanistGitRawCommit.php @@ -0,0 +1,183 @@ +setTreeHash(self::GIT_EMPTY_TREE_HASH); + return $raw; + } + + public static function newFromRawBlob($blob) { + $lines = phutil_split_lines($blob); + + $seen = array(); + $raw = new self(); + + $pattern = '(^(\w+) ([^\n]+)\n?\z)'; + foreach ($lines as $key => $line) { + unset($lines[$key]); + + $is_divider = ($line === "\n"); + if ($is_divider) { + break; + } + + $matches = null; + $ok = preg_match($pattern, $line, $matches); + if (!$ok) { + throw new Exception( + pht( + 'Expected to match pattern "%s" against line "%s" in raw commit '. + 'blob: %s', + $pattern, + $line, + $blob)); + } + + $label = $matches[1]; + $value = $matches[2]; + + // Detect unexpected repeated lines. + + if (isset($seen[$label])) { + switch ($label) { + case 'parent': + break; + default: + throw new Exception( + pht( + 'Encountered two "%s" lines ("%s", "%s") while parsing raw '. + 'commit blob, expected at most one: %s', + $label, + $seen[$label], + $line, + $blob)); + } + } else { + $seen[$label] = $line; + } + + switch ($label) { + case 'tree': + $raw->setTreeHash($value); + break; + case 'parent': + $raw->addParent($value); + break; + case 'author': + $raw->setRawAuthor($value); + break; + case 'committer': + $raw->setRawCommitter($value); + break; + default: + throw new Exception( + pht( + 'Unknown attribute label "%s" in line "%s" while parsing raw '. + 'commit blob: %s', + $label, + $line, + $blob)); + } + } + + $message = implode('', $lines); + $raw->setMessage($message); + + return $raw; + } + + public function getRawBlob() { + $out = array(); + + $tree = $this->getTreeHash(); + if ($tree !== null) { + $out[] = sprintf("tree %s\n", $tree); + } + + $parents = $this->getParents(); + foreach ($parents as $parent) { + $out[] = sprintf("parent %s\n", $parent); + } + + $raw_author = $this->getRawAuthor(); + if ($raw_author !== null) { + $out[] = sprintf("author %s\n", $raw_author); + } + + $raw_committer = $this->getRawCommitter(); + if ($raw_committer !== null) { + $out[] = sprintf("committer %s\n", $raw_committer); + } + + $out[] = "\n"; + + $message = $this->getMessage(); + if ($message !== null) { + $out[] = $message; + } + + return implode('', $out); + } + + public function setTreeHash($tree_hash) { + $this->treeHash = $tree_hash; + return $this; + } + + public function getTreeHash() { + return $this->treeHash; + } + + public function setRawAuthor($raw_author) { + $this->rawAuthor = $raw_author; + return $this; + } + + public function getRawAuthor() { + return $this->rawAuthor; + } + + public function setRawCommitter($raw_committer) { + $this->rawCommitter = $raw_committer; + return $this; + } + + public function getRawCommitter() { + return $this->rawCommitter; + } + + public function setParents(array $parents) { + $this->parents = $parents; + return $this; + } + + public function getParents() { + return $this->parents; + } + + public function addParent($hash) { + $this->parents[] = $hash; + return $this; + } + + public function setMessage($message) { + $this->message = $message; + return $this; + } + + public function getMessage() { + return $this->message; + } + +} diff --git a/src/repository/raw/__tests__/ArcanistGitRawCommitTestCase.php b/src/repository/raw/__tests__/ArcanistGitRawCommitTestCase.php new file mode 100644 index 00000000..8b04ba7e --- /dev/null +++ b/src/repository/raw/__tests__/ArcanistGitRawCommitTestCase.php @@ -0,0 +1,91 @@ + 'empty', + 'blob' => array( + 'tree fcfd0454eac6a28c729aa6bf7d38a5f1efc5cc5d', + '', + '', + ), + 'tree' => 'fcfd0454eac6a28c729aa6bf7d38a5f1efc5cc5d', + ), + array( + 'name' => 'parents', + 'blob' => array( + 'tree 63ece8fd5a8283f1da2c14735d059669a09ba628', + 'parent 4aebaaf60895c3f3dd32a8cadff00db2c8f74899', + 'parent 0da1a2e17d921dc27ce9afa76b123cb4c8b73b17', + 'author alice', + 'committer alice', + '', + 'Quack quack quack.', + '', + ), + 'tree' => '63ece8fd5a8283f1da2c14735d059669a09ba628', + 'parents' => array( + '4aebaaf60895c3f3dd32a8cadff00db2c8f74899', + '0da1a2e17d921dc27ce9afa76b123cb4c8b73b17', + ), + 'author' => 'alice', + 'committer' => 'alice', + 'message' => "Quack quack quack.\n", + ), + ); + + foreach ($cases as $case) { + $name = $case['name']; + $blob = $case['blob']; + + if (is_array($blob)) { + $blob = implode("\n", $blob); + } + + $raw = ArcanistGitRawCommit::newFromRawBlob($blob); + $out = $raw->getRawBlob(); + + $this->assertEqual( + $blob, + $out, + pht( + 'Expected read + write to produce the original raw Git commit '. + 'blob in case "%s".', + $name)); + + $tree = idx($case, 'tree'); + $this->assertEqual( + $tree, + $raw->getTreeHash(), + pht('Tree hashes in case "%s".', $name)); + + $parents = idx($case, 'parents', array()); + $this->assertEqual( + $parents, + $raw->getParents(), + pht('Parents in case "%s".', $name)); + + $author = idx($case, 'author'); + $this->assertEqual( + $author, + $raw->getRawAuthor(), + pht('Authors in case "%s".', $name)); + + $committer = idx($case, 'committer'); + $this->assertEqual( + $committer, + $raw->getRawCommitter(), + pht('Committer in case "%s".', $name)); + + $message = idx($case, 'message', ''); + $this->assertEqual( + $message, + $raw->getMessage(), + pht('Message in case "%s".', $name)); + } + } + +} diff --git a/src/repository/remote/ArcanistGitRepositoryRemoteQuery.php b/src/repository/remote/ArcanistGitRepositoryRemoteQuery.php new file mode 100644 index 00000000..47b914e9 --- /dev/null +++ b/src/repository/remote/ArcanistGitRepositoryRemoteQuery.php @@ -0,0 +1,63 @@ +getRepositoryAPI(); + + $future = $api->newFuture('remote --verbose'); + list($lines) = $future->resolve(); + + $pattern = + '(^'. + '(?P[^\t]+)'. + '\t'. + '(?P[^\s]+)'. + ' '. + '\((?Pfetch|push)\)'. + '\z'. + ')'; + + $map = array(); + + $lines = phutil_split_lines($lines, false); + foreach ($lines as $line) { + $matches = null; + if (!preg_match($pattern, $line, $matches)) { + throw new Exception( + pht( + 'Failed to match remote pattern against line "%s".', + $line)); + } + + $name = $matches['name']; + $uri = $matches['uri']; + $mode = $matches['mode']; + + $map[$name][$mode] = $uri; + } + + $refs = array(); + foreach ($map as $name => $uris) { + $fetch_uri = idx($uris, 'fetch'); + $push_uri = idx($uris, 'push'); + + $ref = id(new ArcanistRemoteRef()) + ->setRemoteName($name); + + if ($fetch_uri !== null) { + $ref->setFetchURI($fetch_uri); + } + + if ($push_uri !== null) { + $ref->setPushURI($push_uri); + } + + $refs[] = $ref; + } + + return $refs; + } + +} diff --git a/src/repository/remote/ArcanistMercurialRepositoryRemoteQuery.php b/src/repository/remote/ArcanistMercurialRepositoryRemoteQuery.php new file mode 100644 index 00000000..879c3111 --- /dev/null +++ b/src/repository/remote/ArcanistMercurialRepositoryRemoteQuery.php @@ -0,0 +1,45 @@ +getRepositoryAPI(); + + $future = $api->newFuture('paths'); + list($lines) = $future->resolve(); + + $refs = array(); + + $pattern = '(^(?P.*?) = (?P.*)\z)'; + + $lines = phutil_split_lines($lines, false); + foreach ($lines as $line) { + $matches = null; + if (!preg_match($pattern, $line, $matches)) { + throw new Exception( + pht( + 'Failed to match remote pattern against line "%s".', + $line)); + } + + $name = $matches['name']; + $uri = $matches['uri']; + + // NOTE: Mercurial gives some special behavior to "default" and + // "default-push", but these remotes are both fully-formed remotes that + // are fetchable and pushable, they just have rules around selection + // as default targets for operations. + + $ref = id(new ArcanistRemoteRef()) + ->setRemoteName($name) + ->setFetchURI($uri) + ->setPushURI($uri); + + $refs[] = $ref; + } + + return $refs; + } + +} diff --git a/src/repository/remote/ArcanistRemoteRef.php b/src/repository/remote/ArcanistRemoteRef.php new file mode 100644 index 00000000..7a34e1bd --- /dev/null +++ b/src/repository/remote/ArcanistRemoteRef.php @@ -0,0 +1,101 @@ +getRemoteName()); + } + + public function setRepositoryAPI(ArcanistRepositoryAPI $repository_api) { + $this->repositoryAPI = $repository_api; + return $this; + } + + public function getRepositoryAPI() { + return $this->repositoryAPI; + } + + public function setRemoteName($remote_name) { + $this->remoteName = $remote_name; + return $this; + } + + public function getRemoteName() { + return $this->remoteName; + } + + public function setFetchURI($fetch_uri) { + $this->fetchURI = $fetch_uri; + return $this; + } + + public function getFetchURI() { + return $this->fetchURI; + } + + public function setPushURI($push_uri) { + $this->pushURI = $push_uri; + return $this; + } + + public function getPushURI() { + return $this->pushURI; + } + + protected function buildRefView(ArcanistRefView $view) { + $view->setObjectName($this->getRemoteName()); + } + + protected function newHardpoints() { + $object_list = new ArcanistObjectListHardpoint(); + return array( + $this->newTemplateHardpoint(self::HARDPOINT_REPOSITORYREFS, $object_list), + ); + } + + private function getRepositoryRefs() { + return $this->getHardpoint(self::HARDPOINT_REPOSITORYREFS); + } + + public function getPushRepositoryRef() { + return $this->getRepositoryRefByURI($this->getPushURI()); + } + + public function getFetchRepositoryRef() { + return $this->getRepositoryRefByURI($this->getFetchURI()); + } + + private function getRepositoryRefByURI($uri) { + $api = $this->getRepositoryAPI(); + + $uri = $api->getNormalizedURI($uri); + foreach ($this->getRepositoryRefs() as $repository_ref) { + foreach ($repository_ref->getURIs() as $repository_uri) { + $repository_uri = $api->getNormalizedURI($repository_uri); + if ($repository_uri === $uri) { + return $repository_ref; + } + } + } + + return null; + } + + public function isPermanentRef(ArcanistMarkerRef $ref) { + $repository_ref = $this->getPushRepositoryRef(); + if (!$repository_ref) { + return false; + } + + return $repository_ref->isPermanentRef($ref); + } + +} diff --git a/src/repository/remote/ArcanistRemoteRefInspector.php b/src/repository/remote/ArcanistRemoteRefInspector.php new file mode 100644 index 00000000..d7f7e778 --- /dev/null +++ b/src/repository/remote/ArcanistRemoteRefInspector.php @@ -0,0 +1,37 @@ +getWorkflow(); + $api = $workflow->getRepositoryAPI(); + + $ref = $api->newRemoteRefQuery() + ->withNames(array($remote_name)) + ->executeOne(); + + if (!$ref) { + throw new PhutilArgumentUsageException( + pht( + 'This working copy has no remote named "%s".', + $remote_name)); + } + + return $ref; + } + +} diff --git a/src/repository/remote/ArcanistRemoteRepositoryRefsHardpointQuery.php b/src/repository/remote/ArcanistRemoteRepositoryRefsHardpointQuery.php new file mode 100644 index 00000000..9969203f --- /dev/null +++ b/src/repository/remote/ArcanistRemoteRepositoryRefsHardpointQuery.php @@ -0,0 +1,89 @@ +getRepositoryAPI(); + + $uris = array(); + foreach ($refs as $remote) { + $fetch_uri = $remote->getFetchURI(); + if ($fetch_uri !== null) { + $uris[] = $fetch_uri; + } + + $push_uri = $remote->getPushURI(); + if ($push_uri !== null) { + $uris[] = $push_uri; + } + } + + if (!$uris) { + yield $this->yieldValue($refs, array()); + } + + $uris = array_fuse($uris); + $uris = array_values($uris); + + $search_future = $this->newConduitSearch( + 'diffusion.repository.search', + array( + 'uris' => $uris, + ), + array( + 'uris' => true, + )); + + $repository_info = (yield $this->yieldFuture($search_future)); + + $repository_refs = array(); + foreach ($repository_info as $raw_result) { + $repository_refs[] = ArcanistRepositoryRef::newFromConduit($raw_result); + } + + $uri_map = array(); + foreach ($repository_refs as $repository_ref) { + foreach ($repository_ref->getURIs() as $repository_uri) { + $repository_uri = $api->getNormalizedURI($repository_uri); + $uri_map[$repository_uri] = $repository_ref; + } + } + + $results = array(); + foreach ($refs as $key => $remote) { + $result = array(); + + $fetch_uri = $remote->getFetchURI(); + if ($fetch_uri !== null) { + $fetch_uri = $api->getNormalizedURI($fetch_uri); + if (isset($uri_map[$fetch_uri])) { + $result[] = $uri_map[$fetch_uri]; + } + } + + $push_uri = $remote->getPushURI(); + if ($push_uri !== null) { + $push_uri = $api->getNormalizedURI($push_uri); + if (isset($uri_map[$push_uri])) { + $result[] = $uri_map[$push_uri]; + } + } + + $results[$key] = $result; + } + + yield $this->yieldMap($results); + } + +} diff --git a/src/repository/remote/ArcanistRepositoryRemoteQuery.php b/src/repository/remote/ArcanistRepositoryRemoteQuery.php new file mode 100644 index 00000000..9918f2ac --- /dev/null +++ b/src/repository/remote/ArcanistRepositoryRemoteQuery.php @@ -0,0 +1,36 @@ +names = $names; + return $this; + } + + final public function execute() { + $api = $this->getRepositoryAPI(); + $refs = $this->newRemoteRefs(); + + foreach ($refs as $ref) { + $ref->setRepositoryAPI($api); + } + + $names = $this->names; + if ($names !== null) { + $names = array_fuse($names); + foreach ($refs as $key => $ref) { + if (!isset($names[$ref->getRemoteName()])) { + unset($refs[$key]); + } + } + } + + return $refs; + } + + abstract protected function newRemoteRefs(); + +} diff --git a/src/repository/remote/ArcanistRepositoryURINormalizer.php b/src/repository/remote/ArcanistRepositoryURINormalizer.php new file mode 100644 index 00000000..33c3f5b9 --- /dev/null +++ b/src/repository/remote/ArcanistRepositoryURINormalizer.php @@ -0,0 +1,159 @@ +getNormalizedPath() === $norm_b->getNormalizedPath()) { + * // URIs appear to point at the same repository. + * } else { + * // URIs are very unlikely to be the same repository. + * } + * + * Because a repository can be hosted at arbitrarily many arbitrary URIs, there + * is no way to completely prevent false negatives by only examining URIs + * (that is, repositories with totally different URIs could really be the same). + * However, normalization is relatively aggressive and false negatives should + * be rare: if normalization says two URIs are different repositories, they + * probably are. + * + * @task normal Normalizing URIs + */ +final class ArcanistRepositoryURINormalizer + extends Phobject { + + const TYPE_GIT = 'git'; + const TYPE_SVN = 'svn'; + const TYPE_MERCURIAL = 'hg'; + + private $type; + private $uri; + private $domainMap = array(); + + public function __construct($type, $uri) { + switch ($type) { + case self::TYPE_GIT: + case self::TYPE_SVN: + case self::TYPE_MERCURIAL: + break; + default: + throw new Exception(pht('Unknown URI type "%s"!', $type)); + } + + $this->type = $type; + $this->uri = $uri; + } + + public static function getAllURITypes() { + return array( + self::TYPE_GIT, + self::TYPE_SVN, + self::TYPE_MERCURIAL, + ); + } + + public function setDomainMap(array $domain_map) { + foreach ($domain_map as $key => $domain) { + $domain_map[$key] = phutil_utf8_strtolower($domain); + } + + $this->domainMap = $domain_map; + return $this; + } + + +/* -( Normalizing URIs )--------------------------------------------------- */ + + + /** + * @task normal + */ + public function getPath() { + switch ($this->type) { + case self::TYPE_GIT: + $uri = new PhutilURI($this->uri); + return $uri->getPath(); + case self::TYPE_SVN: + case self::TYPE_MERCURIAL: + $uri = new PhutilURI($this->uri); + if ($uri->getProtocol()) { + return $uri->getPath(); + } + + return $this->uri; + } + } + + public function getNormalizedURI() { + return $this->getNormalizedDomain().'/'.$this->getNormalizedPath(); + } + + + /** + * @task normal + */ + public function getNormalizedPath() { + $path = $this->getPath(); + $path = trim($path, '/'); + + switch ($this->type) { + case self::TYPE_GIT: + $path = preg_replace('/\.git$/', '', $path); + break; + case self::TYPE_SVN: + case self::TYPE_MERCURIAL: + break; + } + + // If this is a Phabricator URI, strip it down to the callsign. We mutably + // allow you to clone repositories as "/diffusion/X/anything.git", for + // example. + + $matches = null; + if (preg_match('@^(diffusion/(?:[A-Z]+|\d+))@', $path, $matches)) { + $path = $matches[1]; + } + + return $path; + } + + public function getNormalizedDomain() { + $domain = null; + + $uri = new PhutilURI($this->uri); + $domain = $uri->getDomain(); + + if (!strlen($domain)) { + return ''; + } + + $domain = phutil_utf8_strtolower($domain); + + foreach ($this->domainMap as $domain_key => $domain_value) { + if ($domain === $domain_value) { + $domain = $domain_key; + break; + } + } + + return $domain; + } + +} diff --git a/src/repository/remote/__tests__/ArcanistRepositoryURINormalizerTestCase.php b/src/repository/remote/__tests__/ArcanistRepositoryURINormalizerTestCase.php new file mode 100644 index 00000000..e2e6edb5 --- /dev/null +++ b/src/repository/remote/__tests__/ArcanistRepositoryURINormalizerTestCase.php @@ -0,0 +1,84 @@ + 'path', + 'https://user@domain.com/path.git' => 'path', + 'git@domain.com:path.git' => 'path', + 'ssh://user@gitserv002.com/path.git' => 'path', + 'ssh://htaft@domain.com/path.git' => 'path', + 'ssh://user@domain.com/bananas.git' => 'bananas', + 'git@domain.com:bananas.git' => 'bananas', + 'user@domain.com:path/repo' => 'path/repo', + 'user@domain.com:path/repo/' => 'path/repo', + 'file:///path/to/local/repo.git' => 'path/to/local/repo', + '/path/to/local/repo.git' => 'path/to/local/repo', + 'ssh://something.com/diffusion/X/anything.git' => 'diffusion/X', + 'ssh://something.com/diffusion/X/' => 'diffusion/X', + ); + + $type_git = ArcanistRepositoryURINormalizer::TYPE_GIT; + + foreach ($cases as $input => $expect) { + $normal = new ArcanistRepositoryURINormalizer($type_git, $input); + $this->assertEqual( + $expect, + $normal->getNormalizedPath(), + pht('Normalized Git path for "%s".', $input)); + } + } + + public function testDomainURINormalizer() { + $base_domain = 'base.phabricator.example.com'; + $ssh_domain = 'ssh.phabricator.example.com'; + + $domain_map = array( + '' => $base_domain, + '' => $ssh_domain, + ); + + $cases = array( + '/' => '', + '/path/to/local/repo.git' => '', + 'ssh://user@domain.com/path.git' => 'domain.com', + 'ssh://user@DOMAIN.COM/path.git' => 'domain.com', + 'http://'.$base_domain.'/diffusion/X/' => '', + 'ssh://'.$ssh_domain.'/diffusion/X/' => '', + 'git@'.$ssh_domain.':bananas.git' => '', + ); + + $type_git = ArcanistRepositoryURINormalizer::TYPE_GIT; + + foreach ($cases as $input => $expect) { + $normalizer = new ArcanistRepositoryURINormalizer($type_git, $input); + + $normalizer->setDomainMap($domain_map); + + $this->assertEqual( + $expect, + $normalizer->getNormalizedDomain(), + pht('Normalized domain for "%s".', $input)); + } + } + + public function testSVNURINormalizer() { + $cases = array( + 'file:///path/to/repo' => 'path/to/repo', + 'file:///path/to/repo/' => 'path/to/repo', + ); + + $type_svn = ArcanistRepositoryURINormalizer::TYPE_SVN; + + foreach ($cases as $input => $expect) { + $normal = new ArcanistRepositoryURINormalizer($type_svn, $input); + $this->assertEqual( + $expect, + $normal->getNormalizedPath(), + pht('Normalized SVN path for "%s".', $input)); + } + } + +} diff --git a/src/repository/state/ArcanistGitLocalState.php b/src/repository/state/ArcanistGitLocalState.php new file mode 100644 index 00000000..7b0b168a --- /dev/null +++ b/src/repository/state/ArcanistGitLocalState.php @@ -0,0 +1,166 @@ +localRef; + } + + public function getLocalPath() { + return $this->localPath; + } + + protected function executeSaveLocalState() { + $api = $this->getRepositoryAPI(); + + $commit = $api->getWorkingCopyRevision(); + + list($ref) = $api->execxLocal('rev-parse --abbrev-ref HEAD'); + $ref = trim($ref); + if ($ref === 'HEAD') { + $ref = null; + $where = pht( + 'Saving local state (at detached commit "%s").', + $api->getDisplayHash($commit)); + } else { + $where = pht( + 'Saving local state (on ref "%s" at commit "%s").', + $ref, + $api->getDisplayHash($commit)); + } + + $this->localRef = $ref; + $this->localCommit = $commit; + + if ($ref !== null) { + $this->localPath = $api->getPathToUpstream($ref); + } + + $log = $this->getWorkflow()->getLogEngine(); + $log->writeTrace(pht('SAVE STATE'), $where); + } + + protected function executeRestoreLocalState() { + $api = $this->getRepositoryAPI(); + $log = $this->getWorkflow()->getLogEngine(); + + $ref = $this->localRef; + $commit = $this->localCommit; + + if ($ref !== null) { + $where = pht( + 'Restoring local state (to ref "%s" at commit "%s").', + $ref, + $api->getDisplayHash($commit)); + } else { + $where = pht( + 'Restoring local state (to detached commit "%s").', + $api->getDisplayHash($commit)); + } + + $log->writeStatus(pht('LOAD STATE'), $where); + + if ($ref !== null) { + $api->execxLocal('checkout -B %s %s --', $ref, $commit); + + // TODO: We save, but do not restore, the upstream configuration of + // this branch. + + } else { + $api->execxLocal('checkout %s --', $commit); + } + + $api->execxLocal('submodule update --init --recursive'); + } + + protected function executeDiscardLocalState() { + // We don't have anything to clean up in Git. + return; + } + + protected function newRestoreCommandsForDisplay() { + $api = $this->getRepositoryAPI(); + $ref = $this->localRef; + $commit = $this->localCommit; + + $commands = array(); + + if ($ref !== null) { + $commands[] = csprintf( + 'git checkout -B %s %s --', + $ref, + $api->getDisplayHash($commit)); + } else { + $commands[] = csprintf( + 'git checkout %s --', + $api->getDisplayHash($commit)); + } + + // NOTE: We run "submodule update" in the real restore workflow, but + // assume users can reasonably figure that out on their own. + + return $commands; + } + + protected function canStashChanges() { + return true; + } + + protected function getIgnoreHints() { + return array( + pht( + 'To configure Git to ignore certain files in this working copy, '. + 'add the file paths to "%s".', + '.git/info/exclude'), + ); + } + + protected function saveStash() { + $api = $this->getRepositoryAPI(); + + // NOTE: We'd prefer to "git stash create" here, because using "push" + // and "pop" means we're affecting the stash list as a side effect. + + // However, under Git 2.21.1, "git stash create" exits with no output, + // no error, and no effect if the working copy contains only untracked + // files. For now, accept mutations to the stash list. + + $api->execxLocal('stash push --include-untracked --'); + + $log = $this->getWorkflow()->getLogEngine(); + $log->writeStatus( + pht('SAVE STASH'), + pht('Saved uncommitted changes from working copy.')); + + return true; + } + + protected function restoreStash($stash_ref) { + $api = $this->getRepositoryAPI(); + + $log = $this->getWorkflow()->getLogEngine(); + $log->writeStatus( + pht('LOAD STASH'), + pht('Restoring uncommitted changes to working copy.')); + + // NOTE: Under Git 2.21.1, "git stash apply" does not accept "--". + $api->execxLocal('stash apply'); + } + + protected function discardStash($stash_ref) { + $api = $this->getRepositoryAPI(); + + // NOTE: Under Git 2.21.1, "git stash drop" does not accept "--". + $api->execxLocal('stash drop'); + } + + private function getDisplayStashRef($stash_ref) { + return substr($stash_ref, 0, 12); + } + +} diff --git a/src/repository/state/ArcanistMercurialLocalState.php b/src/repository/state/ArcanistMercurialLocalState.php new file mode 100644 index 00000000..a9ad0a31 --- /dev/null +++ b/src/repository/state/ArcanistMercurialLocalState.php @@ -0,0 +1,116 @@ +getRepositoryAPI(); + $log = $this->getWorkflow()->getLogEngine(); + + // TODO: Both of these can be pulled from "hg arc-ls-markers" more + // efficiently. + + $this->localCommit = $api->getCanonicalRevisionName('.'); + + list($branch) = $api->execxLocal('branch'); + $this->localBranch = trim($branch); + + $log->writeTrace( + pht('SAVE STATE'), + pht( + 'Saving local state (at "%s" on branch "%s").', + $api->getDisplayHash($this->localCommit), + $this->localBranch)); + } + + protected function executeRestoreLocalState() { + $api = $this->getRepositoryAPI(); + $log = $this->getWorkflow()->getLogEngine(); + + $log->writeStatus( + pht('LOAD STATE'), + pht( + 'Restoring local state (at "%s" on branch "%s").', + $api->getDisplayHash($this->localCommit), + $this->localBranch)); + + $api->execxLocal('update -- %s', $this->localCommit); + $api->execxLocal('branch --force -- %s', $this->localBranch); + } + + protected function executeDiscardLocalState() { + return; + } + + protected function canStashChanges() { + $api = $this->getRepositoryAPI(); + return $api->getMercurialFeature('shelve'); + } + + protected function getIgnoreHints() { + return array( + pht( + 'To configure Mercurial to ignore certain files in the working '. + 'copy, add them to ".hgignore".'), + ); + } + + protected function newRestoreCommandsForDisplay() { + $api = $this->getRepositoryAPI(); + $commands = array(); + + $commands[] = csprintf( + 'hg update -- %s', + $api->getDisplayHash($this->localCommit)); + + $commands[] = csprintf( + 'hg branch --force -- %s', + $this->localBranch); + + return $commands; + } + + protected function saveStash() { + $api = $this->getRepositoryAPI(); + $log = $this->getWorkflow()->getLogEngine(); + + $stash_ref = sprintf( + 'arc-%s', + Filesystem::readRandomCharacters(12)); + + $api->execxLocal( + '--config extensions.shelve= shelve --unknown --name %s --', + $stash_ref); + + $log->writeStatus( + pht('SHELVE'), + pht('Shelving uncommitted changes from working copy.')); + + return $stash_ref; + } + + protected function restoreStash($stash_ref) { + $api = $this->getRepositoryAPI(); + $log = $this->getWorkflow()->getLogEngine(); + + $log->writeStatus( + pht('UNSHELVE'), + pht('Restoring uncommitted changes to working copy.')); + + $api->execxLocal( + '--config extensions.shelve= unshelve --keep --name %s --', + $stash_ref); + } + + protected function discardStash($stash_ref) { + $api = $this->getRepositoryAPI(); + + $api->execxLocal( + '--config extensions.shelve= shelve --delete %s --', + $stash_ref); + } + +} diff --git a/src/repository/state/ArcanistRepositoryLocalState.php b/src/repository/state/ArcanistRepositoryLocalState.php new file mode 100644 index 00000000..1526d50d --- /dev/null +++ b/src/repository/state/ArcanistRepositoryLocalState.php @@ -0,0 +1,259 @@ +workflow = $workflow; + return $this; + } + + final public function getWorkflow() { + return $this->workflow; + } + + final public function setRepositoryAPI(ArcanistRepositoryAPI $api) { + $this->repositoryAPI = $api; + return $this; + } + + final public function getRepositoryAPI() { + return $this->repositoryAPI; + } + + final public function saveLocalState() { + $api = $this->getRepositoryAPI(); + + $working_copy_display = tsprintf( + " %s: %s\n", + pht('Working Copy'), + $api->getPath()); + + $conflicts = $api->getMergeConflicts(); + if ($conflicts) { + echo tsprintf( + "\n%!\n%W\n\n%s\n", + pht('MERGE CONFLICTS'), + pht('You have merge conflicts in this working copy.'), + $working_copy_display); + + $lists = array(); + + $lists[] = $this->newDisplayFileList( + pht('Merge conflicts in working copy:'), + $conflicts); + + $this->printFileLists($lists); + + throw new PhutilArgumentUsageException( + pht( + 'Resolve merge conflicts before proceeding.')); + } + + $externals = $api->getDirtyExternalChanges(); + if ($externals) { + $message = pht( + '%s submodule(s) have uncommitted or untracked changes:', + new PhutilNumber(count($externals))); + + $prompt = pht( + 'Ignore the changes to these %s submodule(s) and continue?', + new PhutilNumber(count($externals))); + + $list = id(new PhutilConsoleList()) + ->setWrap(false) + ->addItems($externals); + + id(new PhutilConsoleBlock()) + ->addParagraph($message) + ->addList($list) + ->draw(); + + $ok = phutil_console_confirm($prompt, $default_no = false); + if (!$ok) { + throw new ArcanistUserAbortException(); + } + } + + $uncommitted = $api->getUncommittedChanges(); + $unstaged = $api->getUnstagedChanges(); + $untracked = $api->getUntrackedChanges(); + + // We already dealt with externals. + $unstaged = array_diff($unstaged, $externals); + + // We only want files which are purely uncommitted. + $uncommitted = array_diff($uncommitted, $unstaged); + $uncommitted = array_diff($uncommitted, $externals); + + if ($untracked || $unstaged || $uncommitted) { + echo tsprintf( + "\n%!\n%W\n\n%s\n", + pht('UNCOMMITTED CHANGES'), + pht('You have uncommitted changes in this working copy.'), + $working_copy_display); + + $lists = array(); + + $lists[] = $this->newDisplayFileList( + pht('Untracked changes in working copy:'), + $untracked); + + $lists[] = $this->newDisplayFileList( + pht('Unstaged changes in working copy:'), + $unstaged); + + $lists[] = $this->newDisplayFileList( + pht('Uncommitted changes in working copy:'), + $uncommitted); + + $this->printFileLists($lists); + + if ($untracked) { + $hints = $this->getIgnoreHints(); + foreach ($hints as $hint) { + echo tsprintf("%?\n", $hint); + } + } + + if ($this->canStashChanges()) { + + $query = pht('Stash these changes and continue?'); + + $this->getWorkflow() + ->getPrompt('arc.state.stash') + ->setQuery($query) + ->execute(); + + $stash_ref = $this->saveStash(); + + if ($stash_ref === null) { + throw new Exception( + pht( + 'Expected a non-null return from call to "%s->saveStash()".', + get_class($this))); + } + + $this->stashRef = $stash_ref; + } else { + throw new PhutilArgumentUsageException( + pht( + 'You can not continue with uncommitted changes. Commit or '. + 'discard them before proceeding.')); + } + } + + $this->executeSaveLocalState(); + $this->shouldRestore = true; + + // TODO: Detect when we're in the middle of a rebase. + // TODO: Detect when we're in the middle of a cherry-pick. + + return $this; + } + + final public function restoreLocalState() { + $this->shouldRestore = false; + + $this->executeRestoreLocalState(); + $this->applyStash(); + $this->executeDiscardLocalState(); + + return $this; + } + + final public function discardLocalState() { + $this->shouldRestore = false; + + $this->applyStash(); + $this->executeDiscardLocalState(); + + return $this; + } + + final public function __destruct() { + if ($this->shouldRestore) { + $this->restoreLocalState(); + } else { + $this->discardLocalState(); + } + } + + final public function getRestoreCommandsForDisplay() { + return $this->newRestoreCommandsForDisplay(); + } + + protected function canStashChanges() { + return false; + } + + protected function saveStash() { + throw new PhutilMethodNotImplementedException(); + } + + protected function restoreStash($ref) { + throw new PhutilMethodNotImplementedException(); + } + + protected function discardStash($ref) { + throw new PhutilMethodNotImplementedException(); + } + + private function applyStash() { + if ($this->stashRef === null) { + return; + } + $stash_ref = $this->stashRef; + $this->stashRef = null; + + $this->restoreStash($stash_ref); + $this->discardStash($stash_ref); + } + + abstract protected function executeSaveLocalState(); + abstract protected function executeRestoreLocalState(); + abstract protected function executeDiscardLocalState(); + abstract protected function newRestoreCommandsForDisplay(); + + protected function getIgnoreHints() { + return array(); + } + + final protected function newDisplayFileList($title, array $files) { + if (!$files) { + return null; + } + + $items = array(); + $items[] = tsprintf("%s\n\n", $title); + foreach ($files as $file) { + $items[] = tsprintf( + " %s\n", + $file); + } + + return $items; + } + + final protected function printFileLists(array $lists) { + $lists = array_filter($lists); + + $last_key = last_key($lists); + foreach ($lists as $key => $list) { + foreach ($list as $item) { + echo tsprintf('%B', $item); + } + if ($key !== $last_key) { + echo tsprintf("\n\n"); + } + } + + echo tsprintf("\n"); + } + +} diff --git a/src/runtime/ArcanistRuntime.php b/src/runtime/ArcanistRuntime.php index 07a6ce1b..87d574a5 100644 --- a/src/runtime/ArcanistRuntime.php +++ b/src/runtime/ArcanistRuntime.php @@ -1,912 +1,913 @@ checkEnvironment(); } catch (Exception $ex) { echo "CONFIGURATION ERROR\n\n"; echo $ex->getMessage(); echo "\n\n"; return 1; } PhutilTranslator::getInstance() ->setLocale(PhutilLocale::loadLocale('en_US')) ->setTranslations(PhutilTranslation::getTranslationMapForLocale('en_US')); $log = new ArcanistLogEngine(); $this->logEngine = $log; try { return $this->executeCore($argv); } catch (ArcanistConduitException $ex) { $log->writeError(pht('CONDUIT'), $ex->getMessage()); } catch (PhutilArgumentUsageException $ex) { $log->writeError(pht('USAGE EXCEPTION'), $ex->getMessage()); } catch (ArcanistUserAbortException $ex) { $log->writeError(pht('---'), $ex->getMessage()); + } catch (ArcanistConduitAuthenticationException $ex) { + $log->writeError($ex->getTitle(), $ex->getBody()); } return 1; } private function executeCore(array $argv) { $log = $this->getLogEngine(); $config_args = array( array( 'name' => 'library', 'param' => 'path', 'help' => pht('Load a library.'), 'repeat' => true, ), array( 'name' => 'config', 'param' => 'key=value', 'repeat' => true, 'help' => pht('Specify a runtime configuration value.'), ), array( 'name' => 'config-file', 'param' => 'path', 'repeat' => true, 'help' => pht( 'Load one or more configuration files. If this flag is provided, '. 'the system and user configuration files are ignored.'), ), ); $args = id(new PhutilArgumentParser($argv)) ->parseStandardArguments(); // If we can test whether STDIN is a TTY, and it isn't, require that "--" // appear in the argument list. This is intended to make it very hard to // write unsafe scripts on top of Arcanist. if (phutil_is_noninteractive()) { $args->setRequireArgumentTerminator(true); } $is_trace = $args->getArg('trace'); $log->setShowTraceMessages($is_trace); $log->writeTrace(pht('ARGV'), csprintf('%Ls', $argv)); // We're installing the signal handler after parsing "--trace" so that it // can emit debugging messages. This means there's a very small window at // startup where signals have no special handling, but we couldn't really // route them or do anything interesting with them anyway. $this->installSignalHandler(); $args->parsePartial($config_args, true); $config_engine = $this->loadConfiguration($args); $config = $config_engine->newConfigurationSourceList(); $this->loadLibraries($config_engine, $config, $args); // Now that we've loaded libraries, we can validate configuration. // Do this before continuing since configuration can impact other // behaviors immediately and we want to catch any issues right away. $config->setConfigOptions($config_engine->newConfigOptionsMap()); $config->validateConfiguration($this); $toolset = $this->newToolset($argv); $this->setToolset($toolset); $args->parsePartial($toolset->getToolsetArguments()); $workflows = $this->newWorkflows($toolset); $this->workflows = $workflows; $conduit_engine = $this->newConduitEngine($config, $args); $this->conduitEngine = $conduit_engine; $phutil_workflows = array(); foreach ($workflows as $key => $workflow) { $workflow ->setRuntime($this) ->setConfigurationEngine($config_engine) ->setConfigurationSourceList($config) ->setConduitEngine($conduit_engine); $phutil_workflows[$key] = $workflow->newPhutilWorkflow(); } $unconsumed_argv = $args->getUnconsumedArgumentVector(); if (!$unconsumed_argv) { // TOOLSETS: This means the user just ran "arc" or some other top-level // toolset without any workflow argument. We should give them a summary // of the toolset, a list of workflows, and a pointer to "arc help" for // more details. // A possible exception is "arc --help", which should perhaps pass // through and act like "arc help". throw new PhutilArgumentUsageException(pht('Choose a workflow!')); } $alias_effects = id(new ArcanistAliasEngine()) ->setRuntime($this) ->setToolset($toolset) ->setWorkflows($workflows) ->setConfigurationSourceList($config) ->resolveAliases($unconsumed_argv); foreach ($alias_effects as $alias_effect) { if ($alias_effect->getType() === ArcanistAliasEffect::EFFECT_SHELL) { return $this->executeShellAlias($alias_effect); } } $result_argv = $this->applyAliasEffects($alias_effects, $unconsumed_argv); $args->setUnconsumedArgumentVector($result_argv); // TOOLSETS: Some day, stop falling through to the old "arc" runtime. $help_workflows = $this->getHelpWorkflows($phutil_workflows); $args->setHelpWorkflows($help_workflows); try { return $args->parseWorkflowsFull($phutil_workflows); } catch (ArcanistMissingArgumentTerminatorException $terminator_exception) { $log->writeHint( pht('USAGE'), pht( '"%s" is being run noninteractively, but the argument list is '. 'missing "--" to indicate end of flags.', $toolset->getToolsetKey())); $log->writeHint( pht('USAGE'), pht( 'When running noninteractively, you MUST provide "--" to all '. 'commands (even if they take no arguments).')); $log->writeHint( pht('USAGE'), tsprintf( '%s <__%s__>', pht('Learn More:'), 'https://phurl.io/u/noninteractive')); throw new PhutilArgumentUsageException( pht('Missing required "--" in argument list.')); } catch (PhutilArgumentUsageException $usage_exception) { // TODO: This is very, very hacky; we're trying to let errors like // "you passed the wrong arguments" through but fall back to classic // mode if the workflow itself doesn't exist. if (!preg_match('/invalid command/i', $usage_exception->getMessage())) { throw $usage_exception; } } $arcanist_root = phutil_get_library_root('arcanist'); $arcanist_root = dirname($arcanist_root); $bin = $arcanist_root.'/scripts/arcanist.php'; $err = phutil_passthru( 'php -f %R -- %Ls', $bin, array_slice($argv, 1)); return $err; } /** * 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. */ private function checkEnvironment() { // NOTE: We don't have phutil_is_windows() yet here. $is_windows = (DIRECTORY_SEPARATOR != '/'); // NOTE: There's a hard PHP version check earlier, in "init-script.php". if ($is_windows) { $need_functions = array( 'curl_init' => array('builtin-dll', 'php_curl.dll'), ); } else { $need_functions = array( 'curl_init' => array( 'text', "You need to install the cURL PHP extension, maybe with ". "'apt-get install php5-curl' or 'yum install php53-curl' or ". "something similar.", ), 'json_decode' => array('flag', '--without-json'), ); } $problems = array(); $config = null; $show_config = false; 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]; } } list($what, $which) = $resolution; if ($what == 'flag' && strpos($config, $which) !== false) { $show_config = true; $problems[] = sprintf( 'The build of PHP you are running was compiled with the configure '. 'flag "%s", which means it does not support the function "%s()". '. 'This function is required for Arcanist to run. Install a standard '. 'build of PHP or rebuild it without this flag. You may also be '. 'able to build or install the relevant extension separately.', $which, $fname); continue; } if ($what == 'builtin-dll') { $problems[] = sprintf( 'The build of PHP you are running does not have the "%s" extension '. 'enabled. Edit your php.ini file and uncomment the line which '. 'reads "extension=%s".', $which, $which); continue; } if ($what == 'text') { $problems[] = $which; continue; } $problems[] = sprintf( 'The build of PHP you are running is missing the required function '. '"%s()". Rebuild PHP or install the extension which provides "%s()".', $fname, $fname); } if ($problems) { if ($show_config) { $problems[] = "PHP was built with this configure command:\n\n{$config}"; } $problems = implode("\n\n", $problems); throw new Exception($problems); } } private function loadConfiguration(PhutilArgumentParser $args) { $engine = id(new ArcanistConfigurationEngine()) ->setArguments($args); $working_copy = ArcanistWorkingCopy::newFromWorkingDirectory(getcwd()); $engine->setWorkingCopy($working_copy); $this->workingCopy = $working_copy; $working_copy ->getRepositoryAPI() ->setRuntime($this); return $engine; } private function loadLibraries( ArcanistConfigurationEngine $engine, ArcanistConfigurationSourceList $config, PhutilArgumentParser $args) { $sources = array(); $cli_libraries = $args->getArg('library'); if ($cli_libraries) { $sources = array(); foreach ($cli_libraries as $cli_library) { $sources[] = array( 'type' => 'flag', 'library-source' => $cli_library, ); } } else { $items = $config->getStorageValueList('load'); foreach ($items as $item) { foreach ($item->getValue() as $library_path) { $sources[] = array( 'type' => 'config', 'config-source' => $item->getConfigurationSource(), 'library-source' => $library_path, ); } } } foreach ($sources as $spec) { $library_source = $spec['library-source']; switch ($spec['type']) { case 'flag': $description = pht('runtime --library flag'); break; case 'config': $config_source = $spec['config-source']; $description = pht( 'Configuration (%s)', $config_source->getSourceDisplayName()); break; } $this->loadLibrary($engine, $library_source, $description); } } private function loadLibrary( ArcanistConfigurationEngine $engine, $location, $description) { // TODO: This is a legacy system that should be replaced with package // management. $log = $this->getLogEngine(); $working_copy = $engine->getWorkingCopy(); if ($working_copy) { $working_copy_root = $working_copy->getPath(); $working_directory = $working_copy->getWorkingDirectory(); } else { $working_copy_root = null; $working_directory = getcwd(); } // 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. // // Note that absolute paths will just resolve absolutely through rule (1). $resolved = false; // Check inside the working copy. This also checks absolute paths, since // they'll resolve absolute and just ignore the project root. if ($working_copy_root !== null) { $resolved_location = Filesystem::resolvePath( $location, $working_copy_root); 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_root)); if (Filesystem::pathExists($resolved_location)) { $location = $resolved_location; $resolved = true; } } } // Look beside "arcanist/". This is rule (3) above. if (!$resolved) { $arcanist_root = phutil_get_library_root('arcanist'); $arcanist_root = dirname(dirname($arcanist_root)); $resolved_location = Filesystem::resolvePath( $location, $arcanist_root); if (Filesystem::pathExists($resolved_location)) { $location = $resolved_location; $resolved = true; } } $log->writeTrace( pht('LOAD'), pht('Loading library from "%s"...', $location)); $error = null; try { phutil_load_library($location); } catch (PhutilLibraryConflictException $ex) { if ($ex->getLibrary() != 'arcanist') { throw $ex; } // NOTE: If you are running `arc` against itself, we ignore the library // conflict created by loading the local `arc` library (in the current // working directory) and continue without loading it. // This means we only execute code in the `arcanist/` directory which is // associated with the binary you are running, whereas we would normally // execute local code. // This can make `arc` development slightly confusing if your setup is // especially bizarre, but it allows `arc` to be used in automation // workflows more easily. For some context, see PHI13. $executing_directory = dirname(dirname(__FILE__)); $log->writeWarn( pht('VERY META'), pht( 'You are running one copy of Arcanist (at path "%s") against '. 'another copy of Arcanist (at path "%s"). Code in the current '. 'working directory will not be loaded or executed.', $executing_directory, $working_directory)); } catch (PhutilBootloaderException $ex) { $log->writeError( pht('LIBRARY ERROR'), pht( 'Failed to load library at location "%s". This library '. 'is specified by "%s". Check that the library is up to date.', $location, $description)); $prompt = pht('Continue without loading library?'); if (!phutil_console_confirm($prompt)) { throw $ex; } } catch (Exception $ex) { $log->writeError( pht('LOAD ERROR'), pht( 'Failed to load library at location "%s". This library is '. 'specified by "%s". Check that the setting is correct and the '. 'library is located in the right place.', $location, $description)); $prompt = pht('Continue without loading library?'); if (!phutil_console_confirm($prompt)) { throw $ex; } } } private function newToolset(array $argv) { $binary = basename($argv[0]); $toolsets = ArcanistToolset::newToolsetMap(); if (!isset($toolsets[$binary])) { throw new PhutilArgumentUsageException( pht( 'Arcanist toolset "%s" is unknown. The Arcanist binary should '. 'be executed so that "argv[0]" identifies a supported toolset. '. 'Rename the binary or install the library that provides the '. 'desired toolset. Current available toolsets: %s.', $binary, implode(', ', array_keys($toolsets)))); } return $toolsets[$binary]; } private function newWorkflows(ArcanistToolset $toolset) { $workflows = id(new PhutilClassMapQuery()) ->setAncestorClass('ArcanistWorkflow') ->setContinueOnFailure(true) ->execute(); foreach ($workflows as $key => $workflow) { if (!$workflow->supportsToolset($toolset)) { unset($workflows[$key]); } } $map = array(); foreach ($workflows as $workflow) { $key = $workflow->getWorkflowName(); if (isset($map[$key])) { throw new Exception( pht( 'Two workflows ("%s" and "%s") both have the same name ("%s") '. 'and both support the current toolset ("%s", "%s"). Each '. 'workflow in a given toolset must have a unique name.', get_class($workflow), get_class($map[$key]), get_class($toolset), $toolset->getToolsetKey())); } $map[$key] = id(clone $workflow) ->setToolset($toolset); } return $map; } public function getWorkflows() { return $this->workflows; } public function getLogEngine() { return $this->logEngine; } private function applyAliasEffects(array $effects, array $argv) { assert_instances_of($effects, 'ArcanistAliasEffect'); $log = $this->getLogEngine(); $command = null; $arguments = null; foreach ($effects as $effect) { $message = $effect->getMessage(); if ($message !== null) { $log->writeHint(pht('ALIAS'), $message); } if ($effect->getCommand()) { $command = $effect->getCommand(); $arguments = $effect->getArguments(); } } if ($command !== null) { $argv = array_merge(array($command), $arguments); } return $argv; } private function installSignalHandler() { $log = $this->getLogEngine(); if (!function_exists('pcntl_signal')) { $log->writeTrace( pht('PCNTL'), pht( 'Unable to install signal handler, pcntl_signal() unavailable. '. 'Continuing without signal handling.')); return; } // NOTE: SIGHUP, SIGTERM and SIGWINCH are handled by "PhutilSignalRouter". // This logic is largely similar to the logic there, but more specific to // Arcanist workflows. pcntl_signal(SIGINT, array($this, 'routeSignal')); } public function routeSignal($signo) { switch ($signo) { case SIGINT: $this->routeInterruptSignal($signo); break; } } private function routeInterruptSignal($signo) { $log = $this->getLogEngine(); $last_interrupt = $this->lastInterruptTime; $now = microtime(true); $this->lastInterruptTime = $now; $should_exit = false; // If we received another SIGINT recently, always exit. This implements // "press ^C twice in quick succession to exit" regardless of what the // workflow may decide to do. $interval = 2; if ($last_interrupt !== null) { if ($now - $last_interrupt < $interval) { $should_exit = true; } } $handler = null; if (!$should_exit) { // Look for an interrupt handler in the current workflow stack. $stack = $this->getWorkflowStack(); foreach ($stack as $workflow) { if ($workflow->canHandleSignal($signo)) { $handler = $workflow; break; } } // If no workflow in the current execution stack can handle an interrupt // signal, just exit on the first interrupt. if (!$handler) { $should_exit = true; } } // It's common for users to ^C on prompts. Write a newline before writing // a response to the interrupt so the behavior is a little cleaner. This // also avoids lines that read "^C [ INTERRUPT ] ...". $log->writeNewline(); if ($should_exit) { $log->writeHint( pht('INTERRUPT'), pht('Interrupted by SIGINT (^C).')); exit(128 + $signo); } $log->writeHint( pht('INTERRUPT'), pht('Press ^C again to exit.')); $handler->handleSignal($signo); } public function pushWorkflow(ArcanistWorkflow $workflow) { $this->stack[] = $workflow; return $this; } public function popWorkflow() { if (!$this->stack) { throw new Exception(pht('Trying to pop an empty workflow stack!')); } return array_pop($this->stack); } public function getWorkflowStack() { return $this->stack; } public function getCurrentWorkflow() { return last($this->stack); } private function newConduitEngine( ArcanistConfigurationSourceList $config, PhutilArgumentParser $args) { try { $force_uri = $args->getArg('conduit-uri'); } catch (PhutilArgumentSpecificationException $ex) { $force_uri = null; } try { $force_token = $args->getArg('conduit-token'); } catch (PhutilArgumentSpecificationException $ex) { $force_token = null; } if ($force_uri !== null) { $conduit_uri = $force_uri; } else { $conduit_uri = $config->getConfig('phabricator.uri'); if ($conduit_uri === null) { // For now, read this older config from raw storage. There is currently // no definition of this option in the "toolsets" config list, and it // would be nice to get rid of it. $default_list = $config->getStorageValueList('default'); if ($default_list) { $conduit_uri = last($default_list)->getValue(); } } } 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 = phutil_string_cast($conduit_uri); } $engine = new ArcanistConduitEngine(); if ($conduit_uri !== null) { $engine->setConduitURI($conduit_uri); } // TODO: This isn't using "getConfig()" because we aren't defining a // real config entry for the moment. if ($force_token !== null) { $conduit_token = $force_token; } else { $hosts = array(); $hosts_list = $config->getStorageValueList('hosts'); foreach ($hosts_list as $hosts_config) { $hosts += $hosts_config->getValue(); } $host_config = idx($hosts, $conduit_uri, array()); $conduit_token = idx($host_config, 'token'); } if ($conduit_token !== null) { $engine->setConduitToken($conduit_token); } return $engine; } private function executeShellAlias(ArcanistAliasEffect $effect) { $log = $this->getLogEngine(); $message = $effect->getMessage(); if ($message !== null) { $log->writeHint(pht('SHELL ALIAS'), $message); } return phutil_passthru('%Ls', $effect->getArguments()); } public function getSymbolEngine() { if ($this->symbolEngine === null) { $this->symbolEngine = $this->newSymbolEngine(); } return $this->symbolEngine; } private function newSymbolEngine() { return id(new ArcanistSymbolEngine()) ->setWorkflow($this); } public function getHardpointEngine() { if ($this->hardpointEngine === null) { $this->hardpointEngine = $this->newHardpointEngine(); } return $this->hardpointEngine; } private function newHardpointEngine() { $engine = new ArcanistHardpointEngine(); $queries = ArcanistRuntimeHardpointQuery::getAllQueries(); foreach ($queries as $key => $query) { $queries[$key] = id(clone $query) ->setRuntime($this); } $engine->setQueries($queries); return $engine; } public function getViewer() { if (!$this->viewer) { $viewer = $this->getSymbolEngine() ->loadUserForSymbol('viewer()'); // TODO: Deal with anonymous stuff. if (!$viewer) { throw new Exception(pht('No viewer!')); } $this->viewer = $viewer; } return $this->viewer; } public function loadHardpoints($objects, $requests) { if (!is_array($objects)) { $objects = array($objects); } if (!is_array($requests)) { $requests = array($requests); } $engine = $this->getHardpointEngine(); $requests = $engine->requestHardpoints( $objects, $requests); // TODO: Wait for only the required requests. $engine->waitForRequests(array()); } public function getWorkingCopy() { return $this->workingCopy; } public function getConduitEngine() { return $this->conduitEngine; } public function setToolset($toolset) { $this->toolset = $toolset; return $this; } public function getToolset() { return $this->toolset; } private function getHelpWorkflows(array $workflows) { if ($this->getToolset()->getToolsetKey() === 'arc') { $legacy = array(); $legacy[] = new ArcanistCloseRevisionWorkflow(); $legacy[] = new ArcanistCommitWorkflow(); $legacy[] = new ArcanistCoverWorkflow(); $legacy[] = new ArcanistDiffWorkflow(); $legacy[] = new ArcanistExportWorkflow(); $legacy[] = new ArcanistGetConfigWorkflow(); $legacy[] = new ArcanistSetConfigWorkflow(); $legacy[] = new ArcanistInstallCertificateWorkflow(); - $legacy[] = new ArcanistLandWorkflow(); $legacy[] = new ArcanistLintersWorkflow(); $legacy[] = new ArcanistLintWorkflow(); $legacy[] = new ArcanistListWorkflow(); $legacy[] = new ArcanistPatchWorkflow(); $legacy[] = new ArcanistPasteWorkflow(); $legacy[] = new ArcanistTasksWorkflow(); $legacy[] = new ArcanistTodoWorkflow(); $legacy[] = new ArcanistUnitWorkflow(); $legacy[] = new ArcanistWhichWorkflow(); foreach ($legacy as $workflow) { // If this workflow has been updated but not removed from the list // above yet, just skip it. if ($workflow instanceof ArcanistArcWorkflow) { continue; } $workflows[] = $workflow->newLegacyPhutilWorkflow(); } } return $workflows; } } diff --git a/src/toolset/ArcanistArcToolset.php b/src/toolset/ArcanistArcToolset.php index fb3ceff4..5c1cb377 100644 --- a/src/toolset/ArcanistArcToolset.php +++ b/src/toolset/ArcanistArcToolset.php @@ -1,26 +1,22 @@ 'conduit-uri', 'param' => 'uri', 'help' => pht('Connect to Phabricator install specified by __uri__.'), ), array( 'name' => 'conduit-token', 'param' => 'token', 'help' => pht('Use a specific authentication token.'), ), - array( - 'name' => 'anonymous', - 'help' => pht('Run workflow as a public user, without authenticating.'), - ), ); } } diff --git a/src/toolset/ArcanistPrompt.php b/src/toolset/ArcanistPrompt.php index 8cd4a912..caff32c6 100644 --- a/src/toolset/ArcanistPrompt.php +++ b/src/toolset/ArcanistPrompt.php @@ -1,151 +1,320 @@ key = $key; return $this; } public function getKey() { return $this->key; } public function setWorkflow(ArcanistWorkflow $workflow) { $this->workflow = $workflow; return $this; } public function getWorkflow() { return $this->workflow; } public function setDescription($description) { $this->description = $description; return $this; } public function getDescription() { return $this->description; } public function setQuery($query) { $this->query = $query; return $this; } public function getQuery() { return $this->query; } public function execute() { $workflow = $this->getWorkflow(); if ($workflow) { $workflow_ok = $workflow->hasPrompt($this->getKey()); } else { $workflow_ok = false; } if (!$workflow_ok) { throw new Exception( pht( 'Prompt ("%s") is executing, but it is not properly bound to the '. 'invoking workflow. You may have called "newPrompt()" to execute a '. 'prompt instead of "getPrompt()". Use "newPrompt()" when defining '. 'prompts and "getPrompt()" when executing them.', $this->getKey())); } $query = $this->getQuery(); if (!strlen($query)) { throw new Exception( pht( 'Prompt ("%s") has no query text!', $this->getKey())); } - $options = '[y/N]'; - $default = 'N'; + $options = '[y/N/?]'; + $default = 'n'; + + $saved_response = $this->getSavedResponse(); try { phutil_console_require_tty(); } catch (PhutilConsoleStdinNotInteractiveException $ex) { // TOOLSETS: Clean this up to provide more details to the user about how // they can configure prompts to be answered. // Throw after echoing the prompt so the user has some idea what happened. echo $query."\n"; throw $ex; } - // NOTE: We're making stdin nonblocking so that we can respond to signals - // immediately. If we don't, and you ^C during a prompt, the program does - // not handle the signal until fgets() returns. - $stdin = fopen('php://stdin', 'r'); if (!$stdin) { throw new Exception(pht('Failed to open stdin for reading.')); } - $ok = stream_set_blocking($stdin, false); - if (!$ok) { - throw new Exception(pht('Unable to set stdin nonblocking.')); + // NOTE: We're making stdin nonblocking so that we can respond to signals + // immediately. If we don't, and you ^C during a prompt, the program does + // not handle the signal until fgets() returns. + + // On Windows, we skip this because stdin can not be made nonblocking. + + if (!phutil_is_windows()) { + $ok = stream_set_blocking($stdin, false); + if (!$ok) { + throw new Exception(pht('Unable to set stdin nonblocking.')); + } } echo "\n"; $result = null; + $is_saved = false; while (true) { - echo tsprintf( - '** %s ** %s %s ', - '>>>', - $query, - $options); + if ($saved_response !== null) { + $is_saved = true; + + $response = $saved_response; + $saved_response = null; + } else { + echo tsprintf( + '** %s ** %s %s ', + '>>>', + $query, + $options); + + $is_saved = false; + + if (phutil_is_windows()) { + $response = fgets($stdin); + } else { + while (true) { + $read = array($stdin); + $write = array(); + $except = array(); + + $ok = @stream_select($read, $write, $except, 1); + if ($ok === false) { + // NOTE: We may be interrupted by a system call, particularly if + // the window is resized while a prompt is shown and the terminal + // sends SIGWINCH. - while (true) { - $read = array($stdin); - $write = array(); - $except = array(); + // If we are, just continue below and try to read from stdin. If + // we were interrupted, we should read nothing and continue + // normally. If the pipe is broken, the read should fail. + } - $ok = stream_select($read, $write, $except, 1); - if ($ok === false) { - throw new Exception(pht('stream_select() failed!')); + $response = ''; + while (true) { + $bytes = fread($stdin, 8192); + if ($bytes === false) { + throw new Exception( + pht('fread() from stdin failed with an error.')); + } + + if (!strlen($bytes)) { + break; + } + + $response .= $bytes; + } + + if (!strlen($response)) { + continue; + } + + break; + } } - $response = fgets($stdin); + $response = trim($response); if (!strlen($response)) { - continue; + $response = $default; } - - break; } - $response = trim($response); - if (!strlen($response)) { - $response = $default; + $save_scope = null; + if (!$is_saved) { + $matches = null; + if (preg_match('(^(.*)([!*])\z)', $response, $matches)) { + $response = $matches[1]; + + if ($matches[2] === '*') { + $save_scope = ArcanistConfigurationSource::SCOPE_USER; + } else { + $save_scope = ArcanistConfigurationSource::SCOPE_WORKING_COPY; + } + } } if (phutil_utf8_strtolower($response) == 'y') { $result = true; break; } if (phutil_utf8_strtolower($response) == 'n') { $result = false; break; } + + if (phutil_utf8_strtolower($response) == '?') { + echo tsprintf( + "\n** %s ** **%s**\n\n", + pht('PROMPT'), + $this->getKey()); + + echo tsprintf( + "%s\n", + $this->getDescription()); + + echo tsprintf("\n"); + + echo tsprintf( + "%s\n", + pht( + 'The default response to this prompt is "%s".', + $default)); + + echo tsprintf("\n"); + + echo tsprintf( + "%?\n", + pht( + 'Use "*" after a response to save it in user configuration.')); + + echo tsprintf( + "%?\n", + pht( + 'Use "!" after a response to save it in working copy '. + 'configuration.')); + + echo tsprintf( + "%?\n", + pht( + 'Run "arc help prompts" for detailed help on configuring '. + 'responses.')); + + echo tsprintf("\n"); + + continue; + } + } + + if ($save_scope !== null) { + $this->saveResponse($save_scope, $response); + } + + if ($is_saved) { + echo tsprintf( + "** %s ** %s **<%s>**\n". + "** %s ** (%s)\n\n", + '>>>', + $query, + $response, + '>>>', + pht( + 'Using saved response to prompt "%s".', + $this->getKey())); } if (!$result) { throw new ArcanistUserAbortException(); } + } + + private function getSavedResponse() { + $config_key = ArcanistArcConfigurationEngineExtension::KEY_PROMPTS; + $workflow = $this->getWorkflow(); + + $config = $workflow->getConfig($config_key); + + $prompt_key = $this->getKey(); + + $prompt_response = null; + foreach ($config as $response) { + if ($response->getPrompt() === $prompt_key) { + $prompt_response = $response; + } + } + + if ($prompt_response === null) { + return null; + } + + return $prompt_response->getResponse(); + } + + private function saveResponse($scope, $response_value) { + $config_key = ArcanistArcConfigurationEngineExtension::KEY_PROMPTS; + $workflow = $this->getWorkflow(); + + echo tsprintf( + "** %s ** %s\n", + pht('SAVE PROMPT'), + pht( + 'Saving response "%s" to prompt "%s".', + $response_value, + $this->getKey())); + + $source_list = $workflow->getConfigurationSourceList(); + $source = $source_list->getWritableSourceFromScope($scope); + + $response_list = $source_list->getConfigFromScopes( + $config_key, + array($scope)); + + foreach ($response_list as $key => $response) { + if ($response->getPrompt() === $this->getKey()) { + unset($response_list[$key]); + } + } + + if ($response_value !== null) { + $response_list[] = id(new ArcanistPromptResponse()) + ->setPrompt($this->getKey()) + ->setResponse($response_value); + } + $option = $source_list->getConfigOption($config_key); + $option->writeValue($source, $response_list); } } diff --git a/src/toolset/ArcanistPromptResponse.php b/src/toolset/ArcanistPromptResponse.php new file mode 100644 index 00000000..b2ab9007 --- /dev/null +++ b/src/toolset/ArcanistPromptResponse.php @@ -0,0 +1,59 @@ + 'string', + 'response' => 'string', + )); + + return id(new self()) + ->setPrompt($map['prompt']) + ->setResponse($map['response']); + } + + public function getStorageDictionary() { + return array( + 'prompt' => $this->getPrompt(), + 'response' => $this->getResponse(), + ); + } + + public function setPrompt($prompt) { + $this->prompt = $prompt; + return $this; + } + + public function getPrompt() { + return $this->prompt; + } + + public function setResponse($response) { + $this->response = $response; + return $this; + } + + public function getResponse() { + return $this->response; + } + + public function setConfigurationSource( + ArcanistConfigurationSource $configuration_source) { + $this->configurationSource = $configuration_source; + return $this; + } + + public function getConfigurationSource() { + return $this->configurationSource; + } + +} diff --git a/src/toolset/ArcanistWorkflowArgument.php b/src/toolset/ArcanistWorkflowArgument.php index 9a936b33..26d71ece 100644 --- a/src/toolset/ArcanistWorkflowArgument.php +++ b/src/toolset/ArcanistWorkflowArgument.php @@ -1,79 +1,137 @@ key = $key; return $this; } public function getKey() { return $this->key; } public function setWildcard($wildcard) { $this->wildcard = $wildcard; return $this; } public function getWildcard() { return $this->wildcard; } + public function setShortFlag($short_flag) { + $this->shortFlag = $short_flag; + return $this; + } + + public function getShortFlag() { + return $this->shortFlag; + } + + public function setRepeatable($repeatable) { + $this->repeatable = $repeatable; + return $this; + } + + public function getRepeatable() { + return $this->repeatable; + } + + public function addRelatedConfig($related_config) { + $this->relatedConfig[] = $related_config; + return $this; + } + + public function getRelatedConfig() { + return $this->relatedConfig; + } + public function getPhutilSpecification() { $spec = array( 'name' => $this->getKey(), ); if ($this->getWildcard()) { $spec['wildcard'] = true; } $parameter = $this->getParameter(); if ($parameter !== null) { $spec['param'] = $parameter; } $help = $this->getHelp(); if ($help !== null) { + $config = $this->getRelatedConfig(); + + if ($config) { + $more = array(); + foreach ($this->getRelatedConfig() as $config) { + $more[] = tsprintf( + '%s **%s**', + pht('Related configuration:'), + $config); + } + $more = phutil_glue($more, "\n"); + $help = tsprintf("%B\n\n%B", $help, $more); + } + $spec['help'] = $help; } + $short = $this->getShortFlag(); + if ($short !== null) { + $spec['short'] = $short; + } + + $repeatable = $this->getRepeatable(); + if ($repeatable !== null) { + $spec['repeat'] = $repeatable; + } + return $spec; } public function setHelp($help) { + if (is_array($help)) { + $help = implode("\n\n", $help); + } + $this->help = $help; return $this; } public function getHelp() { return $this->help; } public function setParameter($parameter) { $this->parameter = $parameter; return $this; } public function getParameter() { return $this->parameter; } public function setIsPathArgument($is_path_argument) { $this->isPathArgument = $is_path_argument; return $this; } public function getIsPathArgument() { return $this->isPathArgument; } } diff --git a/src/toolset/command/ArcanistCommand.php b/src/toolset/command/ArcanistCommand.php index 0eff69f9..3a7c5338 100644 --- a/src/toolset/command/ArcanistCommand.php +++ b/src/toolset/command/ArcanistCommand.php @@ -1,59 +1,89 @@ executableFuture = $future; return $this; } public function getExecutableFuture() { return $this->executableFuture; } public function setLogEngine(ArcanistLogEngine $log_engine) { $this->logEngine = $log_engine; return $this; } public function getLogEngine() { return $this->logEngine; } + public function setResolveOnError($resolve_on_error) { + $this->resolveOnError = $resolve_on_error; + return $this; + } + + public function getResolveOnError() { + return $this->resolveOnError; + } + + public function setDisplayCommand($pattern /* , ... */) { + $argv = func_get_args(); + $command = call_user_func_array('csprintf', $argv); + $this->displayCommand = $command; + return $this; + } + + public function getDisplayCommand() { + return $this->displayCommand; + } + public function execute() { $log = $this->getLogEngine(); $future = $this->getExecutableFuture(); - $command = $future->getCommand(); + + $display_command = $this->getDisplayCommand(); + if ($display_command !== null) { + $command = $display_command; + } else { + $command = $future->getCommand(); + } $log->writeNewline(); $log->writeStatus( ' $ ', tsprintf('**%s**', phutil_string_cast($command))); $log->writeNewline(); $err = $future->resolve(); $log->writeNewline(); - if ($err) { + if ($err && !$this->getResolveOnError()) { $log->writeError( pht('ERROR'), pht( 'Command exited with error code %d.', $err)); throw new CommandException( pht('Command exited with nonzero error code.'), $command, $err, '', ''); } + + return $err; } } diff --git a/src/toolset/query/ArcanistRuntimeHardpointQuery.php b/src/toolset/query/ArcanistRuntimeHardpointQuery.php index aec1cd32..ed824da2 100644 --- a/src/toolset/query/ArcanistRuntimeHardpointQuery.php +++ b/src/toolset/query/ArcanistRuntimeHardpointQuery.php @@ -1,118 +1,117 @@ runtime = $runtime; return $this; } final public function getRuntime() { return $this->runtime; } final public function getWorkingCopy() { return $this->getRuntime()->getWorkingCopy(); } final public function getRepositoryAPI() { return $this->getWorkingCopy()->getRepositoryAPI(); } public static function getAllQueries() { return id(new PhutilClassMapQuery()) ->setAncestorClass(__CLASS__) ->execute(); } final public function canLoadObject(ArcanistHardpointObject $object) { if ($this->canLoadHardpoint === null) { $this->canLoadHardpoint = $this->canLoadHardpoint(); } if (!$this->canLoadHardpoint) { return false; } if (!$object instanceof ArcanistRef) { return false; } return $this->canLoadRef($object); } protected function canLoadHardpoint() { return true; } abstract protected function canLoadRef(ArcanistRef $ref); final public function newConduitSearch( $method, $constraints, $attachments = array()) { $conduit_engine = $this->getRuntime() ->getConduitEngine(); $conduit_future = id(new ConduitSearchFuture()) ->setConduitEngine($conduit_engine) ->setMethod($method) ->setConstraints($constraints) ->setAttachments($attachments); return $conduit_future; } final public function yieldConduitSearch($method, $constraints) { $conduit_future = $this->newConduitSearch($method, $constraints); return $this->yieldFuture($conduit_future); } final public function newConduit($method, $parameters) { $conduit_engine = $this->getRuntime() ->getConduitEngine(); - $call_object = $conduit_engine->newCall($method, $parameters); - $call_future = $conduit_engine->newFuture($call_object); + $call_future = $conduit_engine->newFuture($method, $parameters); return $call_future; } final public function yieldConduit($method, array $parameters) { $call_future = $this->newConduit($method, $parameters); return $this->yieldFuture($call_future); } final public function yieldRepositoryRef() { // TODO: This should probably move to Runtime. $runtime = $this->getRuntime(); $workflow = $runtime->getCurrentWorkflow(); // TODO: This is currently a blocking request, but should yield to the // hardpoint engine in the future. $repository_ref = $workflow->getRepositoryRef(); $ref_future = new ImmediateFuture($repository_ref); return $this->yieldFuture($ref_future); } final public function yieldValue(array $refs, $value) { assert_instances_of($refs, 'ArcanistRef'); $keys = array_keys($refs); $map = array_fill_keys($keys, $value); return $this->yieldMap($map); } final public function yieldMap(array $map) { return new ArcanistHardpointTaskResult($map); } } diff --git a/src/toolset/workflow/ArcanistHelpWorkflow.php b/src/toolset/workflow/ArcanistHelpWorkflow.php index e7ac38fc..69b1df2e 100644 --- a/src/toolset/workflow/ArcanistHelpWorkflow.php +++ b/src/toolset/workflow/ArcanistHelpWorkflow.php @@ -1,18 +1,19 @@ setWorkflow($this); } public function supportsToolset(ArcanistToolset $toolset) { return true; } } diff --git a/src/toolset/workflow/ArcanistPromptsWorkflow.php b/src/toolset/workflow/ArcanistPromptsWorkflow.php index 104bbf8a..1ec50d23 100644 --- a/src/toolset/workflow/ArcanistPromptsWorkflow.php +++ b/src/toolset/workflow/ArcanistPromptsWorkflow.php @@ -1,80 +1,136 @@ + $ arc prompts __workflow__ + +**Saving Responses** + +If you always want to answer a particular prompt in a certain way, you can +save your response to the prompt. When you encounter the prompt again, your +saved response will be used automatically. + +To save a response, add "*" or "!" to the end of the response you want to save +when you answer the prompt: + + - Using "*" will save the response in user configuration. In the future, + the saved answer will be used any time you encounter the prompt (in any + project). + - Using "!" will save the response in working copy configuration. In the + future, the saved answer will be used when you encounter the prompt in + the current working copy. + +For example, if you would like to always answer "y" to a particular prompt, +respond with "y*" or "y!" to save your response. + EOTEXT ); return $this->newWorkflowInformation() ->addExample(pht('**prompts** __workflow__')) ->setHelp($help); } public function getWorkflowArguments() { return array( $this->newWorkflowArgument('argv') ->setWildcard(true), ); } public function runWorkflow() { $argv = $this->getArgument('argv'); if (!$argv) { throw new PhutilArgumentUsageException( pht('Provide a workflow to list prompts for.')); } $runtime = $this->getRuntime(); $workflows = $runtime->getWorkflows(); $workflow_key = array_shift($argv); $workflow = idx($workflows, $workflow_key); if (!$workflow) { throw new PhutilArgumentUsageException( pht( 'Workflow "%s" is unknown. Supported workflows are: %s.', $workflow_key, implode(', ', array_keys($workflows)))); } $prompts = $workflow->getPromptMap(); if (!$prompts) { echo tsprintf( "%s\n", pht('This workflow does not have any prompts.')); return 0; } + $prompts = msort($prompts, 'getKey'); + + $blocks = array(); foreach ($prompts as $prompt) { - echo tsprintf( - "**%s**\n", + $block = array(); + $block[] = tsprintf( + "** %s ** **%s**\n\n", + pht('PROMPT'), $prompt->getKey()); - echo tsprintf( - "%s\n", + $block[] = tsprintf( + "%W\n", $prompt->getDescription()); + + $responses = $this->getSavedResponses($prompt->getKey()); + if ($responses) { + $block[] = tsprintf("\n"); + foreach ($responses as $response) { + $block[] = tsprintf( + " ** > ** %s\n", + pht( + 'You have saved the response "%s" to this prompt.', + $response->getResponse())); + } + } + + $blocks[] = $block; } + echo tsprintf('%B', phutil_glue($blocks, tsprintf("\n"))); + return 0; } + private function getSavedResponses($prompt_key) { + $config_key = ArcanistArcConfigurationEngineExtension::KEY_PROMPTS; + $config = $this->getConfig($config_key); + + $responses = array(); + foreach ($config as $response) { + if ($response->getPrompt() === $prompt_key) { + $responses[] = $response; + } + } + + return $responses; + } + } diff --git a/src/toolset/workflow/ArcanistShellCompleteWorkflow.php b/src/toolset/workflow/ArcanistShellCompleteWorkflow.php index 4fc1fb0a..f68d04a5 100644 --- a/src/toolset/workflow/ArcanistShellCompleteWorkflow.php +++ b/src/toolset/workflow/ArcanistShellCompleteWorkflow.php @@ -1,660 +1,666 @@ ...your shell should automatically expand the flag to: $ arc diff --draft **Updating Completion** To update shell completion, run the same command: $ arc shell-complete You can update shell completion without reinstalling it by running: $ arc shell-complete --generate You may need to update shell completion if: - you install new Arcanist toolsets; or - you move the Arcanist directory; or - you upgrade Arcanist and the new version fixes shell completion bugs. EOTEXT ); return $this->newWorkflowInformation() ->setSynopsis(pht('Install shell completion.')) ->setHelp($help); } public function getWorkflowArguments() { return array( $this->newWorkflowArgument('current') ->setParameter('cursor-position') ->setHelp( pht( 'Internal. Current term in the argument list being completed.')), $this->newWorkflowArgument('generate') ->setHelp( pht( 'Regenerate shell completion rules, without installing any '. 'configuration.')), $this->newWorkflowArgument('shell') ->setParameter('shell-name') ->setHelp( pht( 'Install completion support for a particular shell.')), $this->newWorkflowArgument('argv') ->setWildcard(true), ); } public function runWorkflow() { $log = $this->getLogEngine(); $argv = $this->getArgument('argv'); $is_generate = $this->getArgument('generate'); $is_shell = (bool)strlen($this->getArgument('shell')); $is_current = $this->getArgument('current'); if ($argv) { $should_install = false; $should_generate = false; if ($is_generate) { throw new PhutilArgumentUsageException( pht( 'You can not use "--generate" when completing arguments.')); } if ($is_shell) { throw new PhutilArgumentUsageException( pht( 'You can not use "--shell" when completing arguments.')); } } else if ($is_generate) { $should_install = false; $should_generate = true; if ($is_current) { throw new PhutilArgumentUsageException( pht( 'You can not use "--current" when generating rules.')); } if ($is_shell) { throw new PhutilArgumentUsageException( pht( 'The flags "--generate" and "--shell" are mutually exclusive. '. 'The "--shell" flag selects which shell to install support for, '. 'but the "--generate" suppresses installation.')); } } else { $should_install = true; $should_generate = true; if ($is_current) { throw new PhutilArgumentUsageException( pht( 'You can not use "--current" when installing support.')); } } if ($should_install) { $this->runInstall(); } if ($should_generate) { $this->runGenerate(); } if ($should_install || $should_generate) { $log->writeHint( pht('NOTE'), pht( 'You may need to open a new terminal window or launch a new shell '. 'before the changes take effect.')); return 0; } $this->runAutocomplete(); } protected function newPrompts() { return array( $this->newPrompt('arc.shell-complete.install') ->setDescription( pht( 'Confirms writing to to "~/.profile" (or another similar file) '. 'to install shell completion.')), ); } private function runInstall() { $log = $this->getLogEngine(); $shells = array( array( 'key' => 'bash', 'path' => '/bin/bash', 'file' => '.profile', 'source' => 'hooks/bash-completion.sh', ), ); $shells = ipull($shells, null, 'key'); $shell = $this->getArgument('shell'); if (!$shell) { $shell = $this->detectShell($shells); } else { $shell = $this->selectShell($shells, $shell); } $spec = $shells[$shell]; $file = $spec['file']; $home = getenv('HOME'); if (!strlen($home)) { throw new PhutilArgumentUsageException( pht( 'The "HOME" environment variable is not defined, so this workflow '. 'can not identify where to install shell completion.')); } $file_path = getenv('HOME').'/'.$file; $file_display = '~/'.$file; if (Filesystem::pathExists($file_path)) { $file_path = Filesystem::resolvePath($file_path); $data = Filesystem::readFile($file_path); $is_new = false; } else { $data = ''; $is_new = true; } $line = csprintf( 'source %R # arcanist-shell-complete', $this->getShellPath($spec['source'])); $matches = null; $replace = preg_match( '/(\s*\n)?[^\n]+# arcanist-shell-complete\s*(\n\s*)?/', $data, $matches, PREG_OFFSET_CAPTURE); $log->writeSuccess( pht('INSTALL'), pht( 'Installing shell completion support for "%s" into "%s".', $shell, $file_display)); if ($replace) { $replace_pos = $matches[0][1]; $replace_line = $matches[0][0]; $replace_len = strlen($replace_line); $replace_display = trim($replace_line); if ($replace_pos === 0) { $new_line = $line."\n"; } else { $new_line = "\n\n".$line."\n"; } $new_data = substr_replace($data, $new_line, $replace_pos, $replace_len); if ($new_data === $data) { // If we aren't changing anything in the file, just skip the write // completely. $needs_write = false; $log->writeStatus( pht('SKIP'), pht('Shell completion for "%s" is already installed.', $shell)); return; } echo tsprintf( "%s\n\n %s\n\n%s\n\n %s\n", pht( 'To update shell completion support for "%s", your existing '. '"%s" file will be modified. This line will be removed:', $shell, $file_display), $replace_display, pht('This line will be added:'), $line); $prompt = pht('Rewrite this file?'); } else { if ($is_new) { $new_data = $line."\n"; echo tsprintf( "%s\n\n %s\n", pht( 'To install shell completion support for "%s", a new "%s" file '. 'will be created with this content:', $shell, $file_display), $line); $prompt = pht('Create this file?'); } else { $new_data = rtrim($data)."\n\n".$line."\n"; echo tsprintf( "%s\n\n %s\n", pht( 'To install shell completion support for "%s", this line will be '. 'added to your existing "%s" file:', $shell, $file_display), $line); $prompt = pht('Append to this file?'); } } $this->getPrompt('arc.shell-complete.install') ->setQuery($prompt) ->execute(); Filesystem::writeFile($file_path, $new_data); $log->writeSuccess( pht('INSTALLED'), pht( 'Installed shell completion support for "%s" to "%s".', $shell, $file_display)); } private function selectShell(array $shells, $shell_arg) { foreach ($shells as $shell) { if ($shell['key'] === $shell_arg) { return $shell_arg; } } throw new PhutilArgumentUsageException( pht( 'The shell "%s" is not supported. Supported shells are: %s.', $shell_arg, implode(', ', ipull($shells, 'key')))); } private function detectShell(array $shells) { // NOTE: The "BASH_VERSION" and "ZSH_VERSION" shell variables are not // passed to subprocesses, so we can't inspect them to figure out which // shell launched us. If we could figure this out in some other way, it // would be nice to do so. // Instead, just look at "SHELL" (the user's startup shell). $log = $this->getLogEngine(); $detected = array(); $log->writeStatus( pht('DETECT'), pht('Detecting current shell...')); $shell_env = getenv('SHELL'); if (!strlen($shell_env)) { $log->writeWarning( pht('SHELL'), pht( 'The "SHELL" environment variable is not defined, so it can '. 'not be used to detect the shell to install rules for.')); } else { $found = false; foreach ($shells as $shell) { if ($shell['path'] !== $shell_env) { continue; } $found = true; $detected[] = $shell['key']; $log->writeSuccess( pht('SHELL'), pht( 'The "SHELL" environment variable has value "%s", so the '. 'target shell was detected as "%s".', $shell_env, $shell['key'])); } if (!$found) { $log->writeStatus( pht('SHELL'), pht( 'The "SHELL" environment variable does not match any recognized '. 'shell.')); } } if (!$detected) { throw new PhutilArgumentUsageException( pht( 'Unable to detect any supported shell, so autocompletion rules '. 'can not be installed. Use "--shell" to select a shell.')); } else if (count($detected) > 1) { throw new PhutilArgumentUsageException( pht( 'Multiple supported shells were detected. Unable to determine '. 'which shell to install autocompletion rules for. Use "--shell" '. 'to select a shell.')); } return head($detected); } private function runGenerate() { $log = $this->getLogEngine(); $toolsets = ArcanistToolset::newToolsetMap(); $log->writeStatus( pht('GENERATE'), pht('Generating shell completion rules...')); $shells = array('bash'); foreach ($shells as $shell) { $rules = array(); foreach ($toolsets as $toolset) { $rules[] = $this->newCompletionRules($toolset, $shell); } $rules = implode("\n", $rules); $rules_path = $this->getShellPath('rules/'.$shell.'-rules.sh'); // If a write wouldn't change anything, skip the write. This allows // "arc shell-complete" to work if "arcanist/" is on a read-only NFS // filesystem or something unusual like that. $skip_write = false; if (Filesystem::pathExists($rules_path)) { $current = Filesystem::readFile($rules_path); if ($current === $rules) { $skip_write = true; } } if ($skip_write) { $log->writeStatus( pht('SKIP'), pht( 'Rules are already up to date for "%s" in: %s', $shell, Filesystem::readablePath($rules_path))); } else { Filesystem::writeFile($rules_path, $rules); $log->writeStatus( pht('RULES'), pht( 'Wrote updated completion rules for "%s" to: %s.', $shell, Filesystem::readablePath($rules_path))); } } } private function newCompletionRules(ArcanistToolset $toolset, $shell) { $template_path = $this->getShellPath('templates/'.$shell.'-template.sh'); $template = Filesystem::readFile($template_path); $variables = array( 'BIN' => $toolset->getToolsetKey(), ); foreach ($variables as $key => $value) { $template = str_replace('{{{'.$key.'}}}', $value, $template); } return $template; } private function getShellPath($to_file = null) { $arc_root = dirname(phutil_get_library_root('arcanist')); return $arc_root.'/support/shell/'.$to_file; } private function runAutocomplete() { $argv = $this->getArgument('argv'); $argc = count($argv); $pos = $this->getArgument('current'); if (!$pos) { $pos = $argc - 1; } if ($pos >= $argc) { throw new ArcanistUsageException( pht( 'Argument position specified with "--current" ("%s") is greater '. 'than the number of arguments provided ("%s").', new PhutilNumber($pos), new PhutilNumber($argc))); } $workflows = $this->getRuntime()->getWorkflows(); // NOTE: This isn't quite right. For example, "arc --con" will // try to autocomplete workflows named "--con", but it should actually // autocomplete global flags and suggest "--config". $is_workflow = ($pos <= 1); if ($is_workflow) { // NOTE: There was previously some logic to try to filter impossible // workflows out of the completion list based on the VCS in the current // working directory: for example, if you're in an SVN working directory, // "arc a" is unlikely to complete to "arc amend" because "amend" does // not support SVN. It's not clear this logic is valuable, but we could // consider restoring it if good use cases arise. // TOOLSETS: Restore the ability for workflows to opt out of shell // completion. It is exceptionally unlikely that users want to shell // complete obscure or internal workflows, like "arc shell-complete" // itself. Perhaps a good behavior would be to offer these as // completions if they are the ONLY available completion, since a user // who has typed "arc shell-comp" likely does want "shell-complete". $complete = array(); foreach ($workflows as $workflow) { $complete[] = $workflow->getWorkflowName(); } foreach ($this->getConfig('aliases') as $alias) { if ($alias->getException()) { continue; } if ($alias->getToolset() !== $this->getToolsetKey()) { continue; } $complete[] = $alias->getTrigger(); } $partial = $argv[$pos]; $complete = $this->getMatches($complete, $partial); if ($complete) { return $this->suggestStrings($complete); } else { return $this->suggestNothing(); } } else { // TOOLSETS: We should resolve aliases before picking a workflow, so // that if you alias "arc draft" to "arc diff --draft", we can suggest // other "diff" flags when you type "arc draft --q". // TOOLSETS: It's possible the workflow isn't in position 1. The user // may be running "arc --trace diff --dra", for example. $workflow = idx($workflows, $argv[1]); if (!$workflow) { return $this->suggestNothing(); } $arguments = $workflow->getWorkflowArguments(); $arguments = mpull($arguments, null, 'getKey'); $current = idx($argv, $pos, ''); $argument = null; $prev = idx($argv, $pos - 1, null); if (!strncmp($prev, '--', 2)) { $prev = substr($prev, 2); $argument = idx($arguments, $prev); } // If the last argument was a "--thing" argument, test if "--thing" is // a parameterized argument. If it is, the next argument should be a // parameter. if ($argument && strlen($argument->getParameter())) { if ($argument->getIsPathArgument()) { return $this->suggestPaths($current); } else { return $this->suggestNothing(); } // TOOLSETS: We can allow workflows and arguments to provide a specific // list of completeable values, like the "--shell" argument for this // workflow. } $flags = array(); $wildcard = null; foreach ($arguments as $argument) { if ($argument->getWildcard()) { $wildcard = $argument; continue; } $flags[] = '--'.$argument->getKey(); } $matches = $this->getMatches($flags, $current); // If whatever the user is completing does not match the prefix of any - // flag, try to autcomplete a wildcard argument if it has some kind of - // meaningful completion. For example, "arc lint READ" should - // autocomplete a file. + // flag (or is entirely empty), try to autcomplete a wildcard argument + // if it has some kind of meaningful completion. For example, "arc lint + // READ" should autocomplete a file, and "arc lint " should + // suggest files in the current directory. - if (!$matches && $wildcard) { + if (!strlen($current) || !$matches) { + $try_paths = true; + } else { + $try_paths = false; + } + if ($try_paths && $wildcard) { // TOOLSETS: There was previously some very questionable support for // autocompleting branches here. This could be moved into Arguments // and Workflows. if ($wildcard->getIsPathArgument()) { return $this->suggestPaths($current); } } // TODO: If a command has only one flag, like "--json", don't suggest // it if the user hasn't typed anything or has only typed "--". // TODO: Don't suggest "--flag" arguments which aren't repeatable if // they are already present in the argument list. return $this->suggestStrings($matches); } } private function suggestPaths($prefix) { // NOTE: We are returning a directive to the bash script to run "compgen" // for us rather than running it ourselves. If we run: // // compgen -A file -- %s // // ...from this context, it fails (exits with error code 1 and no output) // if the prefix is "foo\ ", on my machine. See T9116 for some dicussion. echo ''; return 0; } private function suggestNothing() { return $this->suggestStrings(array()); } private function suggestStrings(array $strings) { sort($strings); echo implode("\n", $strings); return 0; } private function getMatches(array $candidates, $prefix) { $matches = array(); if (strlen($prefix)) { foreach ($candidates as $possible) { if (!strncmp($possible, $prefix, strlen($prefix))) { $matches[] = $possible; } } // If we matched nothing, try a case-insensitive match. if (!$matches) { foreach ($candidates as $possible) { if (!strncasecmp($possible, $prefix, strlen($prefix))) { $matches[] = $possible; } } } } else { $matches = $candidates; } return $matches; } } diff --git a/src/upload/ArcanistFileUploader.php b/src/upload/ArcanistFileUploader.php index 5e1841f4..cde462e7 100644 --- a/src/upload/ArcanistFileUploader.php +++ b/src/upload/ArcanistFileUploader.php @@ -1,319 +1,319 @@ setConduitEngine($conduit); * * // Queue one or more files to be uploaded. * $file = id(new ArcanistFileDataRef()) * ->setName('example.jpg') * ->setPath('/path/to/example.jpg'); * $uploader->addFile($file); * * // Upload the files. * $files = $uploader->uploadFiles(); * * For details about building file references, see @{class:ArcanistFileDataRef}. * * @task config Configuring the Uploader * @task add Adding Files * @task upload Uploading Files * @task internal Internals */ final class ArcanistFileUploader extends Phobject { private $conduitEngine; private $files = array(); /* -( Configuring the Uploader )------------------------------------------- */ public function setConduitEngine(ArcanistConduitEngine $engine) { $this->conduitEngine = $engine; return $this; } /* -( Adding Files )------------------------------------------------------- */ /** * Add a file to the list of files to be uploaded. * * You can optionally provide an explicit key which will be used to identify * the file. After adding files, upload them with @{method:uploadFiles}. * * @param ArcanistFileDataRef File data to upload. * @param null|string Optional key to use to identify this file. * @return this * @task add */ public function addFile(ArcanistFileDataRef $file, $key = null) { if ($key === null) { $this->files[] = $file; } else { if (isset($this->files[$key])) { throw new Exception( pht( 'Two files were added with identical explicit keys ("%s"); each '. 'explicit key must be unique.', $key)); } $this->files[$key] = $file; } return $this; } /* -( Uploading Files )---------------------------------------------------- */ /** * Upload files to the server. * * This transfers all files which have been queued with @{method:addFiles} * over the Conduit link configured with @{method:setConduitEngine}. * * This method returns a map of all file data references. If references were * added with an explicit key when @{method:addFile} was called, the key is * retained in the result map. * * On return, files are either populated with a PHID (indicating a successful * upload) or a list of errors. See @{class:ArcanistFileDataRef} for * details. * * @return map Files with results populated. * @task upload */ public function uploadFiles() { if (!$this->conduitEngine) { throw new PhutilInvalidStateException('setConduitEngine'); } $files = $this->files; foreach ($files as $key => $file) { try { $file->willUpload(); } catch (Exception $ex) { $file->didFail($ex->getMessage()); unset($files[$key]); } } $conduit = $this->conduitEngine; $futures = array(); foreach ($files as $key => $file) { $params = $this->getUploadParameters($file) + array( 'contentLength' => $file->getByteSize(), 'contentHash' => $file->getContentHash(), ); $delete_after = $file->getDeleteAfterEpoch(); if ($delete_after !== null) { $params['deleteAfterEpoch'] = $delete_after; } - // TOOLSETS: This should be a real future, but ConduitEngine and - // ConduitCall are currently built oddly and return pretend futures. - - $futures[$key] = new ImmediateFuture( - $conduit->resolveCall('file.allocate', $params)); + $futures[$key] = $conduit->newFuture('file.allocate', $params); } $iterator = id(new FutureIterator($futures))->limit(4); $chunks = array(); foreach ($iterator as $key => $future) { try { $result = $future->resolve(); } catch (Exception $ex) { // The most likely cause for a failure here is that the server does // not support `file.allocate`. In this case, we'll try the older // upload method below. continue; } $phid = $result['filePHID']; $file = $files[$key]; // We don't need to upload any data. Figure out why not: this can either // be because of an error (server can't accept the data) or because the // server already has the data. if (!$result['upload']) { if (!$phid) { $file->didFail( pht( 'Unable to upload file: the server refused to accept file '. '"%s". This usually means it is too large.', $file->getName())); } else { // These server completed the upload by creating a reference to known // file data. We don't need to transfer the actual data, and are all // set. $file->setPHID($phid); } unset($files[$key]); continue; } // The server wants us to do an upload. if ($phid) { $chunks[$key] = array( 'file' => $file, 'phid' => $phid, ); } } foreach ($chunks as $key => $chunk) { $file = $chunk['file']; $phid = $chunk['phid']; try { $this->uploadChunks($file, $phid); $file->setPHID($phid); } catch (Exception $ex) { $file->didFail( pht( 'Unable to upload file chunks: %s', $ex->getMessage())); } unset($files[$key]); } foreach ($files as $key => $file) { try { $phid = $this->uploadData($file); $file->setPHID($phid); } catch (Exception $ex) { $file->didFail( pht( 'Unable to upload file data: %s', $ex->getMessage())); } unset($files[$key]); } foreach ($this->files as $file) { $file->didUpload(); } return $this->files; } /* -( Internals )---------------------------------------------------------- */ /** * Upload missing chunks of a large file by calling `file.uploadchunk` over * Conduit. * * @task internal */ private function uploadChunks(ArcanistFileDataRef $file, $file_phid) { $conduit = $this->conduitEngine; - $chunks = $conduit->resolveCall( + $future = $conduit->newFuture( 'file.querychunks', array( 'filePHID' => $file_phid, )); + $chunks = $future->resolve(); $remaining = array(); foreach ($chunks as $chunk) { if (!$chunk['complete']) { $remaining[] = $chunk; } } $done = (count($chunks) - count($remaining)); if ($done) { $this->writeStatus( pht( 'Resuming upload (%s of %s chunks remain).', phutil_count($remaining), phutil_count($chunks))); } else { $this->writeStatus( pht( 'Uploading chunks (%s chunks to upload).', phutil_count($remaining))); } $progress = new PhutilConsoleProgressBar(); $progress->setTotal(count($chunks)); for ($ii = 0; $ii < $done; $ii++) { $progress->update(1); } $progress->draw(); // TODO: We could do these in parallel to improve upload performance. foreach ($remaining as $chunk) { $data = $file->readBytes($chunk['byteStart'], $chunk['byteEnd']); - $conduit->resolveCall( + $future = $conduit->newFuture( 'file.uploadchunk', array( 'filePHID' => $file_phid, 'byteStart' => $chunk['byteStart'], 'dataEncoding' => 'base64', 'data' => base64_encode($data), )); + $future->resolve(); $progress->update(1); } } /** * Upload an entire file by calling `file.upload` over Conduit. * * @task internal */ private function uploadData(ArcanistFileDataRef $file) { $conduit = $this->conduitEngine; $data = $file->readBytes(0, $file->getByteSize()); - return $conduit->resolveCall( + $future = $conduit->newFuture( 'file.upload', $this->getUploadParameters($file) + array( 'data_base64' => base64_encode($data), )); + + return $future->resolve(); } /** * Get common parameters for file uploads. */ private function getUploadParameters(ArcanistFileDataRef $file) { $params = array( 'name' => $file->getName(), ); $view_policy = $file->getViewPolicy(); if ($view_policy !== null) { $params['viewPolicy'] = $view_policy; } return $params; } /** * Write a status message. * * @task internal */ private function writeStatus($message) { fwrite(STDERR, $message."\n"); } } diff --git a/src/utils/__tests__/PhutilUtilsTestCase.php b/src/utils/__tests__/PhutilUtilsTestCase.php index e1bade52..75fe4692 100644 --- a/src/utils/__tests__/PhutilUtilsTestCase.php +++ b/src/utils/__tests__/PhutilUtilsTestCase.php @@ -1,969 +1,1003 @@ assertTrue($caught instanceof InvalidArgumentException); } public function testMFilterWithEmptyValueFiltered() { $a = new MFilterTestHelper('o', 'p', 'q'); $b = new MFilterTestHelper('o', '', 'q'); $c = new MFilterTestHelper('o', 'p', 'q'); $list = array( 'a' => $a, 'b' => $b, 'c' => $c, ); $actual = mfilter($list, 'getI'); $expected = array( 'a' => $a, 'c' => $c, ); $this->assertEqual($expected, $actual); } public function testMFilterWithEmptyValueNegateFiltered() { $a = new MFilterTestHelper('o', 'p', 'q'); $b = new MFilterTestHelper('o', '', 'q'); $c = new MFilterTestHelper('o', 'p', 'q'); $list = array( 'a' => $a, 'b' => $b, 'c' => $c, ); $actual = mfilter($list, 'getI', true); $expected = array( 'b' => $b, ); $this->assertEqual($expected, $actual); } public function testIFilterInvalidIndexThrowException() { $caught = null; try { ifilter(array(), null); } catch (InvalidArgumentException $ex) { $caught = $ex; } $this->assertTrue($caught instanceof InvalidArgumentException); } public function testIFilterWithEmptyValueFiltered() { $list = array( 'a' => array('h' => 'o', 'i' => 'p', 'j' => 'q'), 'b' => array('h' => 'o', 'i' => '', 'j' => 'q'), 'c' => array('h' => 'o', 'i' => 'p', 'j' => 'q'), 'd' => array('h' => 'o', 'i' => 0, 'j' => 'q'), 'e' => array('h' => 'o', 'i' => null, 'j' => 'q'), 'f' => array('h' => 'o', 'i' => false, 'j' => 'q'), ); $actual = ifilter($list, 'i'); $expected = array( 'a' => array('h' => 'o', 'i' => 'p', 'j' => 'q'), 'c' => array('h' => 'o', 'i' => 'p', 'j' => 'q'), ); $this->assertEqual($expected, $actual); } public function testIFilterIndexNotExistsAllFiltered() { $list = array( 'a' => array('h' => 'o', 'i' => 'p', 'j' => 'q'), 'b' => array('h' => 'o', 'i' => '', 'j' => 'q'), ); $actual = ifilter($list, 'NoneExisting'); $expected = array(); $this->assertEqual($expected, $actual); } public function testIFilterWithEmptyValueNegateFiltered() { $list = array( 'a' => array('h' => 'o', 'i' => 'p', 'j' => 'q'), 'b' => array('h' => 'o', 'i' => '', 'j' => 'q'), 'c' => array('h' => 'o', 'i' => 'p', 'j' => 'q'), 'd' => array('h' => 'o', 'i' => 0, 'j' => 'q'), 'e' => array('h' => 'o', 'i' => null, 'j' => 'q'), 'f' => array('h' => 'o', 'i' => false, 'j' => 'q'), ); $actual = ifilter($list, 'i', true); $expected = array( 'b' => array('h' => 'o', 'i' => '', 'j' => 'q'), 'd' => array('h' => 'o', 'i' => 0, 'j' => 'q'), 'e' => array('h' => 'o', 'i' => null, 'j' => 'q'), 'f' => array('h' => 'o', 'i' => false, 'j' => 'q'), ); $this->assertEqual($expected, $actual); } public function testIFilterIndexNotExistsNotFiltered() { $list = array( 'a' => array('h' => 'o', 'i' => 'p', 'j' => 'q'), 'b' => array('h' => 'o', 'i' => '', 'j' => 'q'), ); $actual = ifilter($list, 'NoneExisting', true); $expected = array( 'a' => array('h' => 'o', 'i' => 'p', 'j' => 'q'), 'b' => array('h' => 'o', 'i' => '', 'j' => 'q'), ); $this->assertEqual($expected, $actual); } public function testmergevMergingBasicallyWorksCorrectly() { $this->assertEqual( array(), array_mergev( array( // ))); $this->assertEqual( array(), array_mergev( array( array(), array(), array(), ))); $this->assertEqual( array(1, 2, 3, 4, 5), array_mergev( array( array(1, 2), array(3), array(), array(4, 5), ))); $not_valid = array( 'scalar' => array(1), 'array plus scalar' => array(array(), 1), 'null' => array(null), ); foreach ($not_valid as $key => $invalid_input) { $caught = null; try { array_mergev($invalid_input); } catch (InvalidArgumentException $ex) { $caught = $ex; } $this->assertTrue( ($caught instanceof InvalidArgumentException), pht('%s invalid on %s', 'array_mergev()', $key)); } } public function testNonempty() { $this->assertEqual( 'zebra', nonempty(false, null, 0, '', array(), 'zebra')); $this->assertEqual( null, nonempty()); $this->assertEqual( false, nonempty(null, false)); $this->assertEqual( null, nonempty(false, null)); } protected function tryAssertInstancesOfArray($input) { assert_instances_of($input, 'array'); } protected function tryAssertInstancesOfStdClass($input) { assert_instances_of($input, 'stdClass'); } public function testAssertInstancesOf() { $object = new stdClass(); $inputs = array( 'empty' => array(), 'stdClass' => array($object, $object), __CLASS__ => array($object, $this), 'array' => array(array(), array()), 'integer' => array($object, 1), ); $this->tryTestCases( $inputs, array(true, true, false, false, false), array($this, 'tryAssertInstancesOfStdClass'), 'InvalidArgumentException'); $this->tryTestCases( $inputs, array(true, false, false, true, false), array($this, 'tryAssertInstancesOfArray'), 'InvalidArgumentException'); } public function testAssertSameKeys() { $cases = array( array(true, array(), array()), array(true, array(0), array(1)), array(false, array(0), array()), array(false, array(), array(0)), array(false, array('a' => 1), array('b' => 1)), // Make sure "null" values survive "isset()" tests. array(true, array('a' => 1), array('a' => null)), // Key order should not matter. array(true, array('a' => 1, 'b' => 1), array('b' => 1, 'a' => 1)), ); foreach ($cases as $case) { list($same_keys, $expect, $input) = $case; $caught = null; try { assert_same_keys($expect, $input); } catch (InvalidArgumentException $ex) { $caught = $ex; } $this->assertEqual($same_keys, ($caught === null)); } } public function testAssertStringLike() { $this->assertEqual( null, assert_stringlike(null)); $this->assertEqual( null, assert_stringlike('')); $this->assertEqual( null, assert_stringlike('Hello World')); $this->assertEqual( null, assert_stringlike(1)); $this->assertEqual( null, assert_stringlike(9.9999)); $this->assertEqual( null, assert_stringlike(true)); $obj = new Exception('.'); $this->assertEqual( null, assert_stringlike($obj)); $obj = (object)array(); try { assert_stringlike($obj); } catch (InvalidArgumentException $ex) { $caught = $ex; } $this->assertTrue($caught instanceof InvalidArgumentException); $array = array( 'foo' => 'bar', 'bar' => 'foo', ); try { assert_stringlike($array); } catch (InvalidArgumentException $ex) { $caught = $ex; } $this->assertTrue($caught instanceof InvalidArgumentException); $tmp = new TempFile(); $resource = fopen($tmp, 'r'); try { assert_stringlike($resource); } catch (InvalidArgumentException $ex) { $caught = $ex; } fclose($resource); $this->assertTrue($caught instanceof InvalidArgumentException); } public function testCoalesce() { $this->assertEqual( 'zebra', coalesce(null, 'zebra')); $this->assertEqual( null, coalesce()); $this->assertEqual( false, coalesce(false, null)); $this->assertEqual( false, coalesce(null, false)); } public function testHeadLast() { $this->assertEqual( 'a', head(explode('.', 'a.b'))); $this->assertEqual( 'b', last(explode('.', 'a.b'))); } public function testHeadKeyLastKey() { $this->assertEqual( 'a', head_key(array('a' => 0, 'b' => 1))); $this->assertEqual( 'b', last_key(array('a' => 0, 'b' => 1))); $this->assertEqual(null, head_key(array())); $this->assertEqual(null, last_key(array())); } public function testID() { $this->assertEqual(true, id(true)); $this->assertEqual(false, id(false)); } public function testIdx() { $array = array( 'present' => true, 'null' => null, ); $this->assertEqual(true, idx($array, 'present')); $this->assertEqual(true, idx($array, 'present', false)); $this->assertEqual(null, idx($array, 'null')); $this->assertEqual(null, idx($array, 'null', false)); $this->assertEqual(null, idx($array, 'missing')); $this->assertEqual(false, idx($array, 'missing', false)); } public function testSplitLines() { $retain_cases = array( '' => array(''), 'x' => array('x'), "x\n" => array("x\n"), "\n" => array("\n"), "\n\n\n" => array("\n", "\n", "\n"), "\r\n" => array("\r\n"), "x\r\ny\n" => array("x\r\n", "y\n"), "x\ry\nz\r\n" => array("x\ry\n", "z\r\n"), "x\ry\nz\r\n\n" => array("x\ry\n", "z\r\n", "\n"), ); foreach ($retain_cases as $input => $expect) { $this->assertEqual( $expect, phutil_split_lines($input, $retain_endings = true), pht('(Retained) %s', addcslashes($input, "\r\n\\"))); } $discard_cases = array( '' => array(''), 'x' => array('x'), "x\n" => array('x'), "\n" => array(''), "\n\n\n" => array('', '', ''), "\r\n" => array(''), "x\r\ny\n" => array('x', 'y'), "x\ry\nz\r\n" => array("x\ry", 'z'), "x\ry\nz\r\n\n" => array("x\ry", 'z', ''), ); foreach ($discard_cases as $input => $expect) { $this->assertEqual( $expect, phutil_split_lines($input, $retain_endings = false), pht('(Discarded) %s', addcslashes($input, "\r\n\\"))); } } public function testArrayFuse() { $this->assertEqual(array(), array_fuse(array())); $this->assertEqual(array('x' => 'x'), array_fuse(array('x'))); } public function testArrayInterleave() { $this->assertEqual(array(), array_interleave('x', array())); $this->assertEqual(array('y'), array_interleave('x', array('y'))); $this->assertEqual( array('y', 'x', 'z'), array_interleave('x', array('y', 'z'))); $this->assertEqual( array('y', 'x', 'z'), array_interleave( 'x', array( 'kangaroo' => 'y', 'marmoset' => 'z', ))); $obj1 = (object)array(); $obj2 = (object)array(); $this->assertEqual( array($obj1, $obj2, $obj1, $obj2, $obj1), array_interleave( $obj2, array( $obj1, $obj1, $obj1, ))); $implode_tests = array( '' => array(1, 2, 3), 'x' => array(1, 2, 3), 'y' => array(), 'z' => array(1), ); foreach ($implode_tests as $x => $y) { $this->assertEqual( implode('', array_interleave($x, $y)), implode($x, $y)); } } public function testLoggableString() { $this->assertEqual( '', phutil_loggable_string('')); $this->assertEqual( "a\\nb", phutil_loggable_string("a\nb")); $this->assertEqual( "a\\x01b", phutil_loggable_string("a\x01b")); $this->assertEqual( "a\\x1Fb", phutil_loggable_string("a\x1Fb")); } public function testPhutilUnits() { $cases = array( '0 seconds in seconds' => 0, '1 second in seconds' => 1, '2 seconds in seconds' => 2, '100 seconds in seconds' => 100, '2 minutes in seconds' => 120, '1 hour in seconds' => 3600, '1 day in seconds' => 86400, '3 days in seconds' => 259200, '128 bits in bytes' => 16, '1 byte in bytes' => 1, '8 bits in bytes' => 1, '1 minute in milliseconds' => 60000, '2 minutes in microseconds' => 120000000, ); foreach ($cases as $input => $expect) { $this->assertEqual( $expect, phutil_units($input), 'phutil_units("'.$input.'")'); } $bad_cases = array( 'quack', '3 years in seconds', '1 day in days', '-1 minutes in seconds', '1.5 minutes in seconds', '7 bits in bytes', '2 hours in bytes', '1 dram in bytes', '24 bits in seconds', ); foreach ($bad_cases as $input) { $caught = null; try { phutil_units($input); } catch (InvalidArgumentException $ex) { $caught = $ex; } $this->assertTrue( ($caught instanceof InvalidArgumentException), 'phutil_units("'.$input.'")'); } } public function testPhutilJSONDecode() { $valid_cases = array( '{}' => array(), '[]' => array(), '[1, 2]' => array(1, 2), '{"a":"b"}' => array('a' => 'b'), ); foreach ($valid_cases as $input => $expect) { $result = phutil_json_decode($input); $this->assertEqual($expect, $result, 'phutil_json_decode('.$input.')'); } $invalid_cases = array( '', '"a"', '{,}', 'null', '"null"', ); foreach ($invalid_cases as $input) { $caught = null; try { phutil_json_decode($input); } catch (Exception $ex) { $caught = $ex; } $this->assertTrue($caught instanceof PhutilJSONParserException); } } public function testPhutilINIDecode() { // Skip the test if we are using an older version of PHP that doesn't // have the `parse_ini_string` function. try { phutil_ini_decode(''); } catch (PhutilMethodNotImplementedException $ex) { $this->assertSkipped($ex->getMessage()); } $valid_cases = array( '' => array(), 'foo=' => array('foo' => ''), 'foo=bar' => array('foo' => 'bar'), 'foo = bar' => array('foo' => 'bar'), "foo = bar\n" => array('foo' => 'bar'), "foo\nbar = baz" => array('bar' => 'baz'), "[foo]\nbar = baz" => array('foo' => array('bar' => 'baz')), "[foo]\n[bar]\nbaz = foo" => array( 'foo' => array(), 'bar' => array('baz' => 'foo'), ), "[foo]\nbar = baz\n\n[bar]\nbaz = foo" => array( 'foo' => array('bar' => 'baz'), 'bar' => array('baz' => 'foo'), ), "; Comment\n[foo]\nbar = baz" => array('foo' => array('bar' => 'baz')), "# Comment\n[foo]\nbar = baz" => array('foo' => array('bar' => 'baz')), "foo = true\n[bar]\nbaz = false" => array('foo' => true, 'bar' => array('baz' => false)), "foo = 1\nbar = 1.234" => array('foo' => 1, 'bar' => 1.234), 'x = {"foo": "bar"}' => array('x' => '{"foo": "bar"}'), ); foreach ($valid_cases as $input => $expect) { $result = phutil_ini_decode($input); $this->assertEqual($expect, $result, 'phutil_ini_decode('.$input.')'); } $invalid_cases = array( '[' => new PhutilINIParserException(), ); foreach ($invalid_cases as $input => $expect) { $caught = null; try { phutil_ini_decode($input); } catch (Exception $ex) { $caught = $ex; } $this->assertTrue($caught instanceof $expect); } } public function testCensorCredentials() { $cases = array( '' => '', 'abc' => 'abc', // NOTE: We're liberal about censoring here, since we can't tell // if this is a truncated password at the end of an input string // or a domain name. The version with a "/" isn't censored. 'http://example.com' => 'http://********', 'http://example.com/' => 'http://example.com/', 'http://username@example.com' => 'http://********@example.com', 'http://user:pass@example.com' => 'http://********@example.com', // We censor these because they might be truncated credentials at the end // of the string. 'http://user' => 'http://********', "http://user\n" => "http://********\n", 'svn+ssh://user:pass@example.com' => 'svn+ssh://********@example.com', ); foreach ($cases as $input => $expect) { $this->assertEqual( $expect, phutil_censor_credentials($input), pht('Credential censoring for: %s', $input)); } } public function testVarExport() { // Constants $this->assertEqual('null', phutil_var_export(null)); $this->assertEqual('true', phutil_var_export(true)); $this->assertEqual('false', phutil_var_export(false)); $this->assertEqual("'quack'", phutil_var_export('quack')); $this->assertEqual('1234567', phutil_var_export(1234567)); // Arrays $this->assertEqual( 'array()', phutil_var_export(array())); $this->assertEqual( implode("\n", array( 'array(', ' 1,', ' 2,', ' 3,', ')', )), phutil_var_export(array(1, 2, 3))); $this->assertEqual( implode("\n", array( 'array(', " 'foo' => 'bar',", " 'bar' => 'baz',", ')', )), phutil_var_export(array('foo' => 'bar', 'bar' => 'baz'))); $this->assertEqual( implode("\n", array( 'array(', " 'foo' => array(", " 'bar' => array(", " 'baz' => array(),", ' ),', ' ),', ')', )), phutil_var_export( array('foo' => array('bar' => array('baz' => array()))))); // NOTE: Object behavior differs across PHP versions. Older versions of // PHP export objects as "stdClass::__set_state(array())". Newer versions // of PHP (7.3+) export objects as "(object) array()". } public function testFnmatch() { $cases = array( '' => array( array(''), array('.', '/'), ), '*' => array( array('file'), array('dir/', '/dir'), ), '**' => array( array('file', 'dir/', '/dir', 'dir/subdir/file'), array(), ), '**/file' => array( array('file', 'dir/file', 'dir/subdir/file', 'dir/subdir/subdir/file'), array('file/', 'file/dir'), ), 'file.*' => array( array('file.php', 'file.a', 'file.'), array('files.php', 'file.php/blah'), ), 'fo?' => array( array('foo', 'fot'), array('fooo', 'ffoo', 'fo/', 'foo/'), ), 'fo{o,t}' => array( array('foo', 'fot'), array('fob', 'fo/', 'foo/'), ), 'fo{o,\\,}' => array( array('foo', 'fo,'), array('foo/', 'fo,/'), ), 'fo{o,\\\\}' => array( array('foo', 'fo\\'), array('foo/', 'fo\\/'), ), '/foo' => array( array('/foo'), array('foo', '/foo/'), ), // Tests for various `fnmatch` flags. '*.txt' => array( array( 'file.txt', // FNM_PERIOD '.secret-file.txt', ), array( // FNM_PATHNAME 'dir/file.txt', // FNM_CASEFOLD 'file.TXT', ), '\\*.txt' => array( array( // FNM_NOESCAPE '*.txt', ), array( 'file.txt', ), ), ), ); $invalid = array( '{', 'asdf\\', ); foreach ($cases as $input => $expect) { list($matches, $no_matches) = $expect; foreach ($matches as $match) { $this->assertTrue( phutil_fnmatch($input, $match), pht('Expecting "%s" to match "%s".', $input, $match)); } foreach ($no_matches as $no_match) { $this->assertFalse( phutil_fnmatch($input, $no_match), pht('Expecting "%s" not to match "%s".', $input, $no_match)); } } foreach ($invalid as $input) { $caught = null; try { phutil_fnmatch($input, ''); } catch (Exception $ex) { $caught = $ex; } $this->assertTrue($caught instanceof InvalidArgumentException); } } public function testJSONEncode() { $in = array( 'example' => "Not Valid UTF8: \x80", ); $caught = null; try { $value = phutil_json_encode($in); } catch (Exception $ex) { $caught = $ex; } $this->assertTrue(($caught instanceof Exception)); } public function testHashComparisons() { $tests = array( array('1', '12', false), array('0', '0e123', false), array('0e123', '0e124', false), array('', '0', false), array('000', '0e0', false), array('001', '002', false), array('0', '', false), array('987654321', '123456789', false), array('A', 'a', false), array('123456789', '123456789', true), array('hunter42', 'hunter42', true), ); foreach ($tests as $key => $test) { list($u, $v, $expect) = $test; $actual = phutil_hashes_are_identical($u, $v); $this->assertEqual( $expect, $actual, pht('Test Case: "%s" vs "%s"', $u, $v)); } } public function testVectorSortInt() { $original = array( ~PHP_INT_MAX, -2147483648, -5, -3, -1, 0, 1, 2, 3, 100, PHP_INT_MAX, ); $items = $this->shuffleMap($original); foreach ($items as $key => $value) { $items[$key] = (string)id(new PhutilSortVector()) ->addInt($value); } asort($items, SORT_STRING); $this->assertEqual( array_keys($original), array_keys($items)); } public function testVectorSortString() { $original = array( '', "\1", 'A', 'AB', 'Z', "Z\1", 'ZZZ', ); $items = $this->shuffleMap($original); foreach ($items as $key => $value) { $items[$key] = (string)id(new PhutilSortVector()) ->addString($value); } asort($items, SORT_STRING); $this->assertEqual( array_keys($original), array_keys($items)); } private function shuffleMap(array $map) { $keys = array_keys($map); shuffle($keys); return array_select_keys($map, $keys); } public function testQueryStringEncoding() { $expect = array(); // As a starting point, we expect every character to encode as an "%XX" // escaped version. foreach (range(0, 255) as $byte) { $c = chr($byte); $expect[$c] = sprintf('%%%02X', $byte); } // We expect these characters to not be escaped. $ranges = array( range('a', 'z'), range('A', 'Z'), range('0', '9'), array('-', '.', '_', '~'), ); foreach ($ranges as $range) { foreach ($range as $preserve_char) { $expect[$preserve_char] = $preserve_char; } } foreach (range(0, 255) as $byte) { $c = chr($byte); $expect_c = $expect[$c]; $expect_str = "{$expect_c}={$expect_c}"; $actual_str = phutil_build_http_querystring(array($c => $c)); $this->assertEqual( $expect_str, $actual_str, pht('HTTP querystring for byte "%s".', sprintf('0x%02x', $byte))); } } public function testNaturalList() { $cases = array( array(true, array()), array(true, array(0 => true, 1 => true, 2 => true)), array(true, array('a', 'b', 'c')), array(false, array(0 => true, 2 => true, 1 => true)), array(false, array(1 => true)), array(false, array('sound' => 'quack')), ); foreach ($cases as $case) { list($expect, $value) = $case; $this->assertEqual($expect, phutil_is_natural_list($value)); } } + public function testArrayPartition() { + $map = array( + 'empty' => array( + array(), + array(), + ), + 'unique' => array( + array('a' => 'a', 'b' => 'b', 'c' => 'c'), + array(array('a' => 'a'), array('b' => 'b'), array('c' => 'c')), + ), + 'xy' => array( + array('a' => 'x', 'b' => 'x', 'c' => 'y', 'd' => 'y'), + array( + array('a' => 'x', 'b' => 'x'), + array('c' => 'y', 'd' => 'y'), + ), + ), + 'multi' => array( + array('a' => true, 'b' => true, 'c' => false, 'd' => true), + array( + array('a' => true, 'b' => true), + array('c' => false), + array('d' => true), + ), + ), + ); + + foreach ($map as $name => $item) { + list($input, $expect) = $item; + $actual = phutil_partition($input); + $this->assertEqual($expect, $actual, pht('Partition of "%s"', $name)); + } + } + } diff --git a/src/utils/utils.php b/src/utils/utils.php index 0bb1b4f8..4c44d7b7 100644 --- a/src/utils/utils.php +++ b/src/utils/utils.php @@ -1,1968 +1,2005 @@ doStuff(); * * ...but this works fine: * * id(new Thing())->doStuff(); * * @param wild Anything. * @return wild Unmodified argument. */ function id($x) { return $x; } /** * Access an array index, retrieving the value stored there if it exists or * a default if it does not. This function allows you to concisely access an * index which may or may not exist without raising a warning. * * @param array Array to access. * @param scalar Index to access in the array. * @param wild Default value to return if the key is not present in the * array. * @return wild If `$array[$key]` exists, that value is returned. If not, * $default is returned without raising a warning. */ function idx(array $array, $key, $default = null) { // isset() is a micro-optimization - it is fast but fails for null values. if (isset($array[$key])) { return $array[$key]; } // Comparing $default is also a micro-optimization. if ($default === null || array_key_exists($key, $array)) { return null; } return $default; } /** * Access a sequence of array indexes, retrieving a deeply nested value if * it exists or a default if it does not. * * For example, `idxv($dict, array('a', 'b', 'c'))` accesses the key at * `$dict['a']['b']['c']`, if it exists. If it does not, or any intermediate * value is not itself an array, it returns the defualt value. * * @param array Array to access. * @param list List of keys to access, in sequence. * @param wild Default value to return. * @return wild Accessed value, or default if the value is not accessible. */ function idxv(array $map, array $path, $default = null) { if (!$path) { return $default; } $last = last($path); $path = array_slice($path, 0, -1); $cursor = $map; foreach ($path as $key) { $cursor = idx($cursor, $key); if (!is_array($cursor)) { return $default; } } return idx($cursor, $last, $default); } /** * Call a method on a list of objects. Short for "method pull", this function * works just like @{function:ipull}, except that it operates on a list of * objects instead of a list of arrays. This function simplifies a common type * of mapping operation: * * COUNTEREXAMPLE * $names = array(); * foreach ($objects as $key => $object) { * $names[$key] = $object->getName(); * } * * You can express this more concisely with mpull(): * * $names = mpull($objects, 'getName'); * * mpull() takes a third argument, which allows you to do the same but for * the array's keys: * * COUNTEREXAMPLE * $names = array(); * foreach ($objects as $object) { * $names[$object->getID()] = $object->getName(); * } * * This is the mpull version(): * * $names = mpull($objects, 'getName', 'getID'); * * If you pass ##null## as the second argument, the objects will be preserved: * * COUNTEREXAMPLE * $id_map = array(); * foreach ($objects as $object) { * $id_map[$object->getID()] = $object; * } * * With mpull(): * * $id_map = mpull($objects, null, 'getID'); * * See also @{function:ipull}, which works similarly but accesses array indexes * instead of calling methods. * * @param list Some list of objects. * @param string|null Determines which **values** will appear in the result * array. Use a string like 'getName' to store the * value of calling the named method in each value, or * ##null## to preserve the original objects. * @param string|null Determines how **keys** will be assigned in the result * array. Use a string like 'getID' to use the result * of calling the named method as each object's key, or * `null` to preserve the original keys. * @return dict A dictionary with keys and values derived according * to whatever you passed as `$method` and `$key_method`. */ function mpull(array $list, $method, $key_method = null) { $result = array(); foreach ($list as $key => $object) { if ($key_method !== null) { $key = $object->$key_method(); } if ($method !== null) { $value = $object->$method(); } else { $value = $object; } $result[$key] = $value; } return $result; } /** * Access a property on a list of objects. Short for "property pull", this * function works just like @{function:mpull}, except that it accesses object * properties instead of methods. This function simplifies a common type of * mapping operation: * * COUNTEREXAMPLE * $names = array(); * foreach ($objects as $key => $object) { * $names[$key] = $object->name; * } * * You can express this more concisely with ppull(): * * $names = ppull($objects, 'name'); * * ppull() takes a third argument, which allows you to do the same but for * the array's keys: * * COUNTEREXAMPLE * $names = array(); * foreach ($objects as $object) { * $names[$object->id] = $object->name; * } * * This is the ppull version(): * * $names = ppull($objects, 'name', 'id'); * * If you pass ##null## as the second argument, the objects will be preserved: * * COUNTEREXAMPLE * $id_map = array(); * foreach ($objects as $object) { * $id_map[$object->id] = $object; * } * * With ppull(): * * $id_map = ppull($objects, null, 'id'); * * See also @{function:mpull}, which works similarly but calls object methods * instead of accessing object properties. * * @param list Some list of objects. * @param string|null Determines which **values** will appear in the result * array. Use a string like 'name' to store the value of * accessing the named property in each value, or * `null` to preserve the original objects. * @param string|null Determines how **keys** will be assigned in the result * array. Use a string like 'id' to use the result of * accessing the named property as each object's key, or * `null` to preserve the original keys. * @return dict A dictionary with keys and values derived according * to whatever you passed as `$property` and * `$key_property`. */ function ppull(array $list, $property, $key_property = null) { $result = array(); foreach ($list as $key => $object) { if ($key_property !== null) { $key = $object->$key_property; } if ($property !== null) { $value = $object->$property; } else { $value = $object; } $result[$key] = $value; } return $result; } /** * Choose an index from a list of arrays. Short for "index pull", this function * works just like @{function:mpull}, except that it operates on a list of * arrays and selects an index from them instead of operating on a list of * objects and calling a method on them. * * This function simplifies a common type of mapping operation: * * COUNTEREXAMPLE * $names = array(); * foreach ($list as $key => $dict) { * $names[$key] = $dict['name']; * } * * With ipull(): * * $names = ipull($list, 'name'); * * See @{function:mpull} for more usage examples. * * @param list Some list of arrays. * @param scalar|null Determines which **values** will appear in the result * array. Use a scalar to select that index from each * array, or null to preserve the arrays unmodified as * values. * @param scalar|null Determines which **keys** will appear in the result * array. Use a scalar to select that index from each * array, or null to preserve the array keys. * @return dict A dictionary with keys and values derived according * to whatever you passed for `$index` and `$key_index`. */ function ipull(array $list, $index, $key_index = null) { $result = array(); foreach ($list as $key => $array) { if ($key_index !== null) { $key = $array[$key_index]; } if ($index !== null) { $value = $array[$index]; } else { $value = $array; } $result[$key] = $value; } return $result; } /** * Group a list of objects by the result of some method, similar to how * GROUP BY works in an SQL query. This function simplifies grouping objects * by some property: * * COUNTEREXAMPLE * $animals_by_species = array(); * foreach ($animals as $animal) { * $animals_by_species[$animal->getSpecies()][] = $animal; * } * * This can be expressed more tersely with mgroup(): * * $animals_by_species = mgroup($animals, 'getSpecies'); * * In either case, the result is a dictionary which maps species (e.g., like * "dog") to lists of animals with that property, so all the dogs are grouped * together and all the cats are grouped together, or whatever super * businessesey thing is actually happening in your problem domain. * * See also @{function:igroup}, which works the same way but operates on * array indexes. * * @param list List of objects to group by some property. * @param string Name of a method, like 'getType', to call on each object * in order to determine which group it should be placed into. * @param ... Zero or more additional method names, to subgroup the * groups. * @return dict Dictionary mapping distinct method returns to lists of * all objects which returned that value. */ function mgroup(array $list, $by /* , ... */) { $map = mpull($list, $by); $groups = array(); foreach ($map as $group) { // Can't array_fill_keys() here because 'false' gets encoded wrong. $groups[$group] = array(); } foreach ($map as $key => $group) { $groups[$group][$key] = $list[$key]; } $args = func_get_args(); $args = array_slice($args, 2); if ($args) { array_unshift($args, null); foreach ($groups as $group_key => $grouped) { $args[0] = $grouped; $groups[$group_key] = call_user_func_array('mgroup', $args); } } return $groups; } /** * Group a list of arrays by the value of some index. This function is the same * as @{function:mgroup}, except it operates on the values of array indexes * rather than the return values of method calls. * * @param list List of arrays to group by some index value. * @param string Name of an index to select from each array in order to * determine which group it should be placed into. * @param ... Zero or more additional indexes names, to subgroup the * groups. * @return dict Dictionary mapping distinct index values to lists of * all objects which had that value at the index. */ function igroup(array $list, $by /* , ... */) { $map = ipull($list, $by); $groups = array(); foreach ($map as $group) { $groups[$group] = array(); } foreach ($map as $key => $group) { $groups[$group][$key] = $list[$key]; } $args = func_get_args(); $args = array_slice($args, 2); if ($args) { array_unshift($args, null); foreach ($groups as $group_key => $grouped) { $args[0] = $grouped; $groups[$group_key] = call_user_func_array('igroup', $args); } } return $groups; } /** * Sort a list of objects by the return value of some method. In PHP, this is * often vastly more efficient than `usort()` and similar. * * // Sort a list of Duck objects by name. * $sorted = msort($ducks, 'getName'); * * It is usually significantly more efficient to define an ordering method * on objects and call `msort()` than to write a comparator. It is often more * convenient, as well. * * NOTE: This method does not take the list by reference; it returns a new list. * * @param list List of objects to sort by some property. * @param string Name of a method to call on each object; the return values * will be used to sort the list. * @return list Objects ordered by the return values of the method calls. */ function msort(array $list, $method) { $surrogate = mpull($list, $method); // See T13303. A "PhutilSortVector" is technically a sortable object, so // a method which returns a "PhutilSortVector" is suitable for use with // "msort()". However, it's almost certain that the caller intended to use // "msortv()", not "msort()", and forgot to add a "v". Treat this as an error. if ($surrogate) { $item = head($surrogate); if ($item instanceof PhutilSortVector) { throw new Exception( pht( 'msort() was passed a method ("%s") which returns '. '"PhutilSortVector" objects. Use "msortv()", not "msort()", to '. 'sort a list which produces vectors.', $method)); } } asort($surrogate); $result = array(); foreach ($surrogate as $key => $value) { $result[$key] = $list[$key]; } return $result; } /** * Sort a list of objects by a sort vector. * * This sort is stable, well-behaved, and more efficient than `usort()`. * * @param list List of objects to sort. * @param string Name of a method to call on each object. The method must * return a @{class:PhutilSortVector}. * @return list Objects ordered by the vectors. */ function msortv(array $list, $method) { + return msortv_internal($list, $method, SORT_STRING); +} + +function msortv_natural(array $list, $method) { + return msortv_internal($list, $method, SORT_NATURAL | SORT_FLAG_CASE); +} + +function msortv_internal(array $list, $method, $flags) { $surrogate = mpull($list, $method); $index = 0; foreach ($surrogate as $key => $value) { if (!($value instanceof PhutilSortVector)) { throw new Exception( pht( 'Objects passed to "%s" must return sort vectors (objects of '. 'class "%s") from the specified method ("%s"). One object (with '. 'key "%s") did not.', 'msortv()', 'PhutilSortVector', $method, $key)); } // Add the original index to keep the sort stable. $value->addInt($index++); $surrogate[$key] = (string)$value; } - asort($surrogate, SORT_STRING); + asort($surrogate, $flags); $result = array(); foreach ($surrogate as $key => $value) { $result[$key] = $list[$key]; } return $result; } /** * Sort a list of arrays by the value of some index. This method is identical to * @{function:msort}, but operates on a list of arrays instead of a list of * objects. * * @param list List of arrays to sort by some index value. * @param string Index to access on each object; the return values * will be used to sort the list. * @return list Arrays ordered by the index values. */ function isort(array $list, $index) { $surrogate = ipull($list, $index); asort($surrogate); $result = array(); foreach ($surrogate as $key => $value) { $result[$key] = $list[$key]; } return $result; } /** * Filter a list of objects by executing a method across all the objects and * filter out the ones with empty() results. this function works just like * @{function:ifilter}, except that it operates on a list of objects instead * of a list of arrays. * * For example, to remove all objects with no children from a list, where * 'hasChildren' is a method name, do this: * * mfilter($list, 'hasChildren'); * * The optional third parameter allows you to negate the operation and filter * out nonempty objects. To remove all objects that DO have children, do this: * * mfilter($list, 'hasChildren', true); * * @param array List of objects to filter. * @param string A method name. * @param bool Optionally, pass true to drop objects which pass the * filter instead of keeping them. * @return array List of objects which pass the filter. */ function mfilter(array $list, $method, $negate = false) { if (!is_string($method)) { throw new InvalidArgumentException(pht('Argument method is not a string.')); } $result = array(); foreach ($list as $key => $object) { $value = $object->$method(); if (!$negate) { if (!empty($value)) { $result[$key] = $object; } } else { if (empty($value)) { $result[$key] = $object; } } } return $result; } /** * Filter a list of arrays by removing the ones with an empty() value for some * index. This function works just like @{function:mfilter}, except that it * operates on a list of arrays instead of a list of objects. * * For example, to remove all arrays without value for key 'username', do this: * * ifilter($list, 'username'); * * The optional third parameter allows you to negate the operation and filter * out nonempty arrays. To remove all arrays that DO have value for key * 'username', do this: * * ifilter($list, 'username', true); * * @param array List of arrays to filter. * @param scalar The index. * @param bool Optionally, pass true to drop arrays which pass the * filter instead of keeping them. * @return array List of arrays which pass the filter. */ function ifilter(array $list, $index, $negate = false) { if (!is_scalar($index)) { throw new InvalidArgumentException(pht('Argument index is not a scalar.')); } $result = array(); if (!$negate) { foreach ($list as $key => $array) { if (!empty($array[$index])) { $result[$key] = $array; } } } else { foreach ($list as $key => $array) { if (empty($array[$index])) { $result[$key] = $array; } } } return $result; } /** * Selects a list of keys from an array, returning a new array with only the * key-value pairs identified by the selected keys, in the specified order. * * Note that since this function orders keys in the result according to the * order they appear in the list of keys, there are effectively two common * uses: either reducing a large dictionary to a smaller one, or changing the * key order on an existing dictionary. * * @param dict Dictionary of key-value pairs to select from. * @param list List of keys to select. * @return dict Dictionary of only those key-value pairs where the key was * present in the list of keys to select. Ordering is * determined by the list order. */ function array_select_keys(array $dict, array $keys) { $result = array(); foreach ($keys as $key) { if (array_key_exists($key, $dict)) { $result[$key] = $dict[$key]; } } return $result; } /** * Checks if all values of array are instances of the passed class. Throws * `InvalidArgumentException` if it isn't true for any value. * * @param array * @param string Name of the class or 'array' to check arrays. * @return array Returns passed array. */ function assert_instances_of(array $arr, $class) { $is_array = !strcasecmp($class, 'array'); foreach ($arr as $key => $object) { if ($is_array) { if (!is_array($object)) { $given = gettype($object); throw new InvalidArgumentException( pht( "Array item with key '%s' must be of type array, %s given.", $key, $given)); } } else if (!($object instanceof $class)) { $given = gettype($object); if (is_object($object)) { $given = pht('instance of %s', get_class($object)); } throw new InvalidArgumentException( pht( "Array item with key '%s' must be an instance of %s, %s given.", $key, $class, $given)); } } return $arr; } /** * Assert that two arrays have the exact same keys, in any order. * * @param map Array with expected keys. * @param map Array with actual keys. * @return void */ function assert_same_keys(array $expect, array $actual) { foreach ($expect as $key => $value) { if (isset($actual[$key]) || array_key_exists($key, $actual)) { continue; } throw new InvalidArgumentException( pht( 'Expected to find key "%s", but it is not present.', $key)); } foreach ($actual as $key => $value) { if (isset($expect[$key]) || array_key_exists($key, $expect)) { continue; } throw new InvalidArgumentException( pht( 'Found unexpected surplus key "%s" where no such key was expected.', $key)); } } /** * Assert that passed data can be converted to string. * * @param string Assert that this data is valid. * @return void * * @task assert */ function assert_stringlike($parameter) { switch (gettype($parameter)) { case 'string': case 'NULL': case 'boolean': case 'double': case 'integer': return; case 'object': if (method_exists($parameter, '__toString')) { return; } break; case 'array': case 'resource': case 'unknown type': default: break; } throw new InvalidArgumentException( pht( 'Argument must be scalar or object which implements %s!', '__toString()')); } /** * Returns the first argument which is not strictly null, or `null` if there * are no such arguments. Identical to the MySQL function of the same name. * * @param ... Zero or more arguments of any type. * @return mixed First non-`null` arg, or null if no such arg exists. */ function coalesce(/* ... */) { $args = func_get_args(); foreach ($args as $arg) { if ($arg !== null) { return $arg; } } return null; } /** * Similar to @{function:coalesce}, but less strict: returns the first * non-`empty()` argument, instead of the first argument that is strictly * non-`null`. If no argument is nonempty, it returns the last argument. This * is useful idiomatically for setting defaults: * * $display_name = nonempty($user_name, $full_name, "Anonymous"); * * @param ... Zero or more arguments of any type. * @return mixed First non-`empty()` arg, or last arg if no such arg * exists, or null if you passed in zero args. */ function nonempty(/* ... */) { $args = func_get_args(); $result = null; foreach ($args as $arg) { $result = $arg; if ($arg) { break; } } return $result; } /** * Invokes the "new" operator with a vector of arguments. There is no way to * `call_user_func_array()` on a class constructor, so you can instead use this * function: * * $obj = newv($class_name, $argv); * * That is, these two statements are equivalent: * * $pancake = new Pancake('Blueberry', 'Maple Syrup', true); * $pancake = newv('Pancake', array('Blueberry', 'Maple Syrup', true)); * * DO NOT solve this problem in other, more creative ways! Three popular * alternatives are: * * - Build a fake serialized object and unserialize it. * - Invoke the constructor twice. * - just use `eval()` lol * * These are really bad solutions to the problem because they can have side * effects (e.g., __wakeup()) and give you an object in an otherwise impossible * state. Please endeavor to keep your objects in possible states. * * If you own the classes you're doing this for, you should consider whether * or not restructuring your code (for instance, by creating static * construction methods) might make it cleaner before using `newv()`. Static * constructors can be invoked with `call_user_func_array()`, and may give your * class a cleaner and more descriptive API. * * @param string The name of a class. * @param list Array of arguments to pass to its constructor. * @return obj A new object of the specified class, constructed by passing * the argument vector to its constructor. */ function newv($class_name, array $argv) { $reflector = new ReflectionClass($class_name); if ($argv) { return $reflector->newInstanceArgs($argv); } else { return $reflector->newInstance(); } } /** * Returns the first element of an array. Exactly like reset(), but doesn't * choke if you pass it some non-referenceable value like the return value of * a function. * * @param array Array to retrieve the first element from. * @return wild The first value of the array. */ function head(array $arr) { return reset($arr); } /** * Returns the last element of an array. This is exactly like `end()` except * that it won't warn you if you pass some non-referencable array to * it -- e.g., the result of some other array operation. * * @param array Array to retrieve the last element from. * @return wild The last value of the array. */ function last(array $arr) { return end($arr); } /** * Returns the first key of an array. * * @param array Array to retrieve the first key from. * @return int|string The first key of the array. */ function head_key(array $arr) { reset($arr); return key($arr); } /** * Returns the last key of an array. * * @param array Array to retrieve the last key from. * @return int|string The last key of the array. */ function last_key(array $arr) { end($arr); return key($arr); } /** * Merge a vector of arrays performantly. This has the same semantics as * array_merge(), so these calls are equivalent: * * array_merge($a, $b, $c); * array_mergev(array($a, $b, $c)); * * However, when you have a vector of arrays, it is vastly more performant to * merge them with this function than by calling array_merge() in a loop, * because using a loop generates an intermediary array on each iteration. * * @param list Vector of arrays to merge. * @return list Arrays, merged with array_merge() semantics. */ function array_mergev(array $arrayv) { if (!$arrayv) { return array(); } foreach ($arrayv as $key => $item) { if (!is_array($item)) { throw new InvalidArgumentException( pht( 'Expected all items passed to `%s` to be arrays, but '. 'argument with key "%s" has type "%s".', __FUNCTION__.'()', $key, gettype($item))); } } return call_user_func_array('array_merge', $arrayv); } /** * Split a corpus of text into lines. This function splits on "\n", "\r\n", or * a mixture of any of them. * * NOTE: This function does not treat "\r" on its own as a newline because none * of SVN, Git or Mercurial do on any OS. * * @param string Block of text to be split into lines. * @param bool If true, retain line endings in result strings. * @return list List of lines. * * @phutil-external-symbol class PhutilSafeHTML * @phutil-external-symbol function phutil_safe_html */ function phutil_split_lines($corpus, $retain_endings = true) { if (!strlen($corpus)) { return array(''); } // Split on "\r\n" or "\n". if ($retain_endings) { $lines = preg_split('/(?<=\n)/', $corpus); } else { $lines = preg_split('/\r?\n/', $corpus); } // If the text ends with "\n" or similar, we'll end up with an empty string // at the end; discard it. if (end($lines) == '') { array_pop($lines); } if ($corpus instanceof PhutilSafeHTML) { foreach ($lines as $key => $line) { $lines[$key] = phutil_safe_html($line); } return $lines; } return $lines; } /** * Simplifies a common use of `array_combine()`. Specifically, this: * * COUNTEREXAMPLE: * if ($list) { * $result = array_combine($list, $list); * } else { * // Prior to PHP 5.4, array_combine() failed if given empty arrays. * $result = array(); * } * * ...is equivalent to this: * * $result = array_fuse($list); * * @param list List of scalars. * @return dict Dictionary with inputs mapped to themselves. */ function array_fuse(array $list) { if ($list) { return array_combine($list, $list); } return array(); } /** * Add an element between every two elements of some array. That is, given a * list `A, B, C, D`, and some element to interleave, `x`, this function returns * `A, x, B, x, C, x, D`. This works like `implode()`, but does not concatenate * the list into a string. In particular: * * implode('', array_interleave($x, $list)); * * ...is equivalent to: * * implode($x, $list); * * This function does not preserve keys. * * @param wild Element to interleave. * @param list List of elements to be interleaved. * @return list Original list with the new element interleaved. */ function array_interleave($interleave, array $array) { $result = array(); foreach ($array as $item) { $result[] = $item; $result[] = $interleave; } array_pop($result); return $result; } function phutil_is_windows() { // We can also use PHP_OS, but that's kind of sketchy because it returns // "WINNT" for Windows 7 and "Darwin" for Mac OS X. Practically, testing for // DIRECTORY_SEPARATOR is more straightforward. return (DIRECTORY_SEPARATOR != '/'); } function phutil_is_hiphop_runtime() { return (array_key_exists('HPHP', $_ENV) && $_ENV['HPHP'] === 1); } /** * Converts a string to a loggable one, with unprintables and newlines escaped. * * @param string Any string. * @return string String with control and newline characters escaped, suitable * for printing on a single log line. */ function phutil_loggable_string($string) { if (preg_match('/^[\x20-\x7E]+$/', $string)) { return $string; } $result = ''; static $c_map = array( '\\' => '\\\\', "\n" => '\\n', "\r" => '\\r', "\t" => '\\t', ); $len = strlen($string); for ($ii = 0; $ii < $len; $ii++) { $c = $string[$ii]; if (isset($c_map[$c])) { $result .= $c_map[$c]; } else { $o = ord($c); if ($o < 0x20 || $o >= 0x7F) { $result .= '\\x'.sprintf('%02X', $o); } else { $result .= $c; } } } return $result; } /** * Perform an `fwrite()` which distinguishes between EAGAIN and EPIPE. * * PHP's `fwrite()` is broken, and never returns `false` for writes to broken * nonblocking pipes: it always returns 0, and provides no straightforward * mechanism for distinguishing between EAGAIN (buffer is full, can't write any * more right now) and EPIPE or similar (no write will ever succeed). * * See: https://bugs.php.net/bug.php?id=39598 * * If you call this method instead of `fwrite()`, it will attempt to detect * when a zero-length write is caused by EAGAIN and return `0` only if the * write really should be retried. * * @param resource Socket or pipe stream. * @param string Bytes to write. * @return bool|int Number of bytes written, or `false` on any error (including * errors which `fwrite()` can not detect, like a broken pipe). */ function phutil_fwrite_nonblocking_stream($stream, $bytes) { if (!strlen($bytes)) { return 0; } $result = @fwrite($stream, $bytes); if ($result !== 0) { // In cases where some bytes are witten (`$result > 0`) or // an error occurs (`$result === false`), the behavior of fwrite() is // correct. We can return the value as-is. return $result; } // If we make it here, we performed a 0-length write. Try to distinguish // between EAGAIN and EPIPE. To do this, we're going to `stream_select()` // the stream, write to it again if PHP claims that it's writable, and // consider the pipe broken if the write fails. // (Signals received during the "fwrite()" do not appear to affect anything, // see D20083.) $read = array(); $write = array($stream); $except = array(); $result = @stream_select($read, $write, $except, 0); if ($result === false) { // See T13243. If the select is interrupted by a signal, it may return // "false" indicating an underlying EINTR condition. In this case, the // results (notably, "$write") are not usable because "stream_select()" // didn't update them. // In this case, treat this stream as blocked and tell the caller to // retry, since EINTR is the only condition we're currently aware of that // can cause "fwrite()" to return "0" and "stream_select()" to return // "false" on the same stream. return 0; } if (!$write) { // The stream isn't writable, so we conclude that it probably really is // blocked and the underlying error was EAGAIN. Return 0 to indicate that // no data could be written yet. return 0; } // If we make it here, PHP **just** claimed that this stream is writable, so // perform a write. If the write also fails, conclude that these failures are // EPIPE or some other permanent failure. $result = @fwrite($stream, $bytes); if ($result !== 0) { // The write worked or failed explicitly. This value is fine to return. return $result; } // We performed a 0-length write, were told that the stream was writable, and // then immediately performed another 0-length write. Conclude that the pipe // is broken and return `false`. return false; } /** * Convert a human-readable unit description into a numeric one. This function * allows you to replace this: * * COUNTEREXAMPLE * $ttl = (60 * 60 * 24 * 30); // 30 days * * ...with this: * * $ttl = phutil_units('30 days in seconds'); * * ...which is self-documenting and difficult to make a mistake with. * * @param string Human readable description of a unit quantity. * @return int Quantity of specified unit. */ function phutil_units($description) { $matches = null; if (!preg_match('/^(\d+) (\w+) in (\w+)$/', $description, $matches)) { throw new InvalidArgumentException( pht( 'Unable to parse unit specification (expected a specification in the '. 'form "%s"): %s', '5 days in seconds', $description)); } $quantity = (int)$matches[1]; $src_unit = $matches[2]; $dst_unit = $matches[3]; $is_divisor = false; switch ($dst_unit) { case 'seconds': switch ($src_unit) { case 'second': case 'seconds': $factor = 1; break; case 'minute': case 'minutes': $factor = 60; break; case 'hour': case 'hours': $factor = 60 * 60; break; case 'day': case 'days': $factor = 60 * 60 * 24; break; default: throw new InvalidArgumentException( pht( 'This function can not convert from the unit "%s".', $src_unit)); } break; case 'bytes': switch ($src_unit) { case 'byte': case 'bytes': $factor = 1; break; case 'bit': case 'bits': $factor = 8; $is_divisor = true; break; default: throw new InvalidArgumentException( pht( 'This function can not convert from the unit "%s".', $src_unit)); } break; case 'milliseconds': switch ($src_unit) { case 'second': case 'seconds': $factor = 1000; break; case 'minute': case 'minutes': $factor = 1000 * 60; break; case 'hour': case 'hours': $factor = 1000 * 60 * 60; break; case 'day': case 'days': $factor = 1000 * 60 * 60 * 24; break; default: throw new InvalidArgumentException( pht( 'This function can not convert from the unit "%s".', $src_unit)); } break; case 'microseconds': switch ($src_unit) { case 'second': case 'seconds': $factor = 1000000; break; case 'minute': case 'minutes': $factor = 1000000 * 60; break; case 'hour': case 'hours': $factor = 1000000 * 60 * 60; break; case 'day': case 'days': $factor = 1000000 * 60 * 60 * 24; break; default: throw new InvalidArgumentException( pht( 'This function can not convert from the unit "%s".', $src_unit)); } break; default: throw new InvalidArgumentException( pht( 'This function can not convert into the unit "%s".', $dst_unit)); } if ($is_divisor) { if ($quantity % $factor) { throw new InvalidArgumentException( pht( '"%s" is not an exact quantity.', $description)); } return (int)($quantity / $factor); } else { return $quantity * $factor; } } /** * Compute the number of microseconds that have elapsed since an earlier * timestamp (from `microtime(true)`). * * @param double Microsecond-precision timestamp, from `microtime(true)`. * @return int Elapsed microseconds. */ function phutil_microseconds_since($timestamp) { if (!is_float($timestamp)) { throw new Exception( pht( 'Argument to "phutil_microseconds_since(...)" should be a value '. 'returned from "microtime(true)".')); } $delta = (microtime(true) - $timestamp); $delta = 1000000 * $delta; $delta = (int)$delta; return $delta; } /** * Decode a JSON dictionary. * * @param string A string which ostensibly contains a JSON-encoded list or * dictionary. * @return mixed Decoded list/dictionary. */ function phutil_json_decode($string) { $result = @json_decode($string, true); if (!is_array($result)) { // Failed to decode the JSON. Try to use @{class:PhutilJSONParser} instead. // This will probably fail, but will throw a useful exception. $parser = new PhutilJSONParser(); $result = $parser->parse($string); } return $result; } /** * Encode a value in JSON, raising an exception if it can not be encoded. * * @param wild A value to encode. * @return string JSON representation of the value. */ function phutil_json_encode($value) { $result = @json_encode($value); if ($result === false) { $reason = phutil_validate_json($value); if (function_exists('json_last_error')) { $err = json_last_error(); if (function_exists('json_last_error_msg')) { $msg = json_last_error_msg(); $extra = pht('#%d: %s', $err, $msg); } else { $extra = pht('#%d', $err); } } else { $extra = null; } if ($extra) { $message = pht( 'Failed to JSON encode value (%s): %s.', $extra, $reason); } else { $message = pht( 'Failed to JSON encode value: %s.', $reason); } throw new Exception($message); } return $result; } /** * Produce a human-readable explanation why a value can not be JSON-encoded. * * @param wild Value to validate. * @param string Path within the object to provide context. * @return string|null Explanation of why it can't be encoded, or null. */ function phutil_validate_json($value, $path = '') { if ($value === null) { return; } if ($value === true) { return; } if ($value === false) { return; } if (is_int($value)) { return; } if (is_float($value)) { return; } if (is_array($value)) { foreach ($value as $key => $subvalue) { if (strlen($path)) { $full_key = $path.' > '; } else { $full_key = ''; } if (!phutil_is_utf8($key)) { $full_key = $full_key.phutil_utf8ize($key); return pht( 'Dictionary key "%s" is not valid UTF8, and cannot be JSON encoded.', $full_key); } $full_key .= $key; $result = phutil_validate_json($subvalue, $full_key); if ($result !== null) { return $result; } } } if (is_string($value)) { if (!phutil_is_utf8($value)) { $display = substr($value, 0, 256); $display = phutil_utf8ize($display); if (!strlen($path)) { return pht( 'String value is not valid UTF8, and can not be JSON encoded: %s', $display); } else { return pht( 'Dictionary value at key "%s" is not valid UTF8, and cannot be '. 'JSON encoded: %s', $path, $display); } } } return; } /** * Decode an INI string. * * @param string * @return mixed */ function phutil_ini_decode($string) { $results = null; $trap = new PhutilErrorTrap(); try { $have_call = false; if (function_exists('parse_ini_string')) { if (defined('INI_SCANNER_RAW')) { $results = @parse_ini_string($string, true, INI_SCANNER_RAW); $have_call = true; } } if (!$have_call) { throw new PhutilMethodNotImplementedException( pht( '%s is not compatible with your version of PHP (%s). This function '. 'is only supported on PHP versions newer than 5.3.0.', __FUNCTION__, phpversion())); } if ($results === false) { throw new PhutilINIParserException(trim($trap->getErrorsAsString())); } foreach ($results as $section => $result) { if (!is_array($result)) { // We JSON decode the value in ordering to perform the following // conversions: // // - `'true'` => `true` // - `'false'` => `false` // - `'123'` => `123` // - `'1.234'` => `1.234` // $result = json_decode($result, true); if ($result !== null && !is_array($result)) { $results[$section] = $result; } continue; } foreach ($result as $key => $value) { $value = json_decode($value, true); if ($value !== null && !is_array($value)) { $results[$section][$key] = $value; } } } } catch (Exception $ex) { $trap->destroy(); throw $ex; } $trap->destroy(); return $results; } /** * Attempt to censor any plaintext credentials from a string. * * The major use case here is to censor usernames and passwords from command * output. For example, when `git fetch` fails, the output includes credentials * for authenticated HTTP remotes. * * @param string Some block of text. * @return string A similar block of text, but with credentials that could * be identified censored. */ function phutil_censor_credentials($string) { return preg_replace(',(?<=://)([^/@\s]+)(?=@|$),', '********', $string); } /** * Returns a parsable string representation of a variable. * * This function is intended to behave similarly to PHP's `var_export` function, * but the output is intended to follow our style conventions. * * @param wild The variable you want to export. * @return string */ function phutil_var_export($var) { // `var_export(null, true)` returns `"NULL"` (in uppercase). if ($var === null) { return 'null'; } // PHP's `var_export` doesn't format arrays very nicely. In particular: // // - An empty array is split over two lines (`"array (\n)"`). // - A space separates "array" and the first opening brace. // - Non-associative arrays are returned as associative arrays with an // integer key. // if (is_array($var)) { if (count($var) === 0) { return 'array()'; } // Don't show keys for non-associative arrays. $show_keys = !phutil_is_natural_list($var); $output = array(); $output[] = 'array('; foreach ($var as $key => $value) { // Adjust the indentation of the value. $value = str_replace("\n", "\n ", phutil_var_export($value)); $output[] = ' '. ($show_keys ? var_export($key, true).' => ' : ''). $value.','; } $output[] = ')'; return implode("\n", $output); } // Let PHP handle everything else. return var_export($var, true); } /** * An improved version of `fnmatch`. * * @param string A glob pattern. * @param string A path. * @return bool */ function phutil_fnmatch($glob, $path) { // Modify the glob to allow `**/` to match files in the root directory. $glob = preg_replace('@(?:(? Dictionary of parameters. * @return string HTTP query string. */ function phutil_build_http_querystring(array $parameters) { $pairs = array(); foreach ($parameters as $key => $value) { $pairs[] = array($key, $value); } return phutil_build_http_querystring_from_pairs($pairs); } /** * Build a query string from a list of parameter pairs. * * @param list> List of pairs. * @return string HTTP query string. */ function phutil_build_http_querystring_from_pairs(array $pairs) { // We want to encode in RFC3986 mode, but "http_build_query()" did not get // a flag for that mode until PHP 5.4.0. This is equivalent to calling // "http_build_query()" with the "PHP_QUERY_RFC3986" flag. $query = array(); foreach ($pairs as $pair_key => $pair) { if (!is_array($pair) || (count($pair) !== 2)) { throw new Exception( pht( 'HTTP parameter pair (with key "%s") is not valid: each pair must '. 'be an array with exactly two elements.', $pair_key)); } list($key, $value) = $pair; list($key, $value) = phutil_http_parameter_pair($key, $value); $query[] = rawurlencode($key).'='.rawurlencode($value); } $query = implode('&', $query); return $query; } /** * Typecheck and cast an HTTP key-value parameter pair. * * Scalar values are converted to strings. Nonscalar values raise exceptions. * * @param scalar HTTP parameter key. * @param scalar HTTP parameter value. * @return pair Key and value as strings. */ function phutil_http_parameter_pair($key, $value) { try { assert_stringlike($key); } catch (InvalidArgumentException $ex) { throw new PhutilProxyException( pht('HTTP query parameter key must be a scalar.'), $ex); } $key = phutil_string_cast($key); try { assert_stringlike($value); } catch (InvalidArgumentException $ex) { throw new PhutilProxyException( pht( 'HTTP query parameter value (for key "%s") must be a scalar.', $key), $ex); } $value = phutil_string_cast($value); return array($key, $value); } function phutil_decode_mime_header($header) { if (function_exists('iconv_mime_decode')) { return iconv_mime_decode($header, 0, 'UTF-8'); } if (function_exists('mb_decode_mimeheader')) { return mb_decode_mimeheader($header); } throw new Exception( pht( 'Unable to decode MIME header: install "iconv" or "mbstring" '. 'extension.')); } /** * Perform a "(string)" cast without disabling standard exception behavior. * * When PHP invokes "__toString()" automatically, it fatals if the method * raises an exception. In older versions of PHP (until PHP 7.1), this fatal is * fairly opaque and does not give you any information about the exception * itself, although newer versions of PHP at least include the exception * message. * * This is documented on the "__toString()" manual page: * * Warning * You cannot throw an exception from within a __toString() method. Doing * so will result in a fatal error. * * However, this only applies to implicit invocation by the language runtime. * Application code can safely call `__toString()` directly without any effect * on exception handling behavior. Very cool. * * We also reject arrays. PHP casts them to the string "Array". This behavior * is, charitably, evil. * * @param wild Any value which aspires to be represented as a string. * @return string String representation of the provided value. */ function phutil_string_cast($value) { if (is_array($value)) { throw new Exception( pht( 'Value passed to "phutil_string_cast()" is an array; arrays can '. 'not be sensibly cast to strings.')); } if (is_object($value)) { $string = $value->__toString(); if (!is_string($string)) { throw new Exception( pht( 'Object (of class "%s") did not return a string from "__toString()".', get_class($value))); } return $string; } return (string)$value; } /** * Return a short, human-readable description of an object's type. * * This is mostly useful for raising errors like "expected x() to return a Y, * but it returned a Z". * * This is similar to "get_type()", but describes objects and arrays in more * detail. * * @param wild Anything. * @return string Human-readable description of the value's type. */ function phutil_describe_type($value) { return PhutilTypeSpec::getTypeOf($value); } /** * Test if a list has the natural numbers (1, 2, 3, and so on) as keys, in * order. * * @return bool True if the list is a natural list. */ function phutil_is_natural_list(array $list) { $expect = 0; foreach ($list as $key => $item) { if ($key !== $expect) { return false; } $expect++; } return true; } /** * Escape text for inclusion in a URI or a query parameter. Note that this * method does NOT escape '/', because "%2F" is invalid in paths and Apache * will automatically 404 the page if it's present. This will produce correct * (the URIs will work) and desirable (the URIs will be readable) behavior in * these cases: * * '/path/?param='.phutil_escape_uri($string); # OK: Query Parameter * '/path/to/'.phutil_escape_uri($string); # OK: URI Suffix * * It will potentially produce the WRONG behavior in this special case: * * COUNTEREXAMPLE * '/path/to/'.phutil_escape_uri($string).'/thing/'; # BAD: URI Infix * * In this case, any '/' characters in the string will not be escaped, so you * will not be able to distinguish between the string and the suffix (unless * you have more information, like you know the format of the suffix). For infix * URI components, use @{function:phutil_escape_uri_path_component} instead. * * @param string Some string. * @return string URI encoded string, except for '/'. */ function phutil_escape_uri($string) { return str_replace('%2F', '/', rawurlencode($string)); } /** * Escape text for inclusion as an infix URI substring. See discussion at * @{function:phutil_escape_uri}. This function covers an unusual special case; * @{function:phutil_escape_uri} is usually the correct function to use. * * This function will escape a string into a format which is safe to put into * a URI path and which does not contain '/' so it can be correctly parsed when * embedded as a URI infix component. * * However, you MUST decode the string with * @{function:phutil_unescape_uri_path_component} before it can be used in the * application. * * @param string Some string. * @return string URI encoded string that is safe for infix composition. */ function phutil_escape_uri_path_component($string) { return rawurlencode(rawurlencode($string)); } /** * Unescape text that was escaped by * @{function:phutil_escape_uri_path_component}. See * @{function:phutil_escape_uri} for discussion. * * Note that this function is NOT the inverse of * @{function:phutil_escape_uri_path_component}! It undoes additional escaping * which is added to survive the implied unescaping performed by the webserver * when interpreting the request. * * @param string Some string emitted * from @{function:phutil_escape_uri_path_component} and * then accessed via a web server. * @return string Original string. */ function phutil_unescape_uri_path_component($string) { return rawurldecode($string); } function phutil_is_noninteractive() { if (function_exists('posix_isatty') && !posix_isatty(STDIN)) { return true; } return false; } function phutil_is_interactive() { if (function_exists('posix_isatty') && posix_isatty(STDIN)) { return true; } return false; } function phutil_encode_log($message) { return addcslashes($message, "\0..\37\\\177..\377"); } /** * Insert a value in between each pair of elements in a list. * * Keys in the input list are preserved. */ function phutil_glue(array $list, $glue) { if (!$list) { return $list; } $last_key = last_key($list); $keys = array(); $values = array(); $tmp = $list; foreach ($list as $key => $ignored) { $keys[] = $key; if ($key !== $last_key) { $tmp[] = $glue; $keys[] = last_key($tmp); } } return array_select_keys($tmp, $keys); } + +function phutil_partition(array $map) { + $partitions = array(); + + $partition = array(); + $is_first = true; + $partition_value = null; + + foreach ($map as $key => $value) { + if (!$is_first) { + if ($partition_value === $value) { + $partition[$key] = $value; + continue; + } + + $partitions[] = $partition; + } + + $is_first = false; + $partition = array($key => $value); + $partition_value = $value; + } + + if ($partition) { + $partitions[] = $partition; + } + + return $partitions; +} diff --git a/src/work/ArcanistGitWorkEngine.php b/src/work/ArcanistGitWorkEngine.php new file mode 100644 index 00000000..ae5238bd --- /dev/null +++ b/src/work/ArcanistGitWorkEngine.php @@ -0,0 +1,57 @@ +getRepositoryAPI(); + + // NOTE: In Git, we're trying to find the current branch name because the + // behavior of "--track" depends on the symbol we pass. + + $marker = $api->newMarkerRefQuery() + ->withIsActive(true) + ->withMarkerTypes(array(ArcanistMarkerRef::TYPE_BRANCH)) + ->executeOne(); + if ($marker) { + return $marker->getName(); + } + + return $api->getWorkingCopyRevision(); + } + + protected function newMarker($symbol, $start) { + $api = $this->getRepositoryAPI(); + $log = $this->getLogEngine(); + + $log->writeStatus( + pht('NEW BRANCH'), + pht( + 'Creating new branch "%s" from "%s".', + $symbol, + $start)); + + $future = $api->newFuture( + 'checkout --track -b %s %s --', + $symbol, + $start); + $future->resolve(); + } + + protected function moveToMarker(ArcanistMarkerRef $marker) { + $api = $this->getRepositoryAPI(); + $log = $this->getLogEngine(); + + $log->writeStatus( + pht('BRANCH'), + pht( + 'Checking out branch "%s".', + $marker->getName())); + + $future = $api->newFuture( + 'checkout %s --', + $marker->getName()); + $future->resolve(); + } + +} diff --git a/src/work/ArcanistMercurialWorkEngine.php b/src/work/ArcanistMercurialWorkEngine.php new file mode 100644 index 00000000..83aa8b71 --- /dev/null +++ b/src/work/ArcanistMercurialWorkEngine.php @@ -0,0 +1,56 @@ +getRepositoryAPI(); + return $api->getWorkingCopyRevision(); + } + + protected function newMarker($symbol, $start) { + $api = $this->getRepositoryAPI(); + $log = $this->getLogEngine(); + + $log->writeStatus( + pht('NEW BOOKMARK'), + pht( + 'Creating new bookmark "%s" from "%s".', + $symbol, + $start)); + + if ($start !== $this->getDefaultStartSymbol()) { + $future = $api->newFuture('update -- %s', $start); + $future->resolve(); + } + + $future = $api->newFuture('bookmark %s --', $symbol); + $future->resolve(); + } + + protected function moveToMarker(ArcanistMarkerRef $marker) { + $api = $this->getRepositoryAPI(); + $log = $this->getLogEngine(); + + if ($marker->isBookmark()) { + $log->writeStatus( + pht('BOOKMARK'), + pht( + 'Checking out bookmark "%s".', + $marker->getName())); + } else { + $log->writeStatus( + pht('BRANCH'), + pht( + 'Checking out branch "%s".', + $marker->getName())); + } + + $future = $api->newFuture( + 'checkout %s --', + $marker->getName()); + + $future->resolve(); + } + +} diff --git a/src/work/ArcanistWorkEngine.php b/src/work/ArcanistWorkEngine.php new file mode 100644 index 00000000..531270b8 --- /dev/null +++ b/src/work/ArcanistWorkEngine.php @@ -0,0 +1,215 @@ +symbolArgument = $symbol_argument; + return $this; + } + + final public function getSymbolArgument() { + return $this->symbolArgument; + } + + final public function setStartArgument($start_argument) { + $this->startArgument = $start_argument; + return $this; + } + + final public function getStartArgument() { + return $this->startArgument; + } + + final public function execute() { + $workflow = $this->getWorkflow(); + $api = $this->getRepositoryAPI(); + + $local_state = $api->newLocalState() + ->setWorkflow($workflow) + ->saveLocalState(); + + $symbol = $this->getSymbolArgument(); + + $markers = $api->newMarkerRefQuery() + ->withNames(array($symbol)) + ->execute(); + + if ($markers) { + if (count($markers) > 1) { + + // TODO: This almost certainly means the symbol is a Mercurial branch + // with multiple heads. We can pick some head. + + throw new PhutilArgumentUsageException( + pht( + 'Symbol "%s" is ambiguous.', + $symbol)); + } + + $target = head($markers); + $this->moveToMarker($target); + $local_state->discardLocalState(); + return; + } + + $revision_marker = $this->workOnRevision($symbol); + if ($revision_marker) { + $this->moveToMarker($revision_marker); + $local_state->discardLocalState(); + return; + } + + $task_marker = $this->workOnTask($symbol); + if ($task_marker) { + $this->moveToMarker($task_marker); + $local_state->discardLocalState(); + return; + } + + // NOTE: We're resolving this symbol so we can raise an error message if + // it's bogus, but we're using the symbol (not the resolved version) to + // actually create the new marker. This matters in Git because it impacts + // the behavior of "--track" when we pass a branch name. + + $start = $this->getStartArgument(); + if ($start !== null) { + $start_commit = $api->getCanonicalRevisionName($start); + if (!$start_commit) { + throw new PhutilArgumentUsageException( + pht( + 'Unable to resolve startpoint "%s".', + $start)); + } + } else { + $start = $this->getDefaultStartSymbol(); + } + + $this->newMarker($symbol, $start); + $local_state->discardLocalState(); + } + + abstract protected function newMarker($symbol, $start); + abstract protected function moveToMarker(ArcanistMarkerRef $marker); + abstract protected function getDefaultStartSymbol(); + + private function workOnRevision($symbol) { + $workflow = $this->getWorkflow(); + $api = $this->getRepositoryAPI(); + $log = $this->getLogEngine(); + + try { + $revision_symbol = id(new ArcanistRevisionSymbolRef()) + ->setSymbol($symbol); + } catch (Exception $ex) { + return; + } + + $workflow->loadHardpoints( + $revision_symbol, + ArcanistSymbolRef::HARDPOINT_OBJECT); + + $revision_ref = $revision_symbol->getObject(); + if (!$revision_ref) { + throw new PhutilArgumentUsageException( + pht( + 'No revision "%s" exists, or you do not have permission to '. + 'view it.', + $symbol)); + } + + $markers = $api->newMarkerRefQuery() + ->execute(); + + $state_refs = mpull($markers, 'getWorkingCopyStateRef'); + + $workflow->loadHardpoints( + $state_refs, + ArcanistWorkingCopyStateRef::HARDPOINT_REVISIONREFS); + + $selected = array(); + foreach ($markers as $marker) { + $state_ref = $marker->getWorkingCopyStateRef(); + $revision_refs = $state_ref->getRevisionRefs(); + $revision_refs = mpull($revision_refs, null, 'getPHID'); + + if (isset($revision_refs[$revision_ref->getPHID()])) { + $selected[] = $marker; + } + } + + if (!$selected) { + + // TODO: We could patch/load here. + + throw new PhutilArgumentUsageException( + pht( + 'Revision "%s" was not found anywhere in this working copy.', + $revision_ref->getMonogram())); + } + + if (count($selected) > 1) { + $selected = msort($selected, 'getEpoch'); + + echo tsprintf( + "\n%!\n%W\n\n", + pht('AMBIGUOUS MARKER'), + pht( + 'More than one marker in the local working copy is associated '. + 'with the revision "%s", using the most recent one.', + $revision_ref->getMonogram())); + + foreach ($selected as $marker) { + echo tsprintf('%s', $marker->newRefView()); + } + + echo tsprintf("\n"); + + $target = last($selected); + } else { + $target = head($selected); + } + + $log->writeStatus( + pht('REVISION'), + pht('Resuming work on revision:')); + + echo tsprintf('%s', $revision_ref->newRefView()); + echo tsprintf("\n"); + + return $target; + } + + private function workOnTask($symbol) { + $workflow = $this->getWorkflow(); + + try { + $task_symbol = id(new ArcanistTaskSymbolRef()) + ->setSymbol($symbol); + } catch (Exception $ex) { + return; + } + + $workflow->loadHardpoints( + $task_symbol, + ArcanistSymbolRef::HARDPOINT_OBJECT); + + $task_ref = $task_symbol->getObject(); + if (!$task_ref) { + throw new PhutilArgumentUsageException( + pht( + 'No task "%s" exists, or you do not have permission to view it.', + $symbol)); + } + + throw new Exception(pht('TODO: Implement this workflow.')); + + $this->loadHardpoints( + $task_ref, + ArcanistTaskRef::HARDPOINT_REVISIONREFS); + } + +} diff --git a/src/workflow/ArcanistAmendWorkflow.php b/src/workflow/ArcanistAmendWorkflow.php index be1cf25f..33288852 100644 --- a/src/workflow/ArcanistAmendWorkflow.php +++ b/src/workflow/ArcanistAmendWorkflow.php @@ -1,278 +1,278 @@ newWorkflowInformation() ->setSynopsis( pht('Amend the working copy, synchronizing the local commit message.')) ->addExample('**amend** [options] -- ') ->setHelp($help); } public function getWorkflowArguments() { return array( $this->newWorkflowArgument('show') ->setHelp( pht( 'Show the amended commit message, without modifying the '. 'working copy.')), $this->newWorkflowArgument('revision') ->setParameter('id') ->setHelp( pht( 'Use the message from a specific revision. If you do not specify '. 'a revision, arc will guess which revision is in the working '. 'copy.')), ); } protected function newPrompts() { return array( $this->newPrompt('arc.amend.unrelated') ->setDescription( pht( 'Confirms use of a revision that does not appear to be '. 'present in the working copy.')), $this->newPrompt('arc.amend.author') ->setDescription( pht( 'Confirms use of a revision that you are not the author '. 'of.')), $this->newPrompt('arc.amend.immutable') ->setDescription( pht( 'Confirms history mutation in a working copy marked as '. 'immutable.')), ); } public function runWorkflow() { $symbols = $this->getSymbolEngine(); $is_show = $this->getArgument('show'); $repository_api = $this->getRepositoryAPI(); if (!$is_show) { $this->requireAmendSupport($repository_api); } $revision_symbol = $this->getArgument('revision'); // We only care about the local working copy state if we need it to // figure out which revision we're operating on, or we're planning to // mutate it. If the caller is running "arc amend --show --revision X", // the local state does not matter. $need_state = ($revision_symbol === null) || (!$is_show); if ($need_state) { $state_ref = $repository_api->getCurrentWorkingCopyStateRef(); $this->loadHardpoints( $state_ref, ArcanistWorkingCopyStateRef::HARDPOINT_REVISIONREFS); $revision_refs = $state_ref->getRevisionRefs(); } if ($revision_symbol === null) { $revision_ref = $this->selectRevisionRef($revision_refs); } else { $revision_ref = $symbols->loadRevisionForSymbol($revision_symbol); if (!$revision_ref) { throw new PhutilArgumentUsageException( pht( 'Revision "%s" does not exist, or you do not have permission '. 'to see it.', $revision_symbol)); } } if (!$is_show) { echo tsprintf( "%s\n\n%s\n", pht('Amending commit message to reflect revision:'), - $revision_ref->newDisplayRef()); + $revision_ref->newRefView()); $this->confirmAmendAuthor($revision_ref); $this->confirmAmendNotFound($revision_ref, $state_ref); } $this->loadHardpoints( $revision_ref, ArcanistRevisionRef::HARDPOINT_COMMITMESSAGE); $message = $revision_ref->getCommitMessage(); if ($is_show) { echo tsprintf( "%B\n", $message); } else { $repository_api->amendCommit($message); } return 0; } private function requireAmendSupport(ArcanistRepositoryAPI $api) { if (!$api->supportsAmend()) { if ($api instanceof ArcanistMercurialAPI) { throw new PhutilArgumentUsageException( pht( '"arc amend" is only supported under Mercurial 2.2 or newer. '. 'Older versions of Mercurial do not support the "--amend" flag '. 'to "hg commit ...", which this workflow requires.')); } throw new PhutilArgumentUsageException( pht( '"arc amend" must be run from inside a working copy of a '. 'repository using a version control system that supports '. 'amending commits, like Git or Mercurial.')); } if ($this->isHistoryImmutable()) { echo tsprintf( "%!\n\n%W\n", pht('IMMUTABLE WORKING COPY'), pht( 'This working copy is configured to have an immutable local '. 'history, using the "history.immutable" configuration option. '. 'Amending the working copy will mutate local history.')); $prompt = pht('Are you sure you want to mutate history?'); $this->getPrompt('arc.amend.immutable') ->setQuery($prompt) ->execute(); } return; if ($api->getUncommittedChanges()) { // TODO: Make this class of error show the uncommitted changes. // TODO: This only needs to check for staged-but-uncommitted changes. // We can safely amend with untracked and unstaged changes. throw new PhutilArgumentUsageException( pht( 'You have uncommitted changes in this working copy. Commit or '. 'revert them before proceeding.')); } } private function selectRevisionRef(array $revisions) { if (!$revisions) { throw new PhutilArgumentUsageException( pht( 'No revision specified with "--revision", and no revisions found '. 'that match the current working copy state. Use "--revision " '. 'to specify which revision you want to amend.')); } if (count($revisions) > 1) { echo tsprintf( "%!\n%W\n\n%B\n", pht('MULTIPLE REVISIONS IN WORKING COPY'), pht('More than one revision was found in the working copy:'), - mpull($revisions, 'newDisplayRef')); + mpull($revisions, 'newRefView')); throw new PhutilArgumentUsageException( pht( 'Use "--revision " to specify which revision you want '. 'to amend.')); } return head($revisions); } private function confirmAmendAuthor(ArcanistRevisionRef $revision_ref) { $viewer = $this->getViewer(); $viewer_phid = $viewer->getPHID(); $author_phid = $revision_ref->getAuthorPHID(); if ($viewer_phid === $author_phid) { return; } $symbols = $this->getSymbolEngine(); $author_ref = $symbols->loadUserForSymbol($author_phid); if (!$author_ref) { // If we don't have any luck loading the author, skip this warning. return; } echo tsprintf( "%!\n\n%W\n\n%s", pht('NOT REVISION AUTHOR'), array( pht( 'You are amending the working copy using information from '. 'a revision you are not the author of.'), "\n\n", pht( 'The author of this revision (%s) is:', $revision_ref->getMonogram()), ), - $author_ref->newDisplayRef()); + $author_ref->newRefView()); $prompt = pht( 'Amend working copy using revision owned by %s?', $author_ref->getMonogram()); $this->getPrompt('arc.amend.author') ->setQuery($prompt) ->execute(); } private function confirmAmendNotFound( ArcanistRevisionRef $revision_ref, ArcanistWorkingCopyStateRef $state_ref) { $local_refs = $state_ref->getRevisionRefs(); $local_refs = mpull($local_refs, null, 'getPHID'); $revision_phid = $revision_ref->getPHID(); $is_local = isset($local_refs[$revision_phid]); if ($is_local) { return; } echo tsprintf( "%!\n\n%W\n", pht('UNRELATED REVISION'), pht( 'You are amending the working copy using information from '. 'a revision that does not appear to be associated with the '. 'current state of the working copy.')); $prompt = pht( 'Amend working copy using unrelated revision %s?', $revision_ref->getMonogram()); $this->getPrompt('arc.amend.unrelated') ->setQuery($prompt) ->execute(); } } diff --git a/src/workflow/ArcanistBookmarkWorkflow.php b/src/workflow/ArcanistBookmarkWorkflow.php deleted file mode 100644 index 49f56de6..00000000 --- a/src/workflow/ArcanistBookmarkWorkflow.php +++ /dev/null @@ -1,10 +0,0 @@ -newWorkflowInformation() + ->setSynopsis( + pht('Show an enhanced view of bookmarks in the working copy.')) + ->addExample(pht('**bookmarks**')) + ->setHelp($help); + } + + protected function getWorkflowMarkerType() { + $api = $this->getRepositoryAPI(); + $marker_type = ArcanistMarkerRef::TYPE_BOOKMARK; + + if (!$this->hasMarkerTypeSupport($marker_type)) { + throw new PhutilArgumentUsageException( + pht( + 'The version control system ("%s") in the current working copy '. + 'does not support bookmarks.', + $api->getSourceControlSystemName())); + } + + return $marker_type; + } + +} diff --git a/src/workflow/ArcanistBranchWorkflow.php b/src/workflow/ArcanistBranchWorkflow.php deleted file mode 100644 index fe73532c..00000000 --- a/src/workflow/ArcanistBranchWorkflow.php +++ /dev/null @@ -1,10 +0,0 @@ -newWorkflowInformation() + ->setSynopsis( + pht('Show an enhanced view of branches in the working copy.')) + ->addExample(pht('**branches**')) + ->setHelp($help); + } + + protected function getWorkflowMarkerType() { + $api = $this->getRepositoryAPI(); + $marker_type = ArcanistMarkerRef::TYPE_BRANCH; + + if (!$this->hasMarkerTypeSupport($marker_type)) { + throw new PhutilArgumentUsageException( + pht( + 'The version control system ("%s") in the current working copy '. + 'does not support branches.', + $api->getSourceControlSystemName())); + } + + return $marker_type; + } + +} diff --git a/src/workflow/ArcanistCallConduitWorkflow.php b/src/workflow/ArcanistCallConduitWorkflow.php index f5b302ae..025be3e1 100644 --- a/src/workflow/ArcanistCallConduitWorkflow.php +++ b/src/workflow/ArcanistCallConduitWorkflow.php @@ -1,79 +1,78 @@ newWorkflowInformation() ->setSynopsis(pht('Call Conduit API methods.')) ->addExample('**call-conduit** -- __method__') ->setHelp($help); } public function getWorkflowArguments() { return array( $this->newWorkflowArgument('method') ->setWildcard(true), ); } public function runWorkflow() { $method = $this->getArgument('method'); if (count($method) !== 1) { throw new PhutilArgumentUsageException( pht('Provide exactly one Conduit method name to call.')); } $method = head($method); $params = $this->readStdin(); try { $params = phutil_json_decode($params); } catch (PhutilJSONParserException $ex) { throw new ArcanistUsageException( pht('Provide method parameters on stdin as a JSON blob.')); } $engine = $this->getConduitEngine(); - $conduit_call = $engine->newCall($method, $params); - $conduit_future = $engine->newFuture($conduit_call); + $conduit_future = $engine->newFuture($method, $params); $error = null; $error_message = null; try { $result = $conduit_future->resolve(); } catch (ConduitClientException $ex) { $error = $ex->getErrorCode(); $error_message = $ex->getMessage(); $result = null; } echo id(new PhutilJSON())->encodeFormatted( array( 'error' => $error, 'errorMessage' => $error_message, 'response' => $result, )); return 0; } } diff --git a/src/workflow/ArcanistDiffWorkflow.php b/src/workflow/ArcanistDiffWorkflow.php index 12ff5bb5..7997458d 100644 --- a/src/workflow/ArcanistDiffWorkflow.php +++ b/src/workflow/ArcanistDiffWorkflow.php @@ -1,3086 +1,2879 @@ null, 'unit' => null); private $testResults; private $diffID; private $revisionID; - private $haveUncommittedChanges = false; private $diffPropertyFutures = array(); private $commitMessageFromRevision; private $hitAutotargets; private $revisionTransactions; private $revisionIsDraft; const STAGING_PUSHED = 'pushed'; const STAGING_USER_SKIP = 'user.skip'; const STAGING_DIFF_RAW = 'diff.raw'; const STAGING_REPOSITORY_UNKNOWN = 'repository.unknown'; const STAGING_REPOSITORY_UNAVAILABLE = 'repository.unavailable'; const STAGING_REPOSITORY_UNSUPPORTED = 'repository.unsupported'; const STAGING_REPOSITORY_UNCONFIGURED = 'repository.unconfigured'; const STAGING_CLIENT_UNSUPPORTED = 'client.unsupported'; public function getWorkflowName() { return 'diff'; } public function getCommandSynopses() { return phutil_console_format(<<isRawDiffSource(); } public function requiresConduit() { return true; } public function requiresAuthentication() { return true; } public function requiresRepositoryAPI() { if (!$this->isRawDiffSource()) { return true; } - if ($this->getArgument('use-commit-message')) { - return true; - } - return false; } public function getDiffID() { return $this->diffID; } public function getArguments() { $arguments = array( 'message' => array( 'short' => 'm', 'param' => 'message', 'help' => pht( 'When updating a revision, use the specified message instead of '. 'prompting.'), ), 'message-file' => array( 'short' => 'F', 'param' => 'file', 'paramtype' => 'file', 'help' => pht( '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' => pht('Read revision information from a specific commit.'), - 'conflicts' => array( - 'only' => null, - 'update' => null, - ), - ), 'edit' => array( 'supports' => array( 'git', 'hg', ), 'nosupport' => array( 'svn' => pht('Edit revisions via the web interface when using SVN.'), ), 'help' => pht( 'When updating a revision under git, edit revision information '. 'before updating.'), ), 'raw' => array( 'help' => pht( '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' => pht('%s disables lint.', '--raw'), 'never-apply-patches' => pht('%s disables lint.', '--raw'), - 'advice' => pht('%s disables lint.', '--raw'), - 'lintall' => pht('%s disables lint.', '--raw'), 'create' => pht( '%s and %s both need stdin. Use %s.', '--raw', '--create', '--raw-command'), 'edit' => pht( '%s and %s both need stdin. Use %s.', '--raw', '--edit', '--raw-command'), 'raw-command' => null, ), ), 'raw-command' => array( 'param' => 'command', 'help' => pht( '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' => pht('%s disables lint.', '--raw-command'), 'never-apply-patches' => pht('%s disables lint.', '--raw-command'), - 'advice' => pht('%s disables lint.', '--raw-command'), - 'lintall' => pht('%s disables lint.', '--raw-command'), ), ), 'create' => array( 'help' => pht('Always create a new revision.'), 'conflicts' => array( 'edit' => pht( '%s can not be used with %s.', '--create', '--edit'), 'only' => pht( '%s can not be used with %s.', '--create', '--only'), 'update' => pht( '%s can not be used with %s.', '--create', '--update'), ), ), 'update' => array( 'param' => 'revision_id', 'help' => pht('Always update a specific revision.'), ), 'draft' => array( 'help' => pht( 'Create a draft revision so you can look over your changes before '. 'involving anyone else. Other users will not be notified about the '. 'revision until you later use "Request Review" to publish it. You '. 'can still share the draft by giving someone the link.'), 'conflicts' => array( 'edit' => null, 'only' => null, 'update' => null, ), ), 'nounit' => array( 'help' => pht('Do not run unit tests.'), ), 'nolint' => array( 'help' => pht('Do not run lint.'), 'conflicts' => array( - 'lintall' => pht('%s suppresses lint.', '--nolint'), - 'advice' => pht('%s suppresses lint.', '--nolint'), 'apply-patches' => pht('%s suppresses lint.', '--nolint'), 'never-apply-patches' => pht('%s suppresses lint.', '--nolint'), ), ), 'only' => array( 'help' => pht( 'Instead of creating or updating a revision, only create a diff, '. 'which you may later attach to a revision.'), 'conflicts' => array( 'edit' => pht('%s does affect revisions.', '--only'), 'message' => pht('%s does not update any revision.', '--only'), ), ), 'allow-untracked' => array( 'help' => pht('Skip checks for untracked files in the working copy.'), ), - 'excuse' => array( - 'param' => 'excuse', - 'help' => pht( - 'Provide a prepared in advance excuse for any lints/tests '. - 'shall they fail.'), - ), - 'less-context' => array( - 'help' => pht( - "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' => pht( - 'Raise all lint warnings, not just those on lines you changed.'), - 'passthru' => array( - 'lint' => true, - ), - ), - 'advice' => array( - 'help' => pht( - 'Require excuse for lint advice in addition to lint warnings and '. - 'errors.'), - ), - 'only-new' => array( - 'param' => 'bool', - 'help' => pht( - 'Display only lint messages not present in the original code.'), - 'passthru' => array( - 'lint' => true, - ), - ), 'apply-patches' => array( 'help' => pht( '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' => pht('Never apply patches suggested by lint.'), 'conflicts' => array( 'apply-patches' => true, ), 'passthru' => array( 'lint' => true, ), ), 'amend-all' => array( 'help' => pht( 'When linting git repositories, amend HEAD with all patches '. 'suggested by lint without prompting.'), 'passthru' => array( 'lint' => true, ), ), 'amend-autofixes' => array( 'help' => pht( 'When linting git repositories, amend HEAD with autofix '. 'patches suggested by lint without prompting.'), 'passthru' => array( 'lint' => true, ), ), 'add-all' => array( 'short' => 'a', 'help' => pht( 'Automatically add all unstaged and uncommitted '. 'files to the commit.'), ), 'json' => array( 'help' => pht( 'Emit machine-readable JSON. EXPERIMENTAL! Probably does not work!'), ), 'no-amend' => array( 'help' => pht( 'Never amend commits in the working copy with lint patches.'), ), 'uncommitted' => array( 'help' => pht('Suppress warning about uncommitted changes.'), 'supports' => array( 'hg', ), ), 'verbatim' => array( 'help' => pht( 'When creating a revision, try to use the working copy commit '. 'message verbatim, without prompting to edit it. When updating a '. 'revision, update some fields from the local commit message.'), 'supports' => array( 'hg', 'git', ), 'conflicts' => array( - 'use-commit-message' => true, 'update' => true, 'only' => true, 'raw' => true, 'raw-command' => true, 'message-file' => true, ), ), 'reviewers' => array( 'param' => 'usernames', 'help' => pht('When creating a revision, add reviewers.'), 'conflicts' => array( 'only' => true, 'update' => true, ), ), 'cc' => array( 'param' => 'usernames', 'help' => pht('When creating a revision, add CCs.'), 'conflicts' => array( 'only' => true, 'update' => true, ), ), 'skip-binaries' => array( 'help' => pht('Do not upload binaries (like images).'), ), 'skip-staging' => array( 'help' => pht('Do not copy changes to the staging area.'), ), - 'ignore-unsound-tests' => array( - 'help' => pht('Ignore unsound test failures without prompting.'), - ), 'base' => array( 'param' => 'rules', 'help' => pht('Additional rules for determining base revision.'), 'nosupport' => array( 'svn' => pht('Subversion does not use base commits.'), ), 'supports' => array('git', 'hg'), ), 'coverage' => array( 'help' => pht('Always enable coverage information.'), 'conflicts' => array( 'no-coverage' => null, ), 'passthru' => array( 'unit' => true, ), ), 'no-coverage' => array( 'help' => pht('Always disable coverage information.'), 'passthru' => array( 'unit' => true, ), ), 'browse' => array( 'help' => pht( 'After creating a diff or revision, open it in a web browser.'), ), '*' => 'paths', 'head' => array( 'param' => 'commit', 'help' => pht( 'Specify the end of the commit range. This disables many '. 'Arcanist/Phabricator features which depend on having access to '. 'the working copy.'), 'supports' => array('git'), 'nosupport' => array( 'svn' => pht('Subversion does not support commit ranges.'), 'hg' => pht('Mercurial does not support %s yet.', '--head'), ), - 'conflicts' => array( - 'lintall' => pht('%s suppresses lint.', '--head'), - 'advice' => pht('%s suppresses lint.', '--head'), - ), ), ); return $arguments; } public function isRawDiffSource() { return $this->getArgument('raw') || $this->getArgument('raw-command'); } public function run() { $this->console = PhutilConsole::getConsole(); $this->runRepositoryAPISetup(); $this->runDiffSetupBasics(); $commit_message = $this->buildCommitMessage(); $this->dispatchEvent( ArcanistEventType::TYPE_DIFF_DIDBUILDMESSAGE, array( 'message' => $commit_message, )); if (!$this->shouldOnlyCreateDiff()) { $revision = $this->buildRevisionFromCommitMessage($commit_message); } - $server = $this->console->getServer(); - $server->setHandler(array($this, 'handleServerMessage')); $data = $this->runLintUnit(); $lint_result = $data['lintResult']; $this->unresolvedLint = $data['unresolvedLint']; $unit_result = $data['unitResult']; $this->testResults = $data['testResults']; - if ($this->getArgument('nolint')) { - $this->excuses['lint'] = $this->getSkipExcuse( - pht('Provide explanation for skipping lint or press Enter to abort:'), - 'lint-excuses'); - } - - if ($this->getArgument('nounit')) { - $this->excuses['unit'] = $this->getSkipExcuse( - pht( - 'Provide explanation for skipping unit tests '. - 'or press Enter to abort:'), - 'unit-excuses'); - } - $changes = $this->generateChanges(); if (!$changes) { throw new ArcanistUsageException( pht('There are no changes to generate a diff from!')); } $diff_spec = array( 'changes' => mpull($changes, 'toDictionary'), 'lintStatus' => $this->getLintStatus($lint_result), 'unitStatus' => $this->getUnitStatus($unit_result), ) + $this->buildDiffSpecification(); $conduit = $this->getConduit(); $diff_info = $conduit->callMethodSynchronous( 'differential.creatediff', $diff_spec); $this->diffID = $diff_info['diffid']; $event = $this->dispatchEvent( ArcanistEventType::TYPE_DIFF_WASCREATED, array( 'diffID' => $diff_info['diffid'], 'lintResult' => $lint_result, 'unitResult' => $unit_result, )); $this->submitChangesToStagingArea($this->diffID); $phid = idx($diff_info, 'phid'); if ($phid) { $this->hitAutotargets = $this->updateAutotargets( $phid, $unit_result); } $this->updateLintDiffProperty(); $this->updateUnitDiffProperty(); $this->updateLocalDiffProperty(); $this->updateOntoDiffProperty(); $this->resolveDiffPropertyUpdates(); $output_json = $this->getArgument('json'); if ($this->shouldOnlyCreateDiff()) { if (!$output_json) { echo phutil_console_format( "%s\n **%s** __%s__\n\n", pht('Created a new Differential diff:'), pht('Diff URI:'), $diff_info['uri']); } else { $human = ob_get_clean(); echo json_encode(array( 'diffURI' => $diff_info['uri'], 'diffID' => $this->getDiffID(), 'human' => $human, ))."\n"; ob_start(); } if ($this->shouldOpenCreatedObjectsInBrowser()) { $this->openURIsInBrowser(array($diff_info['uri'])); } } else { $is_draft = $this->getArgument('draft'); $revision['diffid'] = $this->getDiffID(); if ($commit_message->getRevisionID()) { if ($is_draft) { // TODO: In at least some cases, we could raise this earlier in the // workflow to save users some time before the workflow aborts. if ($this->revisionIsDraft) { $this->writeWarn( pht('ALREADY A DRAFT'), pht( 'You are updating a revision ("%s") with the "--draft" flag, '. 'but this revision is already a draft. You only need to '. 'provide the "--draft" flag when creating a revision. Draft '. 'revisions are not published until you explicitly request '. 'review from the web UI.', $commit_message->getRevisionMonogram())); } else { throw new ArcanistUsageException( pht( 'You are updating a revision ("%s") with the "--draft" flag, '. 'but this revision has already been published for review. '. 'You can not turn a revision back into a draft once it has '. 'been published.', $commit_message->getRevisionMonogram())); } } $result = $conduit->callMethodSynchronous( 'differential.updaterevision', $revision); foreach (array('edit-messages.json', 'update-messages.json') as $file) { $messages = $this->readScratchJSONFile($file); unset($messages[$revision['id']]); $this->writeScratchJSONFile($file, $messages); } $result_uri = $result['uri']; $result_id = $result['revisionid']; echo pht('Updated an existing Differential revision:')."\n"; } else { // NOTE: We're either using "differential.revision.edit" (preferred) // if we can, or falling back to "differential.createrevision" // (the older way) if not. $xactions = $this->revisionTransactions; if ($xactions) { $xactions[] = array( 'type' => 'update', 'value' => $diff_info['phid'], ); if ($is_draft) { $xactions[] = array( 'type' => 'draft', 'value' => true, ); } $result = $conduit->callMethodSynchronous( 'differential.revision.edit', array( 'transactions' => $xactions, )); $result_id = idxv($result, array('object', 'id')); if (!$result_id) { throw new Exception( pht( 'Expected a revision ID to be returned by '. '"differential.revision.edit".')); } // TODO: This is hacky, but we don't currently receive a URI back // from "differential.revision.edit". $result_uri = id(new PhutilURI($this->getConduitURI())) ->setPath('/D'.$result_id); } else { if ($is_draft) { throw new ArcanistUsageException( pht( 'You have specified "--draft", but the version of Phabricator '. 'on the server is too old to support draft revisions. Omit '. 'the flag or upgrade the server software.')); } $revision = $this->dispatchWillCreateRevisionEvent($revision); $result = $conduit->callMethodSynchronous( 'differential.createrevision', $revision); $result_uri = $result['uri']; $result_id = $result['revisionid']; } $revised_message = $conduit->callMethodSynchronous( 'differential.getcommitmessage', array( 'revision_id' => $result_id, )); if ($this->shouldAmend()) { $repository_api = $this->getRepositoryAPI(); if ($repository_api->supportsAmend()) { echo pht('Updating commit message...')."\n"; $repository_api->amendCommit($revised_message); } else { echo pht( 'Commit message was not amended. Amending commit message is '. 'only supported in git and hg (version 2.2 or newer)'); } } echo pht('Created a new Differential revision:')."\n"; } $uri = $result_uri; echo phutil_console_format( " **%s** __%s__\n\n", pht('Revision URI:'), $uri); if ($this->shouldOpenCreatedObjectsInBrowser()) { $this->openURIsInBrowser(array($uri)); } } echo pht('Included changes:')."\n"; foreach ($changes as $change) { echo ' '.$change->renderTextSummary()."\n"; } if ($output_json) { ob_get_clean(); } $this->removeScratchFile('create-message'); return 0; } private function runRepositoryAPISetup() { if (!$this->requiresRepositoryAPI()) { return; } $repository_api = $this->getRepositoryAPI(); - if ($this->getArgument('less-context')) { - $repository_api->setDiffLinesOfContext(3); - } $repository_api->setBaseCommitArgumentRules( $this->getArgument('base', '')); if ($repository_api->supportsCommitRanges()) { $this->parseBaseCommitArgument($this->getArgument('paths')); } $head_commit = $this->getArgument('head'); if ($head_commit !== null) { $repository_api->setHeadCommit($head_commit); } } private function runDiffSetupBasics() { $output_json = $this->getArgument('json'); if ($output_json) { // TODO: We should move this to a higher-level and put an indirection // layer between echoing stuff and stdout. ob_start(); } if ($this->requiresWorkingCopy()) { $repository_api = $this->getRepositoryAPI(); if ($this->getArgument('add-all')) { $this->setCommitMode(self::COMMIT_ENABLE); } else if ($this->getArgument('uncommitted')) { $this->setCommitMode(self::COMMIT_DISABLE); } else { $this->setCommitMode(self::COMMIT_ALLOW); } if ($repository_api instanceof ArcanistSubversionAPI) { $repository_api->limitStatusToPaths($this->getArgument('paths')); } if (!$this->getArgument('head')) { $this->requireCleanWorkingCopy(); } } $this->dispatchEvent( ArcanistEventType::TYPE_DIFF_DIDCOLLECTCHANGES, array()); } private function buildRevisionFromCommitMessage( ArcanistDifferentialCommitMessage $message) { $conduit = $this->getConduit(); $revision_id = $message->getRevisionID(); $revision = array( 'fields' => $message->getFields(), ); $xactions = $message->getTransactions(); if ($revision_id) { // With '--verbatim', pass the (possibly modified) local fields. This // allows the user to edit some fields (like "title" and "summary") // locally without '--edit' and have changes automatically synchronized. // Without '--verbatim', we do not update the revision to reflect local // commit message changes. if ($this->getArgument('verbatim')) { $use_fields = $message->getFields(); } else { $use_fields = array(); } $should_edit = $this->getArgument('edit'); $edit_messages = $this->readScratchJSONFile('edit-messages.json'); $remote_corpus = idx($edit_messages, $revision_id); if (!$should_edit || !$remote_corpus || $use_fields) { if ($this->commitMessageFromRevision) { $remote_corpus = $this->commitMessageFromRevision; } else { $remote_corpus = $conduit->callMethodSynchronous( 'differential.getcommitmessage', array( 'revision_id' => $revision_id, 'edit' => 'edit', 'fields' => $use_fields, )); } } if ($should_edit) { $edited = $this->newInteractiveEditor($remote_corpus) ->setName('differential-edit-revision-info') ->editInteractively(); if ($edited != $remote_corpus) { $remote_corpus = $edited; $edit_messages[$revision_id] = $remote_corpus; $this->writeScratchJSONFile('edit-messages.json', $edit_messages); } } if ($this->commitMessageFromRevision == $remote_corpus) { $new_message = $message; } else { $remote_corpus = ArcanistCommentRemover::removeComments( $remote_corpus); $new_message = ArcanistDifferentialCommitMessage::newFromRawCorpus( $remote_corpus); $new_message->pullDataFromConduit($conduit); } $revision['fields'] = $new_message->getFields(); $xactions = $new_message->getTransactions(); $revision['id'] = $revision_id; $this->revisionID = $revision_id; $revision['message'] = $this->getArgument('message'); if (!strlen($revision['message'])) { $update_messages = $this->readScratchJSONFile('update-messages.json'); $update_messages[$revision_id] = $this->getUpdateMessage( $revision['fields'], idx($update_messages, $revision_id)); $revision['message'] = ArcanistCommentRemover::removeComments( $update_messages[$revision_id]); if (!strlen(trim($revision['message']))) { throw new ArcanistUserAbortException(); } $this->writeScratchJSONFile('update-messages.json', $update_messages); } } $this->revisionTransactions = $xactions; return $revision; } protected function shouldOnlyCreateDiff() { if ($this->getArgument('create')) { return false; } if ($this->getArgument('update')) { return false; } - if ($this->getArgument('use-commit-message')) { - return false; - } - if ($this->isRawDiffSource()) { return true; } return $this->getArgument('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( "%s\n\n%s\n\n", pht( "The working copy includes changes to '%s' paths. These ". "changes will not be included in the diff because SVN can not ". "commit 'svn:externals' changes alongside normal changes.", 'svn:externals'), pht( "Modified '%s' files:", 'svn:externals'), phutil_console_wrap(implode("\n", $warn_externals), 8)); $prompt = pht('Generate a diff (with just local changes) anyway?'); if (!phutil_console_confirm($prompt)) { throw new ArcanistUserAbortException(); } else { $this->hasWarnedExternals = true; } } } else { $paths = $repository_api->getWorkingCopyStatus(); } foreach ($paths as $path => $mask) { if ($mask & ArcanistRepositoryAPI::FLAG_UNTRACKED) { unset($paths[$path]); } } return $paths; } protected function generateChanges() { $parser = $this->newDiffParser(); $is_raw = $this->isRawDiffSource(); if ($is_raw) { if ($this->getArgument('raw')) { fwrite(STDERR, pht('Reading diff from stdin...')."\n"); $raw_diff = file_get_contents('php://stdin'); } else if ($this->getArgument('raw-command')) { list($raw_diff) = execx('%C', $this->getArgument('raw-command')); } else { throw new Exception(pht('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[] = ' '.pht('Revision %s, %s', $baserev, $path); } $revlist = implode("\n", $revlist); foreach ($bases as $path => $baserev) { if ($baserev !== $rev) { throw new ArcanistUsageException( pht( "Base revisions of changed paths are mismatched. Update all ". "paths to the same base revision before creating a diff: ". "\n\n%s", $revlist)); } } // If you have a change which affects several files, all of which are // at a consistent base revision, treat that revision as the effective // base revision. The use case here is that you made a change to some // file, which updates it to HEAD, but want to be able to change it // again without updating the entire working copy. This is a little // sketchy but it arises in Facebook Ops workflows with config files and // doesn't have any real material tradeoffs (e.g., these patches are // perfectly applyable). $repository_api->overrideSVNBaseRevisionNumber($rev); } $changes = $parser->parseSubversionDiff( $repository_api, $paths); } else if ($repository_api instanceof ArcanistGitAPI) { $diff = $repository_api->getFullGitDiff( $repository_api->getBaseCommit(), $repository_api->getHeadCommit()); if (!strlen($diff)) { throw new ArcanistUsageException( pht('No changes found. (Did you specify the wrong commit range?)')); } $changes = $parser->parseDiff($diff); } else if ($repository_api instanceof ArcanistMercurialAPI) { $diff = $repository_api->getFullMercurialDiff(); if (!strlen($diff)) { throw new ArcanistUsageException( pht('No changes found. (Did you specify the wrong commit range?)')); } $changes = $parser->parseDiff($diff); } else { throw new Exception(pht('Repository API is not supported.')); } $limit = 1024 * 1024 * 4; foreach ($changes as $change) { $size = 0; foreach ($change->getHunks() as $hunk) { $size += strlen($hunk->getCorpus()); } if ($size > $limit) { $byte_warning = pht( "Diff for '%s' with context is %s bytes in length. ". "Generally, source changes should not be this large.", $change->getCurrentPath(), new PhutilNumber($size)); - if (!$this->getArgument('less-context')) { - $byte_warning .= ' '.pht( - "If this file is a huge text file, try using the '%s' flag.", - '--less-context'); - } if ($repository_api instanceof ArcanistSubversionAPI) { throw new ArcanistUsageException( $byte_warning.' '. pht( "If the file is not a text file, mark it as binary with:". "\n\n $ %s\n", 'svn propset svn:mime-type application/octet-stream ')); } else { $confirm = $byte_warning.' '.pht( "If the file is not a text file, you can mark it 'binary'. ". "Mark this file as 'binary' and continue?"); if (phutil_console_confirm($confirm)) { $change->convertToBinaryChange($repository_api); } else { throw new ArcanistUsageException( pht('Aborted generation of gigantic diff.')); } } } } $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 { $try_encoding = $this->getRepositoryEncoding(); } catch (ConduitClientException $e) { if ($e->getErrorCode() == 'ERR-BAD-ARCANIST-PROJECT') { echo phutil_console_wrap( pht('Lookup of encoding in arcanist project failed: %s', $e->getMessage())."\n"); } else { throw $e; } } if ($try_encoding) { $corpus = phutil_utf8_convert($corpus, 'UTF-8', $try_encoding); $name = $change->getCurrentPath(); if (phutil_is_utf8($corpus)) { $this->writeStatusMessage( pht( "Converted a '%s' hunk from '%s' to UTF-8.\n", $name, $try_encoding)); $hunk->setCorpus($corpus); continue; } } } $utf8_problems[] = $change; break; } } } // If there are non-binary files which aren't valid UTF-8, warn the user // and treat them as binary changes. See D327 for discussion of why Arcanist // has this behavior. if ($utf8_problems) { $utf8_warning = sprintf( "%s\n\n%s\n\n %s\n", pht( 'This diff includes %s file(s) which are not valid UTF-8 (they '. 'contain invalid byte sequences). You can either stop this '. 'workflow and fix these files, or continue. If you continue, '. 'these files will be marked as binary.', phutil_count($utf8_problems)), pht( "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."), pht( '%s AFFECTED FILE(S)', phutil_count($utf8_problems))); $confirm = pht( 'Do you want to mark these %s file(s) as binary and continue?', phutil_count($utf8_problems)); echo phutil_console_format( "**%s**\n", pht('Invalid Content Encoding (Non-UTF8)')); 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(pht('Aborted workflow to fix UTF-8.')); } else { foreach ($utf8_problems as $change) { $change->convertToBinaryChange($repository_api); } } } $this->uploadFilesForChanges($changes); return $changes; } private function getGitParentLogInfo() { $info = array( 'parent' => null, 'base_revision' => null, 'base_path' => null, 'uuid' => null, ); $repository_api = $this->getRepositoryAPI(); $parser = $this->newDiffParser(); $history_messages = $repository_api->getGitHistoryLog(); if (!$history_messages) { // This can occur on the initial commit. return $info; } $history_messages = $parser->parseDiff($history_messages); foreach ($history_messages as $key => $change) { try { $message = ArcanistDifferentialCommitMessage::newFromRawCorpus( $change->getMetadata('message')); if ($message->getRevisionID() && $info['parent'] === null) { $info['parent'] = $message->getRevisionID(); } if ($message->getGitSVNBaseRevision() && $info['base_revision'] === null) { $info['base_revision'] = $message->getGitSVNBaseRevision(); $info['base_path'] = $message->getGitSVNBasePath(); } if ($message->getGitSVNUUID()) { $info['uuid'] = $message->getGitSVNUUID(); } if ($info['parent'] && $info['base_revision']) { break; } } catch (ArcanistDifferentialCommitMessageParserException $ex) { // Ignore. } catch (ArcanistUsageException $ex) { // Ignore an invalid Differential Revision field in the parent commit } } return $info; } protected function primeSubversionWorkingCopyData($paths) { $repository_api = $this->getRepositoryAPI(); $futures = array(); $targets = array(); foreach ($paths as $path => $mask) { $futures[] = $repository_api->buildDiffFuture($path); $targets[] = array('command' => 'diff', 'path' => $path); $futures[] = $repository_api->buildInfoFuture($path); $targets[] = array('command' => 'info', 'path' => $path); } $futures = id(new FutureIterator($futures)) ->limit(8); foreach ($futures as $key => $future) { $target = $targets[$key]; if ($target['command'] == 'diff') { $repository_api->primeSVNDiffResult( $target['path'], $future->resolve()); } else { $repository_api->primeSVNInfoResult( $target['path'], $future->resolve()); } } } private function shouldAmend() { if ($this->isRawDiffSource()) { return false; } - if ($this->haveUncommittedChanges) { - return false; - } - if ($this->getArgument('no-amend')) { return false; } if ($this->getArgument('head') !== null) { return false; } // Run this last: with --raw or --raw-command, we won't have a repository // API. if ($this->isHistoryImmutable()) { return false; } return true; } /* -( Lint and Unit Tests )------------------------------------------------ */ /** * @task lintunit */ private function runLintUnit() { $lint_result = $this->runLint(); $unit_result = $this->runUnit(); return array( 'lintResult' => $lint_result, 'unresolvedLint' => $this->unresolvedLint, 'unitResult' => $unit_result, 'testResults' => $this->testResults, ); } /** * @task lintunit */ private function runLint() { if ($this->getArgument('nolint') || $this->isRawDiffSource() || $this->getArgument('head')) { return ArcanistLintWorkflow::RESULT_SKIP; } $repository_api = $this->getRepositoryAPI(); $this->console->writeOut("%s\n", pht('Linting...')); try { $argv = $this->getPassthruArgumentsAsArgv('lint'); if ($repository_api->supportsCommitRanges()) { $argv[] = '--rev'; $argv[] = $repository_api->getBaseCommit(); } $lint_workflow = $this->buildChildWorkflow('lint', $argv); if ($this->shouldAmend()) { // TODO: We should offer to create a checkpoint commit. $lint_workflow->setShouldAmendChanges(true); } $lint_result = $lint_workflow->run(); switch ($lint_result) { case ArcanistLintWorkflow::RESULT_OKAY: - if ($this->getArgument('advice') && - $lint_workflow->getUnresolvedMessages()) { - $this->getErrorExcuse( - 'lint', - pht('Lint issued unresolved advice.'), - 'lint-excuses'); - } else { - $this->console->writeOut( - "** %s ** %s\n", - pht('LINT OKAY'), - pht('No lint problems.')); - } + $this->console->writeOut( + "** %s ** %s\n", + pht('LINT OKAY'), + pht('No lint problems.')); break; case ArcanistLintWorkflow::RESULT_WARNINGS: - $this->getErrorExcuse( - 'lint', - pht('Lint issued unresolved warnings.'), - 'lint-excuses'); + $this->console->writeOut( + "** %s ** %s\n", + pht('LINT MESSAGES'), + pht('Lint issued unresolved warnings.')); break; case ArcanistLintWorkflow::RESULT_ERRORS: $this->console->writeOut( "** %s ** %s\n", pht('LINT ERRORS'), pht('Lint raised errors!')); - $this->getErrorExcuse( - 'lint', - pht('Lint issued unresolved errors!'), - 'lint-excuses'); break; } $this->unresolvedLint = array(); foreach ($lint_workflow->getUnresolvedMessages() as $message) { $this->unresolvedLint[] = $message->toDictionary(); } return $lint_result; } catch (ArcanistNoEngineException $ex) { $this->console->writeOut( "%s\n", pht('No lint engine configured for this project.')); } catch (ArcanistNoEffectException $ex) { $this->console->writeOut("%s\n", $ex->getMessage()); } return null; } /** * @task lintunit */ private function runUnit() { if ($this->getArgument('nounit') || $this->isRawDiffSource() || $this->getArgument('head')) { return ArcanistUnitWorkflow::RESULT_SKIP; } $repository_api = $this->getRepositoryAPI(); $this->console->writeOut("%s\n", pht('Running unit tests...')); try { $argv = $this->getPassthruArgumentsAsArgv('unit'); if ($repository_api->supportsCommitRanges()) { $argv[] = '--rev'; $argv[] = $repository_api->getBaseCommit(); } $unit_workflow = $this->buildChildWorkflow('unit', $argv); $unit_result = $unit_workflow->run(); switch ($unit_result) { case ArcanistUnitWorkflow::RESULT_OKAY: $this->console->writeOut( "** %s ** %s\n", pht('UNIT OKAY'), pht('No unit test failures.')); break; case ArcanistUnitWorkflow::RESULT_UNSOUND: - if ($this->getArgument('ignore-unsound-tests')) { - echo phutil_console_format( - "** %s ** %s\n", - pht('UNIT UNSOUND'), - pht( - 'Unit testing raised errors, but all '. - 'failing tests are unsound.')); - } else { - $continue = phutil_console_confirm( - pht( - 'Unit test results included failures, but all failing tests '. - 'are known to be unsound. Ignore unsound test failures?')); - if (!$continue) { - throw new ArcanistUserAbortException(); - } + $continue = phutil_console_confirm( + pht( + 'Unit test results included failures, but all failing tests '. + 'are known to be unsound. Ignore unsound test failures?')); + if (!$continue) { + throw new ArcanistUserAbortException(); } + + echo phutil_console_format( + "** %s ** %s\n", + pht('UNIT UNSOUND'), + pht( + 'Unit testing raised errors, but all '. + 'failing tests are unsound.')); break; case ArcanistUnitWorkflow::RESULT_FAIL: $this->console->writeOut( "** %s ** %s\n", pht('UNIT ERRORS'), pht('Unit testing raised errors!')); - $this->getErrorExcuse( - 'unit', - pht('Unit test results include failures!'), - 'unit-excuses'); break; } $this->testResults = array(); foreach ($unit_workflow->getTestResults() as $test) { $this->testResults[] = $test->toDictionary(); } return $unit_result; } catch (ArcanistNoEngineException $ex) { $this->console->writeOut( "%s\n", pht('No unit test engine is configured for this project.')); } catch (ArcanistNoEffectException $ex) { $this->console->writeOut("%s\n", $ex->getMessage()); } return null; } public function getTestResults() { return $this->testResults; } - private function getSkipExcuse($prompt, $history) { - $excuse = $this->getArgument('excuse'); - - if ($excuse === null) { - $history = $this->getRepositoryAPI()->getScratchFilePath($history); - $excuse = phutil_console_prompt($prompt, $history); - if ($excuse == '') { - throw new ArcanistUserAbortException(); - } - } - - return $excuse; - } - - private function getErrorExcuse($type, $prompt, $history) { - if ($this->getArgument('excuse')) { - $this->console->sendMessage(array( - 'type' => $type, - 'confirm' => $prompt.' '.pht('Ignore them?'), - )); - return; - } - - $history = $this->getRepositoryAPI()->getScratchFilePath($history); - - $prompt .= ' '. - pht('Provide explanation to continue or press Enter to abort.'); - $this->console->writeOut("\n\n%s", phutil_console_wrap($prompt)); - $this->console->sendMessage(array( - 'type' => $type, - 'prompt' => pht('Explanation:'), - 'history' => $history, - )); - } - - public function handleServerMessage(PhutilConsoleMessage $message) { - $data = $message->getData(); - - if ($this->getArgument('excuse')) { - try { - phutil_console_require_tty(); - } catch (PhutilConsoleStdinNotInteractiveException $ex) { - $this->excuses[$data['type']] = $this->getArgument('excuse'); - return null; - } - } - - $response = ''; - if (isset($data['prompt'])) { - $response = phutil_console_prompt($data['prompt'], idx($data, 'history')); - } else if (phutil_console_confirm($data['confirm'])) { - $response = $this->getArgument('excuse'); - } - if ($response == '') { - throw new ArcanistUserAbortException(); - } - $this->excuses[$data['type']] = $response; - return null; - } - /* -( Commit and Update Messages )----------------------------------------- */ /** * @task message */ private function buildCommitMessage() { if ($this->getArgument('only')) { return null; } $is_create = $this->getArgument('create'); $is_update = $this->getArgument('update'); $is_raw = $this->isRawDiffSource(); - $is_message = $this->getArgument('use-commit-message'); $is_verbatim = $this->getArgument('verbatim'); - if ($is_message) { - return $this->getCommitMessageFromCommit($is_message); - } - if ($is_verbatim) { return $this->getCommitMessageFromUser(); } if (!$is_raw && !$is_create && !$is_update) { $repository_api = $this->getRepositoryAPI(); $revisions = $repository_api->loadWorkingCopyDifferentialRevisions( $this->getConduit(), array( 'authors' => array($this->getUserPHID()), 'status' => 'status-open', )); if (!$revisions) { $is_create = true; } else if (count($revisions) == 1) { $revision = head($revisions); $is_update = $revision['id']; } else { throw new ArcanistUsageException( pht( "There are several revisions which match the working copy:\n\n%s\n". "Use '%s' to choose one, or '%s' to create a new revision.", $this->renderRevisionList($revisions), '--update', '--create')); } } $message = null; if ($is_create) { $message_file = $this->getArgument('message-file'); if ($message_file) { return $this->getCommitMessageFromFile($message_file); } else { return $this->getCommitMessageFromUser(); } } else if ($is_update) { $revision_id = $this->normalizeRevisionID($is_update); if (!is_numeric($revision_id)) { throw new ArcanistUsageException( pht( 'Parameter to %s must be a Differential Revision number.', '--update')); } return $this->getCommitMessageFromRevision($revision_id); } else { // This is --raw without enough info to create a revision, so force just // a diff. return null; } } - /** - * @task message - */ - private function getCommitMessageFromCommit($commit) { - $text = $this->getRepositoryAPI()->getCommitMessage($commit); - $message = ArcanistDifferentialCommitMessage::newFromRawCorpus($text); - $message->pullDataFromConduit($this->getConduit()); - $this->validateCommitMessage($message); - return $message; - } - - /** * @task message */ private function getCommitMessageFromUser() { $conduit = $this->getConduit(); $template = null; if (!$this->getArgument('verbatim')) { $saved = $this->readScratchFile('create-message'); if ($saved) { $where = $this->getReadableScratchFilePath('create-message'); $preview = explode("\n", $saved); $preview = array_shift($preview); $preview = trim($preview); $preview = id(new PhutilUTF8StringTruncator()) ->setMaximumGlyphs(64) ->truncateString($preview); if ($preview) { $preview = pht('Message begins:')."\n\n {$preview}\n\n"; } else { $preview = null; } echo pht( "You have a saved revision message in '%s'.\n%s". "You can use this message, or discard it.", $where, $preview); $use = phutil_console_confirm( pht('Do you want to use this message?'), $default_no = false); if ($use) { $template = $saved; } else { $this->removeScratchFile('create-message'); } } } $template_is_default = false; $notes = array(); $included = array(); list($fields, $notes, $included_commits) = $this->getDefaultCreateFields(); if ($template) { $fields = array(); $notes = array(); } else { if (!$fields) { $template_is_default = true; } if ($notes) { $commit = head($this->getRepositoryAPI()->getLocalCommitInformation()); $template = $commit['message']; } else { $template = $conduit->callMethodSynchronous( 'differential.getcommitmessage', array( 'revision_id' => null, 'edit' => 'create', 'fields' => $fields, )); } } $old_message = $template; $included = array(); if ($included_commits) { foreach ($included_commits as $commit) { $included[] = ' '.$commit; } if (!$this->isRawDiffSource()) { $message = pht( 'Included commits in branch %s:', $this->getRepositoryAPI()->getBranchName()); } else { $message = pht('Included commits:'); } $included = array_merge( array( '', $message, '', ), $included); } $issues = array_merge( array( pht('NEW DIFFERENTIAL REVISION'), pht('Describe the changes in this new revision.'), ), $included, array( '', pht( 'arc could not identify any existing revision in your working copy.'), pht('If you intended to update an existing revision, use:'), '', ' $ arc diff --update ', )); if ($notes) { $issues = array_merge($issues, array(''), $notes); } $done = false; $first = true; while (!$done) { $template = rtrim($template, "\r\n")."\n\n"; foreach ($issues as $issue) { $template .= rtrim('# '.$issue)."\n"; } $template .= "\n"; if ($first && $this->getArgument('verbatim') && !$template_is_default) { $new_template = $template; } else { $new_template = $this->newInteractiveEditor($template) ->setName('new-commit') ->editInteractively(); } $first = false; if ($template_is_default && ($new_template == $template)) { throw new ArcanistUsageException(pht('Template not edited.')); } $template = ArcanistCommentRemover::removeComments($new_template); // With --raw-command, we may not have a repository API. if ($this->hasRepositoryAPI()) { $repository_api = $this->getRepositoryAPI(); // special check for whether to amend here. optimizes a common git // workflow. we can't do this for mercurial because the mq extension // is popular and incompatible with hg commit --amend ; see T2011. $should_amend = (count($included_commits) == 1 && $repository_api instanceof ArcanistGitAPI && $this->shouldAmend()); } else { $should_amend = false; } if ($should_amend) { $wrote = (rtrim($old_message) != rtrim($template)); if ($wrote) { $repository_api->amendCommit($template); $where = pht('commit message'); } } else { $wrote = $this->writeScratchFile('create-message', $template); $where = "'".$this->getReadableScratchFilePath('create-message')."'"; } try { $message = ArcanistDifferentialCommitMessage::newFromRawCorpus( $template); $message->pullDataFromConduit($conduit); $this->validateCommitMessage($message); $done = true; } catch (ArcanistDifferentialCommitMessageParserException $ex) { echo pht('Commit message has errors:')."\n\n"; $issues = array(pht('Resolve these errors:')); foreach ($ex->getParserErrors() as $error) { echo phutil_console_wrap("- ".$error."\n", 6); $issues[] = ' - '.$error; } echo "\n"; echo pht('You must resolve these errors to continue.'); $again = phutil_console_confirm( pht('Do you want to edit the message?'), $default_no = false); if ($again) { // Keep going. } else { $saved = null; if ($wrote) { $saved = pht('A copy was saved to %s.', $where); } throw new ArcanistUsageException( pht('Message has unresolved errors.')." {$saved}"); } } catch (Exception $ex) { if ($wrote) { echo phutil_console_wrap(pht('(Message saved to %s.)', $where)."\n"); } throw $ex; } } return $message; } /** * @task message */ private function getCommitMessageFromFile($file) { $conduit = $this->getConduit(); $data = Filesystem::readFile($file); $message = ArcanistDifferentialCommitMessage::newFromRawCorpus($data); $message->pullDataFromConduit($conduit); $this->validateCommitMessage($message); return $message; } /** * @task message */ private function getCommitMessageFromRevision($revision_id) { $id = $revision_id; $revision = $this->getConduit()->callMethodSynchronous( 'differential.query', array( 'ids' => array($id), )); $revision = head($revision); if (!$revision) { throw new ArcanistUsageException( pht( "Revision '%s' does not exist!", $revision_id)); } $this->checkRevisionOwnership($revision); // TODO: Save this status to improve a prompt later. See PHI458. This is // extra awful until we move to "differential.revision.search" because // the "differential.query" method doesn't return a real draft status for // compatibility. $this->revisionIsDraft = (idx($revision, 'statusName') === 'Draft'); $message = $this->getConduit()->callMethodSynchronous( 'differential.getcommitmessage', array( 'revision_id' => $id, 'edit' => false, )); $this->commitMessageFromRevision = $message; $obj = ArcanistDifferentialCommitMessage::newFromRawCorpus($message); $obj->pullDataFromConduit($this->getConduit()); return $obj; } /** * @task message */ private function validateCommitMessage( ArcanistDifferentialCommitMessage $message) { $futures = array(); $revision_id = $message->getRevisionID(); if ($revision_id) { $futures['revision'] = $this->getConduit()->callMethod( 'differential.query', array( 'ids' => array($revision_id), )); } $reviewers = $message->getFieldValue('reviewerPHIDs'); if ($reviewers) { $futures['reviewers'] = $this->getConduit()->callMethod( 'user.query', array( 'phids' => $reviewers, )); } foreach (new FutureIterator($futures) as $key => $future) { $result = $future->resolve(); switch ($key) { case 'revision': if (empty($result)) { throw new ArcanistUsageException( pht( 'There is no revision %s.', "D{$revision_id}")); } $this->checkRevisionOwnership(head($result)); break; case 'reviewers': $away = array(); foreach ($result as $user) { if (idx($user, 'currentStatus') != 'away') { continue; } $username = $user['userName']; $real_name = $user['realName']; if (strlen($real_name)) { $name = pht('%s (%s)', $username, $real_name); } else { $name = pht('%s', $username); } $away[] = array( 'name' => $name, 'until' => $user['currentStatusUntil'], ); } if ($away) { if (count($away) == count($reviewers)) { $earliest_return = min(ipull($away, 'until')); $message = pht( 'All reviewers are away until %s:', date('l, M j Y', $earliest_return)); } else { $message = pht('Some reviewers are currently away:'); } echo tsprintf( "%s\n\n", $message); $list = id(new PhutilConsoleList()); foreach ($away as $spec) { $list->addItem( pht( '%s (until %s)', $spec['name'], date('l, M j Y', $spec['until']))); } echo tsprintf( '%B', $list->drawConsoleString()); $confirm = pht('Continue even though reviewers are unavailable?'); if (!phutil_console_confirm($confirm)) { throw new ArcanistUsageException( pht('Specify available reviewers and retry.')); } } break; } } } /** * @task message */ private function getUpdateMessage(array $fields, $template = '') { if ($this->getArgument('raw')) { throw new ArcanistUsageException( pht( "When using '%s' to update a revision, specify an update message ". "with '%s'. (Normally, we'd launch an editor to ask you for a ". "message, but can not do that because stdin is the diff source.)", '--raw', '--message')); } // When updating a revision using git without specifying '--message', try // to prefill with the message in HEAD if it isn't a template message. The // idea is that if you do: // // $ git commit -a -m 'fix some junk' // $ arc diff // // ...you shouldn't have to retype the update message. Similar things apply // to Mercurial. if ($template == '') { $comments = $this->getDefaultUpdateMessage(); $template = sprintf( "%s\n\n# %s\n#\n# %s\n# %s\n#\n# %s\n# $ %s\n\n", rtrim($comments), pht( 'Updating %s: %s', "D{$fields['revisionID']}", $fields['title']), pht( 'Enter a brief description of the changes included in this update.'), pht('The first line is used as subject, next lines as comment.'), pht('If you intended to create a new revision, use:'), 'arc diff --create'); } $comments = $this->newInteractiveEditor($template) ->setName('differential-update-comments') ->editInteractively(); return $comments; } private function getDefaultCreateFields() { $result = array(array(), array(), array()); if ($this->isRawDiffSource()) { return $result; } $repository_api = $this->getRepositoryAPI(); $local = $repository_api->getLocalCommitInformation(); if ($local) { $result = $this->parseCommitMessagesIntoFields($local); if ($this->getArgument('create')) { unset($result[0]['revisionID']); } } $result[0] = $this->dispatchWillBuildEvent($result[0]); return $result; } /** * Convert a list of commits from `getLocalCommitInformation()` into * a format usable by arc to create a new diff. Specifically, we emit: * * - A dictionary of commit message fields. * - A list of errors encountered while parsing the messages. * - A human-readable list of the commits themselves. * * For example, if the user runs "arc diff HEAD^^^" and selects a diff range * which includes several diffs, we attempt to merge them somewhat * intelligently into a single message, because we can only send one * "Summary:", "Reviewers:", etc., field to Differential. We also return * errors (e.g., if the user typed a reviewer name incorrectly) and a * summary of the commits themselves. * * @param dict Local commit information. * @return list Complex output, see summary. * @task message */ private function parseCommitMessagesIntoFields(array $local) { $conduit = $this->getConduit(); $local = ipull($local, null, 'commit'); // If the user provided "--reviewers" or "--ccs", add a faux message to // the list with the implied fields. $faux_message = array(); if ($this->getArgument('reviewers')) { $faux_message[] = pht('Reviewers: %s', $this->getArgument('reviewers')); } if ($this->getArgument('cc')) { $faux_message[] = pht('CC: %s', $this->getArgument('cc')); } // NOTE: For now, this isn't a real field, so it just ends up as the first // part of the summary. $depends_ref = $this->getDependsOnRevisionRef(); if ($depends_ref) { $faux_message[] = pht( 'Depends on %s. ', $depends_ref->getMonogram()); } // See T12069. After T10312, the first line of a message is always parsed // as a title. Add a placeholder so "Reviewers" and "CC" are never the // first line. $placeholder_title = pht(''); if ($faux_message) { array_unshift($faux_message, $placeholder_title); $faux_message = implode("\n\n", $faux_message); $local = array( '(Flags) ' => array( 'message' => $faux_message, 'summary' => pht('Command-Line Flags'), ), ) + $local; } // Build a human-readable list of the commits, so we can show the user which // commits are included in the diff. $included = array(); foreach ($local as $hash => $info) { $included[] = substr($hash, 0, 12).' '.$info['summary']; } // Parse all of the messages into fields. $messages = array(); foreach ($local as $hash => $info) { $text = $info['message']; $obj = ArcanistDifferentialCommitMessage::newFromRawCorpus($text); $messages[$hash] = $obj; } $notes = array(); $fields = array(); foreach ($messages as $hash => $message) { try { $message->pullDataFromConduit($conduit, $partial = true); $fields[$hash] = $message->getFields(); } catch (ArcanistDifferentialCommitMessageParserException $ex) { if ($this->getArgument('verbatim')) { // In verbatim mode, just bail when we hit an error. The user can // rerun without --verbatim if they want to fix it manually. Most // users will probably `git commit --amend` instead. throw $ex; } $fields[$hash] = $message->getFields(); $frev = substr($hash, 0, 12); $notes[] = pht( 'NOTE: commit %s could not be completely parsed:', $frev); foreach ($ex->getParserErrors() as $error) { $notes[] = " - {$error}"; } } } // Merge commit message fields. We do this somewhat-intelligently so that // multiple "Reviewers" or "CC" fields will merge into the concatenation // of all values. // We have special parsing rules for 'title' because we can't merge // multiple titles, and one-line commit messages like "fix stuff" will // parse as titles. Instead, pick the first title we encounter. When we // encounter subsequent titles, treat them as part of the summary. Then // we merge all the summaries together below. $result = array(); // Process fields in oldest-first order, so earlier commits get to set the // title of record and reviewers/ccs are listed in chronological order. $fields = array_reverse($fields); foreach ($fields as $hash => $dict) { $title = idx($dict, 'title'); if (!strlen($title)) { continue; } if ($title === $placeholder_title) { continue; } if (!isset($result['title'])) { // We don't have a title yet, so use this one. $result['title'] = $title; } else { // We already have a title, so merge this new title into the summary. $summary = idx($dict, 'summary'); if ($summary) { $summary = $title."\n\n".$summary; } else { $summary = $title; } $fields[$hash]['summary'] = $summary; } } // Now, merge all the other fields in a general sort of way. foreach ($fields as $hash => $dict) { foreach ($dict as $key => $value) { if ($key == 'title') { // This has been handled above, and either assigned directly or // merged into the summary. continue; } if (is_array($value)) { // For array values, merge the arrays, appending the new values. // Examples are "Reviewers" and "Cc", where this produces a list of // all users specified as reviewers. $cur = idx($result, $key, array()); $new = array_merge($cur, $value); $result[$key] = $new; continue; } else { if (!strlen(trim($value))) { // Ignore empty fields. continue; } // For string values, append the new field to the old field with // a blank line separating them. Examples are "Test Plan" and // "Summary". $cur = idx($result, $key, ''); if (strlen($cur)) { $new = $cur."\n\n".$value; } else { $new = $value; } $result[$key] = $new; } } } return array($result, $notes, $included); } private function getDefaultUpdateMessage() { if ($this->isRawDiffSource()) { return null; } $repository_api = $this->getRepositoryAPI(); if ($repository_api instanceof ArcanistGitAPI) { return $this->getGitUpdateMessage(); } if ($repository_api instanceof ArcanistMercurialAPI) { return $this->getMercurialUpdateMessage(); } return null; } /** * Retrieve the git messages between HEAD and the last update. * * @task message */ private function getGitUpdateMessage() { $repository_api = $this->getRepositoryAPI(); $parser = $this->newDiffParser(); $commit_messages = $repository_api->getGitCommitLog(); $commit_messages = $parser->parseDiff($commit_messages); if (count($commit_messages) == 1) { // If there's only one message, assume this is an amend-based workflow and // that using it to prefill doesn't make sense. return null; } // We have more than one message, so figure out which ones are new. We // do this by pulling the current diff and comparing commit hashes in the // working copy with attached commit hashes. It's not super important that // we always get this 100% right, we're just trying to do something // reasonable. $hashes = $this->loadActiveDiffLocalCommitHashes(); $hashes = array_fuse($hashes); $usable = array(); foreach ($commit_messages as $message) { $text = $message->getMetadata('message'); $parsed = ArcanistDifferentialCommitMessage::newFromRawCorpus($text); if ($parsed->getRevisionID()) { // If this is an amended commit message with a revision ID, it's // certainly not new. Stop marking commits as usable and break out. break; } if (isset($hashes[$message->getCommitHash()])) { // If this commit is currently part of the diff, stop using commit // messages, since anything older than this isn't new. break; } // Otherwise, this looks new, so it's a usable commit message. $usable[] = $text; } if (!$usable) { // No new commit messages, so we don't have anywhere to start from. return null; } return $this->formatUsableLogs($usable); } /** * Retrieve the hg messages between tip and the last update. * * @task message */ private function getMercurialUpdateMessage() { $repository_api = $this->getRepositoryAPI(); $messages = $repository_api->getCommitMessageLog(); if (count($messages) == 1) { // If there's only one message, assume this is an amend-based workflow and // that using it to prefill doesn't make sense. return null; } $hashes = $this->loadActiveDiffLocalCommitHashes(); $hashes = array_fuse($hashes); $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 loadActiveDiffLocalCommitHashes() { // The older "differential.querydiffs" method includes the full diff text, // which can be very slow for large diffs. If we can, try to use // "differential.diff.search" instead. // We expect this to fail if the Phabricator version on the server is // older than April 2018 (D19386), which introduced the "commits" // attachment for "differential.revision.search". // TODO: This can be optimized if we're able to learn the "revisionPHID" // before we get here. See PHI1104. try { $revisions_raw = $this->getConduit()->callMethodSynchronous( 'differential.revision.search', array( 'constraints' => array( 'ids' => array( $this->revisionID, ), ), )); $revisions = $revisions_raw['data']; $revision = head($revisions); if ($revision) { $revision_phid = $revision['phid']; $diffs_raw = $this->getConduit()->callMethodSynchronous( 'differential.diff.search', array( 'constraints' => array( 'revisionPHIDs' => array( $revision_phid, ), ), 'attachments' => array( 'commits' => true, ), 'limit' => 1, )); $diffs = $diffs_raw['data']; $diff = head($diffs); if ($diff) { $commits = idxv($diff, array('attachments', 'commits', 'commits')); if ($commits !== null) { $hashes = ipull($commits, 'identifier'); return array_values($hashes); } } } } catch (Exception $ex) { // If any of this fails, fall back to the older method below. } $current_diff = $this->getConduit()->callMethodSynchronous( 'differential.querydiffs', array( 'revisionIDs' => array($this->revisionID), )); $current_diff = head($current_diff); $properties = idx($current_diff, 'properties', array()); $local = idx($properties, 'local:commits', array()); $hashes = ipull($local, 'commit'); return array_values($hashes); } /* -( 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', ); return idx($map, $unit_result, 'none'); } /** * @task diffspec */ private function buildDiffSpecification() { $base_revision = null; $base_path = null; $vcs = null; $repo_uuid = null; $parent = null; $source_path = null; $branch = null; $bookmark = null; if (!$this->isRawDiffSource()) { $repository_api = $this->getRepositoryAPI(); $base_revision = $repository_api->getSourceControlBaseRevision(); $base_path = $repository_api->getSourceControlPath(); $vcs = $repository_api->getSourceControlSystemName(); $source_path = $repository_api->getPath(); $branch = $repository_api->getBranchName(); $repo_uuid = $repository_api->getRepositoryUUID(); if ($repository_api instanceof ArcanistGitAPI) { $info = $this->getGitParentLogInfo(); if ($info['parent']) { $parent = $info['parent']; } if ($info['base_revision']) { $base_revision = $info['base_revision']; } if ($info['base_path']) { $base_path = $info['base_path']; } if ($info['uuid']) { $repo_uuid = $info['uuid']; } } else if ($repository_api instanceof ArcanistMercurialAPI) { $bookmark = $repository_api->getActiveBookmark(); $svn_info = $repository_api->getSubversionInfo(); $repo_uuid = idx($svn_info, 'uuid'); $base_path = idx($svn_info, 'base_path', $base_path); $base_revision = idx($svn_info, 'base_revision', $base_revision); // TODO: provide parent info } } $data = array( 'sourceMachine' => php_uname('n'), 'sourcePath' => $source_path, 'branch' => $branch, 'bookmark' => $bookmark, 'sourceControlSystem' => $vcs, 'sourceControlPath' => $base_path, 'sourceControlBaseRevision' => $base_revision, 'creationMethod' => 'arc', ); if (!$this->isRawDiffSource()) { $repository_phid = $this->getRepositoryPHID(); if ($repository_phid) { $data['repositoryPHID'] = $repository_phid; } } return $data; } /* -( Diff Properties )---------------------------------------------------- */ /** * Update lint information for the diff. * * @return void * * @task diffprop */ private function updateLintDiffProperty() { - if (strlen($this->excuses['lint'])) { - $this->updateDiffProperty( - 'arc:lint-excuse', - json_encode($this->excuses['lint'])); - } - if (!$this->hitAutotargets) { if ($this->unresolvedLint) { $this->updateDiffProperty( 'arc:lint', json_encode($this->unresolvedLint)); } } } /** * Update unit test information for the diff. * * @return void * * @task diffprop */ private function updateUnitDiffProperty() { - if (strlen($this->excuses['unit'])) { - $this->updateDiffProperty('arc:unit-excuse', - json_encode($this->excuses['unit'])); - } - if (!$this->hitAutotargets) { if ($this->testResults) { $this->updateDiffProperty('arc:unit', json_encode($this->testResults)); } } } /** * Update local commit information for the diff. * * @task diffprop */ private function updateLocalDiffProperty() { if ($this->isRawDiffSource()) { return; } $local_info = $this->getRepositoryAPI()->getLocalCommitInformation(); if (!$local_info) { return; } $this->updateDiffProperty('local:commits', json_encode($local_info)); } private function updateOntoDiffProperty() { $onto = $this->getDiffOntoTargets(); if (!$onto) { return; } $this->updateDiffProperty('arc:onto', json_encode($onto)); } private function getDiffOntoTargets() { if ($this->isRawDiffSource()) { return null; } $api = $this->getRepositoryAPI(); if (!($api instanceof ArcanistGitAPI)) { return null; } // If we track an upstream branch either directly or indirectly, use that. $branch = $api->getBranchName(); if (strlen($branch)) { $upstream_path = $api->getPathToUpstream($branch); $remote_branch = $upstream_path->getRemoteBranchName(); if (strlen($remote_branch)) { return array( array( 'type' => 'branch', 'name' => $remote_branch, 'kind' => 'upstream', ), ); } } // If "arc.land.onto.default" is configured, use that. $config_key = 'arc.land.onto.default'; $onto = $this->getConfigFromAnySource($config_key); if (strlen($onto)) { return array( array( 'type' => 'branch', 'name' => $onto, 'kind' => 'arc.land.onto.default', ), ); } return null; } /** * Update an arbitrary diff property. * * @param string Diff property name. * @param string Diff property value. * @return void * * @task diffprop */ private function updateDiffProperty($name, $data) { $this->diffPropertyFutures[] = $this->getConduit()->callMethod( 'differential.setdiffproperty', array( 'diff_id' => $this->getDiffID(), 'name' => $name, 'data' => $data, )); } /** * Wait for finishing all diff property updates. * * @return void * * @task diffprop */ private function resolveDiffPropertyUpdates() { id(new FutureIterator($this->diffPropertyFutures)) ->resolveAll(); $this->diffPropertyFutures = array(); } private function dispatchWillCreateRevisionEvent(array $fields) { $event = $this->dispatchEvent( ArcanistEventType::TYPE_REVISION_WILLCREATEREVISION, array( 'specification' => $fields, )); return $event->getValue('specification'); } private function dispatchWillBuildEvent(array $fields) { $event = $this->dispatchEvent( ArcanistEventType::TYPE_DIFF_WILLBUILDMESSAGE, array( 'fields' => $fields, )); return $event->getValue('fields'); } private function checkRevisionOwnership(array $revision) { if ($revision['authorPHID'] == $this->getUserPHID()) { return; } $id = $revision['id']; $title = $revision['title']; $prompt = pht( "You don't own revision %s: \"%s\". Normally, you should ". "only update revisions you own. You can \"Commandeer\" this revision ". "from the web interface if you want to become the owner.\n\n". "Update this revision anyway?", "D{$id}", $title); $ok = phutil_console_confirm($prompt, $default_no = true); if (!$ok) { throw new ArcanistUsageException( pht('Aborted update of revision: You are not the owner.')); } } /* -( File Uploads )------------------------------------------------------- */ private function uploadFilesForChanges(array $changes) { assert_instances_of($changes, 'ArcanistDiffChange'); // Collect all the files we need to upload. $need_upload = array(); foreach ($changes as $key => $change) { if ($change->getFileType() != ArcanistDiffChangeType::FILE_BINARY) { continue; } if ($this->getArgument('skip-binaries')) { continue; } $name = basename($change->getCurrentPath()); $need_upload[] = array( 'type' => 'old', 'name' => $name, 'data' => $change->getOriginalFileData(), 'change' => $change, ); $need_upload[] = array( 'type' => 'new', 'name' => $name, 'data' => $change->getCurrentFileData(), 'change' => $change, ); } if (!$need_upload) { return; } // Determine mime types and file sizes. Update changes from "binary" to // "image" if the file is an image. Set image metadata. $type_image = ArcanistDiffChangeType::FILE_IMAGE; foreach ($need_upload as $key => $spec) { $change = $need_upload[$key]['change']; if ($spec['data'] === null) { // This covers the case where a file was added or removed; we don't // need to upload the other half of it (e.g., the old file data for // a file which was just added). This is distinct from an empty // file, which we do upload. unset($need_upload[$key]); continue; } $type = $spec['type']; $size = strlen($spec['data']); $change->setMetadata("{$type}:file:size", $size); $mime = $this->getFileMimeType($spec['data']); if (preg_match('@^image/@', $mime)) { $change->setFileType($type_image); } $change->setMetadata("{$type}:file:mime-type", $mime); } $uploader = id(new ArcanistFileUploader()) ->setConduitEngine($this->getConduitEngine()); foreach ($need_upload as $key => $spec) { $ref = id(new ArcanistFileDataRef()) ->setName($spec['name']) ->setData($spec['data']); $uploader->addFile($ref, $key); } $files = $uploader->uploadFiles(); $errors = false; foreach ($files as $key => $file) { if ($file->getErrors()) { unset($files[$key]); $errors = true; echo pht( 'Failed to upload binary "%s".', $file->getName()); } } if ($errors) { $prompt = pht('Continue?'); $ok = phutil_console_confirm($prompt, $default_no = false); if (!$ok) { throw new ArcanistUsageException( pht( 'Aborted due to file upload failure. You can use %s '. 'to skip binary uploads.', '--skip-binaries')); } } foreach ($files as $key => $file) { $spec = $need_upload[$key]; $phid = $file->getPHID(); $change = $spec['change']; $type = $spec['type']; $change->setMetadata("{$type}:binary-phid", $phid); echo pht('Uploaded binary data for "%s".', $file->getName())."\n"; } echo pht('Upload complete.')."\n"; } private function getFileMimeType($data) { $tmp = new TempFile(); Filesystem::writeFile($tmp, $data); return Filesystem::getMimeType($tmp); } private function shouldOpenCreatedObjectsInBrowser() { return $this->getArgument('browse'); } private function submitChangesToStagingArea($id) { $result = $this->pushChangesToStagingArea($id); // We'll either get a failure constant on error, or a list of pushed // refs on success. $ok = is_array($result); if ($ok) { $staging = array( 'status' => self::STAGING_PUSHED, 'refs' => $result, ); } else { $staging = array( 'status' => $result, 'refs' => array(), ); } $this->updateDiffProperty( 'arc.staging', phutil_json_encode($staging)); } private function pushChangesToStagingArea($id) { if ($this->getArgument('skip-staging')) { $this->writeInfo( pht('SKIP STAGING'), pht('Flag --skip-staging was specified.')); return self::STAGING_USER_SKIP; } if ($this->isRawDiffSource()) { $this->writeInfo( pht('SKIP STAGING'), pht('Raw changes can not be pushed to a staging area.')); return self::STAGING_DIFF_RAW; } if (!$this->getRepositoryPHID()) { $this->writeInfo( pht('SKIP STAGING'), pht('Unable to determine repository for this change.')); return self::STAGING_REPOSITORY_UNKNOWN; } $staging = $this->getRepositoryStagingConfiguration(); if ($staging === null) { $this->writeInfo( pht('SKIP STAGING'), pht('The server does not support staging areas.')); return self::STAGING_REPOSITORY_UNAVAILABLE; } $supported = idx($staging, 'supported'); if (!$supported) { $this->writeInfo( pht('SKIP STAGING'), pht('Phabricator does not support staging areas for this repository.')); return self::STAGING_REPOSITORY_UNSUPPORTED; } $staging_uri = idx($staging, 'uri'); if (!$staging_uri) { $this->writeInfo( pht('SKIP STAGING'), pht('No staging area is configured for this repository.')); return self::STAGING_REPOSITORY_UNCONFIGURED; } $api = $this->getRepositoryAPI(); if (!($api instanceof ArcanistGitAPI)) { $this->writeInfo( pht('SKIP STAGING'), pht('This client version does not support staging this repository.')); return self::STAGING_CLIENT_UNSUPPORTED; } $commit = $api->getHeadCommit(); $prefix = idx($staging, 'prefix', 'phabricator'); $base_tag = "refs/tags/{$prefix}/base/{$id}"; $diff_tag = "refs/tags/{$prefix}/diff/{$id}"; $this->writeOkay( pht('PUSH STAGING'), pht('Pushing changes to staging area...')); $push_flags = array(); if (version_compare($api->getGitVersion(), '1.8.2', '>=')) { $push_flags[] = '--no-verify'; } $refs = array(); $remote = array( 'uri' => $staging_uri, ); $is_lfs = $api->isGitLFSWorkingCopy(); // If the base commit is a real commit, we're going to push it. We don't // use this, but pushing it to a ref reduces the amount of redundant work // that Git does on later pushes by helping it figure out that the remote // already has most of the history. See T10509. // In the future, we could avoid this push if the staging area is the same // as the main repository, or if the staging area is a virtual repository. // In these cases, the staging area should automatically have up-to-date // refs. $base_commit = $api->getSourceControlBaseRevision(); if ($base_commit !== ArcanistGitAPI::GIT_MAGIC_ROOT_COMMIT) { $refs[] = array( 'ref' => $base_tag, 'type' => 'base', 'commit' => $base_commit, 'remote' => $remote, ); } // We're always going to push the change itself. $refs[] = array( 'ref' => $diff_tag, 'type' => 'diff', 'commit' => $is_lfs ? $base_commit : $commit, 'remote' => $remote, ); $ref_list = array(); foreach ($refs as $ref) { $ref_list[] = $ref['commit'].':'.$ref['ref']; } $err = phutil_passthru( 'git push %Ls -- %s %Ls', $push_flags, $staging_uri, $ref_list); if ($err) { $this->writeWarn( pht('STAGING FAILED'), pht('Unable to push changes to the staging area.')); throw new ArcanistUsageException( pht( 'Failed to push changes to staging area. Correct the issue, or '. 'use --skip-staging to skip this step.')); } if ($is_lfs) { $ref = '+'.$commit.':'.$diff_tag; $err = phutil_passthru( 'git push -- %s %s', $staging_uri, $ref); } return $refs; } /** * Try to upload lint and unit test results into modern Harbormaster build * targets. * * @return bool True if everything was uploaded to build targets. */ private function updateAutotargets($diff_phid, $unit_result) { $lint_key = 'arcanist.lint'; $unit_key = 'arcanist.unit'; try { $result = $this->getConduit()->callMethodSynchronous( 'harbormaster.queryautotargets', array( 'objectPHID' => $diff_phid, 'targetKeys' => array( $lint_key, $unit_key, ), )); $targets = idx($result, 'targetMap', array()); } catch (Exception $ex) { return false; } $futures = array(); $lint_target = idx($targets, $lint_key); if ($lint_target) { $lint = nonempty($this->unresolvedLint, array()); foreach ($lint as $key => $message) { $lint[$key] = $this->getModernLintDictionary($message); } // Consider this target to have failed if there are any unresolved // errors or warnings. $type = 'pass'; foreach ($lint as $message) { switch (idx($message, 'severity')) { case ArcanistLintSeverity::SEVERITY_WARNING: case ArcanistLintSeverity::SEVERITY_ERROR: $type = 'fail'; break; } } $futures[] = $this->getConduit()->callMethod( 'harbormaster.sendmessage', array( 'buildTargetPHID' => $lint_target, 'lint' => array_values($lint), 'type' => $type, )); } $unit_target = idx($targets, $unit_key); if ($unit_target) { $unit = nonempty($this->testResults, array()); foreach ($unit as $key => $message) { $unit[$key] = $this->getModernUnitDictionary($message); } $type = ArcanistUnitWorkflow::getHarbormasterTypeFromResult($unit_result); $futures[] = $this->getConduit()->callMethod( 'harbormaster.sendmessage', array( 'buildTargetPHID' => $unit_target, 'unit' => array_values($unit), 'type' => $type, )); } try { foreach (new FutureIterator($futures) as $future) { $future->resolve(); } return true; } catch (Exception $ex) { // TODO: Eventually, we should expect these to succeed if we get this // far, but just log errors for now. phlog($ex); return false; } } private function getDependsOnRevisionRef() { // TODO: Restore this behavior after updating for toolsets. Loading the // required hardpoints currently depends on a "WorkingCopy" existing. return null; $api = $this->getRepositoryAPI(); $base_ref = $api->getBaseCommitRef(); $state_ref = id(new ArcanistWorkingCopyStateRef()) ->setCommitRef($base_ref); $this->loadHardpoints( $state_ref, ArcanistWorkingCopyStateRef::HARDPOINT_REVISIONREFS); $revision_refs = $state_ref->getRevisionRefs(); $viewer_phid = $this->getUserPHID(); foreach ($revision_refs as $key => $revision_ref) { // Don't automatically depend on closed revisions. if ($revision_ref->isClosed()) { unset($revision_refs[$key]); continue; } // Don't automatically depend on revisions authored by other users. if ($revision_ref->getAuthorPHID() != $viewer_phid) { unset($revision_refs[$key]); continue; } } if (!$revision_refs) { return null; } if (count($revision_refs) > 1) { return null; } return head($revision_refs); } } diff --git a/src/workflow/ArcanistFeatureBaseWorkflow.php b/src/workflow/ArcanistFeatureBaseWorkflow.php deleted file mode 100644 index 94756c2a..00000000 --- a/src/workflow/ArcanistFeatureBaseWorkflow.php +++ /dev/null @@ -1,265 +0,0 @@ -newWorkflowArgument('view-all') - ->setHelp(pht('Include closed and abandoned revisions.')), - $this->newWorkflowArgument('by-status') - ->setParameter('status') - ->setHelp(pht('Sort branches by status instead of time.')), - $this->newWorkflowArgument('output') - ->setParameter('format') - ->setHelp( - pht( - 'With "json", show features in machine-readable JSON format.')), - $this->newWorkflowArgument('branch') - ->setWildcard(true), - ); - } - - public function getWorkflowInformation() { - return $this->newWorkflowInformation() - ->setSynopsis(pht('Wrapper on "git branch" or "hg bookmark".')) - ->addExample(pht('**%s** [__options__]', $this->getWorkflowName())) - ->addExample(pht('**%s** __name__ [__start__]', $this->getWorkflowName())) - ->setHelp( - pht(<<getRepositoryAPI(); - if (!$repository_api) { - throw new PhutilArgumentUsageException( - pht( - 'This command must be run in a Git or Mercurial working copy.')); - } - - $names = $this->getArgument('branch'); - if ($names) { - if (count($names) > 2) { - throw new ArcanistUsageException(pht('Specify only one branch.')); - } - return $this->checkoutBranch($names); - } - - // TODO: Everything in this whole workflow that says "branch" means - // "bookmark" in Mercurial. - - $branches = $repository_api->getAllBranchRefs(); - if (!$branches) { - throw new ArcanistUsageException( - pht('No branches in this working copy.')); - } - - $states = array(); - foreach ($branches as $branch_key => $branch) { - $state_ref = id(new ArcanistWorkingCopyStateRef()) - ->setCommitRef($branch->getCommitRef()); - - $states[] = array( - 'branch' => $branch, - 'state' => $state_ref, - ); - } - - $this->loadHardpoints( - ipull($states, 'state'), - ArcanistWorkingCopyStateRef::HARDPOINT_REVISIONREFS); - - $this->printBranches($states); - - return 0; - } - - private function checkoutBranch(array $names) { - $api = $this->getRepositoryAPI(); - - if ($api instanceof ArcanistMercurialAPI) { - $command = 'update %s'; - } else { - $command = 'checkout %s'; - } - - $err = 1; - - $name = $names[0]; - if (isset($names[1])) { - $start = $names[1]; - } else { - $start = $this->getConfigFromAnySource('arc.feature.start.default'); - } - - $branches = $api->getAllBranches(); - if (in_array($name, ipull($branches, 'name'))) { - list($err, $stdout, $stderr) = $api->execManualLocal($command, $name); - } - - if ($err) { - $match = null; - if (preg_match('/^D(\d+)$/', $name, $match)) { - $diff = $this->getConduitEngine()->resolveCall( - 'differential.querydiffs', - array( - 'revisionIDs' => array($match[1]), - )); - $diff = head($diff); - - if ($diff['branch'] != '') { - $name = $diff['branch']; - list($err, $stdout, $stderr) = $api->execManualLocal( - $command, - $name); - } - } - } - - if ($err) { - if ($api instanceof ArcanistMercurialAPI) { - $rev = ''; - if ($start) { - $rev = csprintf('-r %s', $start); - } - - $exec = $api->execManualLocal('bookmark %C %s', $rev, $name); - - if (!$exec[0] && $start) { - $api->execxLocal('update %s', $name); - } - } else { - $startarg = $start ? csprintf('%s', $start) : ''; - $exec = $api->execManualLocal( - 'checkout --track -b %s %C', - $name, - $startarg); - } - - list($err, $stdout, $stderr) = $exec; - } - - echo $stdout; - fprintf(STDERR, '%s', $stderr); - return $err; - } - - private function printBranches(array $states) { - static $color_map = array( - 'Closed' => 'cyan', - 'Needs Review' => 'magenta', - 'Needs Revision' => 'red', - 'Accepted' => 'green', - 'No Revision' => 'blue', - 'Abandoned' => 'default', - ); - - static $ssort_map = array( - 'Closed' => 1, - 'No Revision' => 2, - 'Needs Review' => 3, - 'Needs Revision' => 4, - 'Accepted' => 5, - ); - - $out = array(); - foreach ($states as $objects) { - $state = $objects['state']; - $branch = $objects['branch']; - - $revision = null; - if ($state->hasAmbiguousRevisionRefs()) { - $status = pht('Ambiguous Revision'); - } else { - $revision = $state->getRevisionRef(); - if ($revision) { - $status = $revision->getStatusDisplayName(); - } else { - $status = pht('No Revision'); - } - } - - if (!$this->getArgument('view-all') && !$branch->getIsCurrentBranch()) { - if ($status == 'Closed' || $status == 'Abandoned') { - continue; - } - } - - $commit = $branch->getCommitRef(); - $epoch = $commit->getCommitEpoch(); - - $color = idx($color_map, $status, 'default'); - $ssort = sprintf('%d%012d', idx($ssort_map, $status, 0), $epoch); - - if ($revision) { - $desc = $revision->getFullName(); - } else { - $desc = $commit->getSummary(); - } - - $out[] = array( - 'name' => $branch->getBranchName(), - 'current' => $branch->getIsCurrentBranch(), - 'status' => $status, - 'desc' => $desc, - 'revision' => $revision ? $revision->getID() : null, - 'color' => $color, - 'esort' => $epoch, - 'epoch' => $epoch, - 'ssort' => $ssort, - ); - } - - if (!$out) { - // All of the revisions are closed or abandoned. - return; - } - - $len_name = max(array_map('strlen', ipull($out, 'name'))) + 2; - $len_status = max(array_map('strlen', ipull($out, 'status'))) + 2; - - if ($this->getArgument('by-status')) { - $out = isort($out, 'ssort'); - } else { - $out = isort($out, 'esort'); - } - if ($this->getArgument('output') == 'json') { - foreach ($out as &$feature) { - unset($feature['color'], $feature['ssort'], $feature['esort']); - } - echo json_encode(ipull($out, null, 'name'))."\n"; - } else { - $table = id(new PhutilConsoleTable()) - ->setShowHeader(false) - ->addColumn('current', array('title' => '')) - ->addColumn('name', array('title' => pht('Name'))) - ->addColumn('status', array('title' => pht('Status'))) - ->addColumn('descr', array('title' => pht('Description'))); - - foreach ($out as $line) { - $table->addRow(array( - 'current' => $line['current'] ? '*' : '', - 'name' => tsprintf('**%s**', $line['name']), - 'status' => tsprintf( - "%s", $line['status']), - 'descr' => $line['desc'], - )); - } - - $table->draw(); - } - } - -} diff --git a/src/workflow/ArcanistFeatureWorkflow.php b/src/workflow/ArcanistFeatureWorkflow.php deleted file mode 100644 index 760d3dc6..00000000 --- a/src/workflow/ArcanistFeatureWorkflow.php +++ /dev/null @@ -1,10 +0,0 @@ -newWorkflowInformation() ->setSynopsis(pht('Show internal object information.')) ->addExample(pht('**inspect** [__options__] -- __object__')) ->setHelp($help); } public function getWorkflowArguments() { return array( $this->newWorkflowArgument('explore') ->setHelp(pht('Load all object hardpoints.')), $this->newWorkflowArgument('objects') ->setWildcard(true), ); } public function runWorkflow() { $is_explore = $this->getArgument('explore'); $objects = $this->getArgument('objects'); $inspectors = ArcanistRefInspector::getAllInspectors(); + foreach ($inspectors as $inspector) { + $inspector->setWorkflow($this); + } + if (!$objects) { echo tsprintf( "%s\n\n", pht('Choose an object to inspect:')); foreach ($inspectors as $inspector) { echo tsprintf( " - %s\n", $inspector->getInspectFunctionName()); } echo tsprintf("\n"); return 0; } $all_refs = array(); foreach ($objects as $description) { $matches = null; $pattern = '/^([\w-]+)(?:\((.*)\))?\z/'; if (!preg_match($pattern, $description, $matches)) { throw new PhutilArgumentUsageException( pht( 'Object specification "%s" is unknown, expected a specification '. 'like "commit(HEAD)".', $description)); } $function = $matches[1]; if (!isset($inspectors[$function])) { ksort($inspectors); throw new PhutilArgumentUsageException( pht( 'Unknown object type "%s", supported types are: %s.', $function, implode(', ', array_keys($inspectors)))); } $inspector = $inspectors[$function]; if (isset($matches[2])) { $arguments = array($matches[2]); } else { $arguments = array(); } $ref = $inspector->newInspectRef($arguments); $all_refs[] = $ref; } if ($is_explore) { $this->exploreRefs($all_refs); } $list = array(); foreach ($all_refs as $ref) { $out = $this->describeRef($ref, 0); $list[] = $out; } $list = phutil_glue($list, "\n"); echo tsprintf('%B', $list); return 0; } private function describeRef(ArcanistRef $ref, $depth) { $indent = str_repeat(' ', $depth); $out = array(); $out[] = tsprintf( "%s+ [%s] %s\n", $indent, get_class($ref), $ref->getRefDisplayName()); $hardpoint_list = $ref->getHardpointList(); foreach ($hardpoint_list->getHardpoints() as $hardpoint) { $lines = $this->describeHardpoint($ref, $hardpoint, $depth + 1); foreach ($lines as $line) { $out[] = $line; } } return $out; } private function describeHardpoint( ArcanistRef $ref, ArcanistHardpoint $hardpoint, $depth) { $indent = str_repeat(' ', $depth); $children = array(); $values = array(); $hardpoint_key = $hardpoint->getHardpointKey(); if ($ref->hasAttachedHardpoint($hardpoint_key)) { $mode = '*'; $value = $ref->getHardpoint($hardpoint_key); if ($value instanceof ArcanistRef) { $children[] = $value; } else if (is_array($value)) { foreach ($value as $key => $child) { if ($child instanceof ArcanistRef) { $children[] = $child; } else { $values[] = $value; } } } else { $values[] = $value; } } else { $mode = 'o'; } $out = array(); $out[] = tsprintf( "%s%s [%s] %s\n", $indent, $mode, get_class($hardpoint), $hardpoint->getHardpointKey()); foreach ($children as $child) { $lines = $this->describeRef($child, $depth + 1); foreach ($lines as $line) { $out[] = $line; } } foreach ($values as $value) { $lines = $this->describeValue($value, $depth + 1); foreach ($lines as $line) { $out[] = $line; } } return $out; } private function describeValue($value, $depth) { $indent = str_repeat(' ', $depth); if (is_string($value)) { $display_value = '"'.addcslashes(substr($value, 0, 64), "\n\r\t\\\"").'"'; } else if (is_scalar($value)) { $display_value = phutil_string_cast($value); } else if ($value === null) { $display_value = 'null'; } else { $display_value = phutil_describe_type($value); } $out = array(); $out[] = tsprintf( "%s> %s\n", $indent, $display_value); return $out; } private function exploreRefs(array $refs) { $seen = array(); $look = $refs; while ($look) { $ref_map = $this->getRefsByClass($look); $look = array(); $children = $this->inspectHardpoints($ref_map); foreach ($children as $child) { $hash = spl_object_hash($child); if (isset($seen[$hash])) { continue; } $seen[$hash] = true; $look[] = $child; } } } private function getRefsByClass(array $refs) { $ref_lists = array(); foreach ($refs as $ref) { $ref_lists[get_class($ref)][] = $ref; } foreach ($ref_lists as $ref_class => $refs) { $typical_ref = head($refs); $hardpoint_list = $typical_ref->getHardpointList(); $hardpoints = $hardpoint_list->getHardpoints(); if (!$hardpoints) { unset($ref_lists[$ref_class]); continue; } $hardpoint_keys = mpull($hardpoints, 'getHardpointKey'); $ref_lists[$ref_class] = array( 'keys' => $hardpoint_keys, 'refs' => $refs, ); } return $ref_lists; } private function inspectHardpoints(array $ref_lists) { foreach ($ref_lists as $ref_class => $spec) { $refs = $spec['refs']; $keys = $spec['keys']; $this->loadHardpoints($refs, $keys); } $child_refs = array(); foreach ($ref_lists as $ref_class => $spec) { $refs = $spec['refs']; $keys = $spec['keys']; foreach ($refs as $ref) { foreach ($keys as $key) { $value = $ref->getHardpoint($key); if (!is_array($value)) { $value = array($value); } foreach ($value as $child) { if ($child instanceof ArcanistRef) { $child_refs[] = $child; } } } } } return $child_refs; } } diff --git a/src/workflow/ArcanistLandWorkflow.php b/src/workflow/ArcanistLandWorkflow.php index 2fa7b022..92fc643d 100644 --- a/src/workflow/ArcanistLandWorkflow.php +++ b/src/workflow/ArcanistLandWorkflow.php @@ -1,1610 +1,347 @@ revision; - } +final class ArcanistLandWorkflow + extends ArcanistArcWorkflow { public function getWorkflowName() { return 'land'; } - public function getCommandSynopses() { - return phutil_console_format(<< array( - 'param' => 'master', - 'help' => pht( - "Land feature branch onto a branch other than the default ". - "('master' in git, 'default' in hg). You can change the default ". - "by setting '%s' with `%s` or for the entire project in %s.", - 'arc.land.onto.default', - 'arc set-config', - '.arcconfig'), - ), - 'hold' => array( - 'help' => pht( - 'Prepare the change to be pushed, but do not actually push it.'), - ), - 'keep-branch' => array( - 'help' => pht( - 'Keep the feature branch after pushing changes to the '. - 'remote (by default, it is deleted).'), - ), - 'remote' => array( - 'param' => 'origin', - 'help' => pht( - 'Push to a remote other than the default.'), - ), - 'merge' => array( - 'help' => pht( - 'Perform a %s merge, not a %s merge. If the project '. - 'is marked as having an immutable history, this is the default '. - 'behavior.', - '--no-ff', - '--squash'), - 'supports' => array( - 'git', - ), - 'nosupport' => array( - 'hg' => pht( - 'Use the %s strategy when landing in mercurial.', - '--squash'), - ), - ), - 'squash' => array( - 'help' => pht( - 'Perform a %s merge, not a %s merge. If the project is '. - 'marked as having a mutable history, this is the default behavior.', - '--squash', - '--no-ff'), - 'conflicts' => array( - 'merge' => pht( - '%s and %s are conflicting merge strategies.', - '--merge', - '--squash'), - ), - ), - 'delete-remote' => array( - 'help' => pht( - 'Delete the feature branch in the remote after landing it.'), - 'conflicts' => array( - 'keep-branch' => true, - ), - 'supports' => array( - 'hg', - ), - ), - 'revision' => array( - 'param' => 'id', - 'help' => pht( - 'Use the message from a specific revision, rather than '. - 'inferring the revision based on branch content.'), - ), - 'preview' => array( - 'help' => pht( - 'Prints the commits that would be landed. Does not '. - 'actually modify or land the commits.'), - ), - '*' => 'branch', - ); - } +Under merge strategies which mutate history (including the default "squash" +strategy), local refs which descend from commits that were published are +now updated. For example, if you land "feature4", local branches "feature5" and +"feature6" may now be rebased on the published version of the change. - public function run() { - $this->readArguments(); +Once everything has been pushed, cleanup occurs. Consulting mystical sources of +power, the workflow makes a guess about what state you wanted to end up in +after the process finishes. The working copy is put into that state. - $engine = null; - if ($this->isGit && !$this->isGitSvn) { - $engine = new ArcanistGitLandEngine(); - } - - if ($engine) { - $should_hold = $this->getArgument('hold'); - $remote_arg = $this->getArgument('remote'); - $onto_arg = $this->getArgument('onto'); - - $engine - ->setWorkflow($this) - ->setRepositoryAPI($this->getRepositoryAPI()) - ->setSourceRef($this->branch) - ->setShouldHold($should_hold) - ->setShouldKeep($this->keepBranch) - ->setShouldSquash($this->useSquash) - ->setShouldPreview($this->preview) - ->setRemoteArgument($remote_arg) - ->setOntoArgument($onto_arg) - ->setBuildMessageCallback(array($this, 'buildEngineMessage')); - - // The goal here is to raise errors with flags early (which is cheap), - // before we test if the working copy is clean (which can be slow). This - // could probably be structured more cleanly. - - $engine->parseArguments(); - - // This must be configured or we fail later inside "buildEngineMessage()". - // This is less than ideal. - $this->ontoRemoteBranch = sprintf( - '%s/%s', - $engine->getTargetRemote(), - $engine->getTargetOnto()); - - $this->requireCleanWorkingCopy(); - $engine->execute(); - - if (!$should_hold && !$this->preview) { - $this->didPush(); - } - - return 0; - } - - $this->validate(); - - try { - $this->pullFromRemote(); - } catch (Exception $ex) { - $this->restoreBranch(); - throw $ex; - } - - $this->printPendingCommits(); - if ($this->preview) { - $this->restoreBranch(); - return 0; - } - - $this->checkoutBranch(); - $this->findRevision(); - - if ($this->useSquash) { - $this->rebase(); - $this->squash(); - } else { - $this->merge(); - } - - $this->push(); - - if (!$this->keepBranch) { - $this->cleanupBranch(); - } - - if ($this->oldBranch != $this->onto) { - // If we were on some branch A and the user ran "arc land B", - // switch back to A. - if ($this->keepBranch || $this->oldBranch != $this->branch) { - $this->restoreBranch(); - } - } - - echo pht('Done.'), "\n"; - - return 0; - } - - private function getUpstreamMatching($branch, $pattern) { - if ($this->isGit) { - $repository_api = $this->getRepositoryAPI(); - list($err, $fullname) = $repository_api->execManualLocal( - 'rev-parse --symbolic-full-name %s@{upstream}', - $branch); - if (!$err) { - $matches = null; - if (preg_match($pattern, $fullname, $matches)) { - return last($matches); - } - } - } - return null; - } - - private function getGitSvnTrunk() { - if (!$this->isGitSvn) { - return null; - } - - // See T13293, this depends on the options passed when cloning. - // On any error we return `trunk`, which was the previous default. - - $repository_api = $this->getRepositoryAPI(); - list($err, $refspec) = $repository_api->execManualLocal( - 'config svn-remote.svn.fetch'); - - if ($err) { - return 'trunk'; - } - - $refspec = rtrim(substr($refspec, strrpos($refspec, ':') + 1)); - - $prefix = 'refs/remotes/'; - if (substr($refspec, 0, strlen($prefix)) !== $prefix) { - return 'trunk'; - } - - $refspec = substr($refspec, strlen($prefix)); - return $refspec; - } - - private function readArguments() { - $repository_api = $this->getRepositoryAPI(); - $this->isGit = $repository_api instanceof ArcanistGitAPI; - $this->isHg = $repository_api instanceof ArcanistMercurialAPI; - - if ($this->isGit) { - $repository = $this->loadProjectRepository(); - $this->isGitSvn = (idx($repository, 'vcs') == 'svn'); - } - - if ($this->isHg) { - $this->isHgSvn = $repository_api->isHgSubversionRepo(); - } - - $branch = $this->getArgument('branch'); - if (empty($branch)) { - $branch = $this->getBranchOrBookmark(); - if ($branch !== null) { - $this->branchType = $this->getBranchType($branch); - - // TODO: This message is misleading when landing a detached head or - // a tag in Git. - - echo pht("Landing current %s '%s'.", $this->branchType, $branch), "\n"; - $branch = array($branch); - } - } - - if (count($branch) !== 1) { - throw new ArcanistUsageException( - pht('Specify exactly one branch or bookmark to land changes from.')); - } - $this->branch = head($branch); - $this->keepBranch = $this->getArgument('keep-branch'); - - $this->preview = $this->getArgument('preview'); - - if (!$this->branchType) { - $this->branchType = $this->getBranchType($this->branch); - } - - $onto_default = $this->isGit ? 'master' : 'default'; - $onto_default = nonempty( - $this->getConfigFromAnySource('arc.land.onto.default'), - $onto_default); - $onto_default = coalesce( - $this->getUpstreamMatching($this->branch, '/^refs\/heads\/(.+)$/'), - $onto_default); - $this->onto = $this->getArgument('onto', $onto_default); - $this->ontoType = $this->getBranchType($this->onto); - - $remote_default = $this->isGit ? 'origin' : ''; - $remote_default = coalesce( - $this->getUpstreamMatching($this->onto, '/^refs\/remotes\/(.+?)\//'), - $remote_default); - $this->remote = $this->getArgument('remote', $remote_default); - - if ($this->getArgument('merge')) { - $this->useSquash = false; - } else if ($this->getArgument('squash')) { - $this->useSquash = true; - } else { - $this->useSquash = !$this->isHistoryImmutable(); - } - - $this->ontoRemoteBranch = $this->onto; - if ($this->isGitSvn) { - $this->ontoRemoteBranch = $this->getGitSvnTrunk(); - } else if ($this->isGit) { - $this->ontoRemoteBranch = $this->remote.'/'.$this->onto; - } +Any obsolete refs that point at commits which were published are deleted, +unless the **--keep-branches** flag is passed. +EOTEXT + ); - $this->oldBranch = $this->getBranchOrBookmark(); + return $this->newWorkflowInformation() + ->setSynopsis(pht('Publish reviewed changes.')) + ->addExample(pht('**land** [__options__] -- [__ref__ ...]')) + ->setHelp($help); } - private function validate() { - $repository_api = $this->getRepositoryAPI(); - - if ($this->onto == $this->branch) { - $message = pht( - "You can not land a %s onto itself -- you are trying ". - "to land '%s' onto '%s'. For more information on how to push ". - "changes, see 'Pushing and Closing Revisions' in 'Arcanist User ". - "Guide: arc diff' in the documentation.", - $this->branchType, - $this->branch, - $this->onto); - if (!$this->isHistoryImmutable()) { - $message .= ' '.pht("You may be able to '%s' instead.", 'arc amend'); - } - throw new ArcanistUsageException($message); - } - - if ($this->isHg) { - if ($this->useSquash) { - if (!$repository_api->supportsRebase()) { - throw new ArcanistUsageException( + public function getWorkflowArguments() { + return array( + $this->newWorkflowArgument('hold') + ->setHelp( + pht( + 'Prepare the changes to be pushed, but do not actually push '. + 'them.')), + $this->newWorkflowArgument('keep-branches') + ->setHelp( + pht( + 'Keep local branches around after changes are pushed. By '. + 'default, local branches are deleted after the changes they '. + 'contain are published.')), + $this->newWorkflowArgument('onto-remote') + ->setParameter('remote-name') + ->setHelp(pht('Push to a remote other than the default.')) + ->addRelatedConfig('arc.land.onto-remote'), + $this->newWorkflowArgument('onto') + ->setParameter('branch-name') + ->setRepeatable(true) + ->addRelatedConfig('arc.land.onto') + ->setHelp( + array( pht( - 'You must enable the rebase extension to use the %s strategy.', - '--squash')); - } - } - - if ($this->branchType != $this->ontoType) { - throw new ArcanistUsageException(pht( - 'Source %s is a %s but destination %s is a %s. When landing a '. - '%s, the destination must also be a %s. Use %s to specify a %s, '. - 'or set %s in %s.', - $this->branch, - $this->branchType, - $this->onto, - $this->ontoType, - $this->branchType, - $this->branchType, - '--onto', - $this->branchType, - 'arc.land.onto.default', - '.arcconfig')); - } - } - - if ($this->isGit) { - list($err) = $repository_api->execManualLocal( - 'rev-parse --verify %s', - $this->branch); - - if ($err) { - throw new ArcanistUsageException( - pht("Branch '%s' does not exist.", $this->branch)); - } - } - - $this->requireCleanWorkingCopy(); - } - - private function checkoutBranch() { - $repository_api = $this->getRepositoryAPI(); - if ($this->getBranchOrBookmark() != $this->branch) { - $repository_api->execxLocal('checkout %s', $this->branch); - } - - switch ($this->branchType) { - case self::REFTYPE_BOOKMARK: - $message = pht( - 'Switched to bookmark **%s**. Identifying and merging...', - $this->branch); - break; - case self::REFTYPE_BRANCH: - default: - $message = pht( - 'Switched to branch **%s**. Identifying and merging...', - $this->branch); - break; - } - - echo phutil_console_format($message."\n"); - } - - private function printPendingCommits() { - $repository_api = $this->getRepositoryAPI(); - - if ($repository_api instanceof ArcanistGitAPI) { - list($out) = $repository_api->execxLocal( - 'log --oneline %s %s --', - $this->branch, - '^'.$this->onto); - } else if ($repository_api instanceof ArcanistMercurialAPI) { - $common_ancestor = $repository_api->getCanonicalRevisionName( - hgsprintf('ancestor(%s,%s)', - $this->onto, - $this->branch)); - - $branch_range = hgsprintf( - 'reverse((%s::%s) - %s)', - $common_ancestor, - $this->branch, - $common_ancestor); - - list($out) = $repository_api->execxLocal( - 'log -r %s --template %s', - $branch_range, - '{node|short} {desc|firstline}\n'); - } - - if (!trim($out)) { - $this->restoreBranch(); - throw new ArcanistUsageException( - pht('No commits to land from %s.', $this->branch)); - } - - echo pht("The following commit(s) will be landed:\n\n%s", $out), "\n"; - } - - private function findRevision() { - $repository_api = $this->getRepositoryAPI(); - - $this->parseBaseCommitArgument(array($this->ontoRemoteBranch)); - - $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(pht( - "No such revision '%s'!", - "D{$revision_id}")); - } - } else { - $revisions = $repository_api->loadWorkingCopyDifferentialRevisions( - $this->getConduit(), - array()); - } - - if (!count($revisions)) { - throw new ArcanistUsageException(pht( - "arc can not identify which revision exists on %s '%s'. Update the ". - "revision with recent changes to synchronize the %s name and hashes, ". - "or use '%s' to amend the commit message at HEAD, or use ". - "'%s' to select a revision explicitly.", - $this->branchType, - $this->branch, - $this->branchType, - 'arc amend', - '--revision ')); - } else if (count($revisions) > 1) { - switch ($this->branchType) { - case self::REFTYPE_BOOKMARK: - $message = pht( - "There are multiple revisions on feature bookmark '%s' which are ". - "not present on '%s':\n\n". - "%s\n". - 'Separate these revisions onto different bookmarks, or use '. - '--revision to use the commit message from '. - 'and land them all.', - $this->branch, - $this->onto, - $this->renderRevisionList($revisions)); - break; - case self::REFTYPE_BRANCH: - default: - $message = pht( - "There are multiple revisions on feature branch '%s' which are ". - "not present on '%s':\n\n". - "%s\n". - 'Separate these revisions onto different branches, or use '. - '--revision to use the commit message from '. - 'and land them all.', - $this->branch, - $this->onto, - $this->renderRevisionList($revisions)); - break; - } - - throw new ArcanistUsageException($message); - } - - $this->revision = head($revisions); - - $rev_status = $this->revision['status']; - $rev_id = $this->revision['id']; - $rev_title = $this->revision['title']; - $rev_auxiliary = idx($this->revision, 'auxiliary', array()); - - $full_name = pht('D%d: %s', $rev_id, $rev_title); - - if ($this->revision['authorPHID'] != $this->getUserPHID()) { - $other_author = $this->getConduit()->callMethodSynchronous( - 'user.query', - array( - 'phids' => array($this->revision['authorPHID']), - )); - $other_author = ipull($other_author, 'userName', 'phid'); - $other_author = $other_author[$this->revision['authorPHID']]; - $ok = phutil_console_confirm(pht( - "This %s has revision '%s' but you are not the author. Land this ". - "revision by %s?", - $this->branchType, - $full_name, - $other_author)); - if (!$ok) { - throw new ArcanistUserAbortException(); - } - } - - $state_warning = null; - $state_header = null; - if ($rev_status == ArcanistDifferentialRevisionStatus::CHANGES_PLANNED) { - $state_header = pht('REVISION HAS CHANGES PLANNED'); - $state_warning = pht( - 'The revision you are landing ("%s") is currently in the "%s" state, '. - 'indicating that you expect to revise it before moving forward.'. - "\n\n". - 'Normally, you should resubmit it for review and wait until it is '. - '"%s" by reviewers before you continue.'. - "\n\n". - 'To resubmit the revision for review, either: update the revision '. - 'with revised changes; or use "Request Review" from the web interface.', - $full_name, - pht('Changes Planned'), - pht('Accepted')); - } else if ($rev_status != ArcanistDifferentialRevisionStatus::ACCEPTED) { - $state_header = pht('REVISION HAS NOT BEEN ACCEPTED'); - $state_warning = pht( - 'The revision you are landing ("%s") has not been "%s" by reviewers.', - $full_name, - pht('Accepted')); - } - - if ($state_warning !== null) { - $prompt = pht('Land revision in the wrong state?'); - - id(new PhutilConsoleBlock()) - ->addParagraph(tsprintf('** %s **', $state_header)) - ->addParagraph(tsprintf('%B', $state_warning)) - ->draw(); - - $ok = phutil_console_confirm($prompt); - if (!$ok) { - throw new ArcanistUserAbortException(); - } - } - - if ($rev_auxiliary) { - $phids = idx($rev_auxiliary, 'phabricator:depends-on', array()); - if ($phids) { - $dep_on_revs = $this->getConduit()->callMethodSynchronous( - 'differential.query', - array( - 'phids' => $phids, - 'status' => 'status-open', - )); - - $open_dep_revs = array(); - foreach ($dep_on_revs as $dep_on_rev) { - $dep_on_rev_id = $dep_on_rev['id']; - $dep_on_rev_title = $dep_on_rev['title']; - $dep_on_rev_status = $dep_on_rev['status']; - $open_dep_revs[$dep_on_rev_id] = $dep_on_rev_title; - } - - if (!empty($open_dep_revs)) { - $open_revs = array(); - foreach ($open_dep_revs as $id => $title) { - $open_revs[] = ' - D'.$id.': '.$title; - } - $open_revs = implode("\n", $open_revs); - - echo pht( - "Revision '%s' depends on open revisions:\n\n%s", - "D{$rev_id}: {$rev_title}", - $open_revs); - - $ok = phutil_console_confirm(pht('Continue anyway?')); - if (!$ok) { - throw new ArcanistUserAbortException(); - } - } - } - } - - $message = $this->getConduit()->callMethodSynchronous( - 'differential.getcommitmessage', - array( - 'revision_id' => $rev_id, - )); - - $this->messageFile = new TempFile(); - Filesystem::writeFile($this->messageFile, $message); - - echo pht( - "Landing revision '%s'...", - "D{$rev_id}: {$rev_title}")."\n"; - - $diff_phid = idx($this->revision, 'activeDiffPHID'); - if ($diff_phid) { - $this->checkForBuildables($diff_phid); - } - } - - private function pullFromRemote() { - $repository_api = $this->getRepositoryAPI(); - - $local_ahead_of_remote = false; - if ($this->isGit) { - $repository_api->execxLocal('checkout %s', $this->onto); - - echo phutil_console_format(pht( - "Switched to branch **%s**. Updating branch...\n", - $this->onto)); - - try { - $repository_api->execxLocal('pull --ff-only --no-stat'); - } catch (CommandException $ex) { - if (!$this->isGitSvn) { - throw $ex; - } - } - list($out) = $repository_api->execxLocal( - 'log %s..%s', - $this->ontoRemoteBranch, - $this->onto); - if (strlen(trim($out))) { - $local_ahead_of_remote = true; - } else if ($this->isGitSvn) { - $repository_api->execxLocal('svn rebase'); - } - - } else if ($this->isHg) { - echo phutil_console_format(pht('Updating **%s**...', $this->onto)."\n"); - - try { - list($out, $err) = $repository_api->execxLocal('pull'); - - $divergedbookmark = $this->onto.'@'.$repository_api->getBranchName(); - if (strpos($err, $divergedbookmark) !== false) { - throw new ArcanistUsageException(phutil_console_format(pht( - "Local bookmark **%s** has diverged from the server's **%s** ". - "(now labeled **%s**). Please resolve this divergence and run ". - "'%s' again.", - $this->onto, - $this->onto, - $divergedbookmark, - 'arc land'))); - } - } catch (CommandException $ex) { - $err = $ex->getError(); - $stdout = $ex->getStdout(); - - // Copied from: PhabricatorRepositoryPullLocalDaemon.php - // NOTE: Between versions 2.1 and 2.1.1, Mercurial changed the - // behavior of "hg pull" to return 1 in case of a successful pull - // with no changes. This behavior has been reverted, but users who - // updated between Feb 1, 2012 and Mar 1, 2012 will have the - // erroring version. Do a dumb test against stdout to check for this - // possibility. - // See: https://github.com/phacility/phabricator/issues/101/ - - // NOTE: Mercurial has translated versions, which translate this error - // string. In a translated version, the string will be something else, - // like "aucun changement trouve". There didn't seem to be an easy way - // to handle this (there are hard ways but this is not a common - // problem and only creates log spam, not application failures). - // Assume English. - - // TODO: Remove this once we're far enough in the future that - // deployment of 2.1 is exceedingly rare? - if ($err != 1 || !preg_match('/no changes found/', $stdout)) { - throw $ex; - } - } - - // Pull succeeded. Now make sure master is not on an outgoing change - if ($repository_api->supportsPhases()) { - list($out) = $repository_api->execxLocal( - 'log -r %s --template %s', $this->onto, '{phase}'); - if ($out != 'public') { - $local_ahead_of_remote = true; - } - } else { - // execManual instead of execx because outgoing returns - // code 1 when there is nothing outgoing - list($err, $out) = $repository_api->execManualLocal( - 'outgoing -r %s', - $this->onto); - - // $err === 0 means something is outgoing - if ($err === 0) { - $local_ahead_of_remote = true; - } - } - } - - if ($local_ahead_of_remote) { - throw new ArcanistUsageException(pht( - "Local %s '%s' is ahead of remote %s '%s', so landing a feature ". - "%s would push additional changes. Push or reset the changes in '%s' ". - "before running '%s'.", - $this->ontoType, - $this->onto, - $this->ontoType, - $this->ontoRemoteBranch, - $this->ontoType, - $this->onto, - 'arc land')); - } - } - - private function rebase() { - $repository_api = $this->getRepositoryAPI(); - - chdir($repository_api->getPath()); - if ($this->isHg) { - $onto_tip = $repository_api->getCanonicalRevisionName($this->onto); - $common_ancestor = $repository_api->getCanonicalRevisionName( - hgsprintf('ancestor(%s, %s)', $this->onto, $this->branch)); - - // Only rebase if the local branch is not at the tip of the onto branch. - if ($onto_tip != $common_ancestor) { - // keep branch here so later we can decide whether to remove it - $err = $repository_api->execPassthru( - 'rebase -d %s --keepbranches', - $this->onto); - if ($err) { - echo phutil_console_format("%s\n", pht('Aborting rebase')); - $repository_api->execManualLocal('rebase --abort'); - $this->restoreBranch(); - throw new ArcanistUsageException(pht( - "'%s' failed and the rebase was aborted. This is most ". - "likely due to conflicts. Manually rebase %s onto %s, resolve ". - "the conflicts, then run '%s' again.", - sprintf('hg rebase %s', $this->onto), - $this->branch, - $this->onto, - 'arc land')); - } - } - } - - $repository_api->reloadWorkingCopy(); - } - - private function squash() { - $repository_api = $this->getRepositoryAPI(); - - if ($this->isGit) { - $repository_api->execxLocal('checkout %s', $this->onto); - $repository_api->execxLocal( - 'merge --no-stat --squash --ff-only %s', - $this->branch); - } else if ($this->isHg) { - // The hg code is a little more complex than git's because we - // need to handle the case where the landing branch has child branches: - // -a--------b master - // \ - // w--x mybranch - // \--y subbranch1 - // \--z subbranch2 - // - // arc land --branch mybranch --onto master : - // -a--b--wx master - // \--y subbranch1 - // \--z subbranch2 - - $branch_rev_id = $repository_api->getCanonicalRevisionName($this->branch); - - // At this point $this->onto has been pulled from remote and - // $this->branch has been rebased on top of onto(by the rebase() - // function). So we're guaranteed to have onto as an ancestor of branch - // when we use first((onto::branch)-onto) below. - $branch_root = $repository_api->getCanonicalRevisionName( - hgsprintf('first((%s::%s)-%s)', - $this->onto, - $this->branch, - $this->onto)); - - $branch_range = hgsprintf( - '(%s::%s)', - $branch_root, - $this->branch); - - if (!$this->keepBranch) { - $this->handleAlternateBranches($branch_root, $branch_range); - } - - // Collapse just the landing branch onto master. - // Leave its children on the original branch. - $err = $repository_api->execPassthru( - 'rebase --collapse --keep --logfile %s -r %s -d %s', - $this->messageFile, - $branch_range, - $this->onto); - - if ($err) { - $repository_api->execManualLocal('rebase --abort'); - $this->restoreBranch(); - throw new ArcanistUsageException( + 'After merging, push changes onto a specified branch.'), + pht( + 'Specifying this flag multiple times will push to multiple '. + 'branches.'), + )), + $this->newWorkflowArgument('strategy') + ->setParameter('strategy-name') + ->addRelatedConfig('arc.land.strategy') + ->setHelp( + array( + pht( + 'Merge using a particular strategy. Supported strategies are '. + '"squash" and "merge".'), + pht( + 'The "squash" strategy collapses multiple local commits into '. + 'a single commit when publishing. It produces a linear '. + 'published history (but discards local checkpoint commits). '. + 'This is the default strategy.'), + pht( + 'The "merge" strategy generates a merge commit when publishing '. + 'that retains local checkpoint commits (but produces a '. + 'nonlinear published history). Select this strategy if you do '. + 'not want "arc land" to discard checkpoint commits.'), + )), + $this->newWorkflowArgument('revision') + ->setParameter('revision-identifier') + ->setHelp( pht( - "Squashing the commits under %s failed. ". - "Manually squash your commits and run '%s' again.", - $this->branch, - 'arc land')); - } - - if ($repository_api->isBookmark($this->branch)) { - // a bug in mercurial means bookmarks end up on the revision prior - // to the collapse when using --collapse with --keep, - // so we manually move them to the correct spots - // see: http://bz.selenic.com/show_bug.cgi?id=3716 - $repository_api->execxLocal( - 'bookmark -f %s', - $this->onto); - - $repository_api->execxLocal( - 'bookmark -f %s -r %s', - $this->branch, - $branch_rev_id); - } - - // check if the branch had children - list($output) = $repository_api->execxLocal( - 'log -r %s --template %s', - hgsprintf('children(%s)', $this->branch), - '{node}\n'); - - $child_branch_roots = phutil_split_lines($output, false); - $child_branch_roots = array_filter($child_branch_roots); - if ($child_branch_roots) { - // move the branch's children onto the collapsed commit - foreach ($child_branch_roots as $child_root) { - $repository_api->execxLocal( - 'rebase -d %s -s %s --keep --keepbranches', - $this->onto, - $child_root); - } - } - - // All the rebases may have moved us to another branch - // so we move back. - $repository_api->execxLocal('checkout %s', $this->onto); - } - } - - /** - * Detect alternate branches and prompt the user for how to handle - * them. An alternate branch is a branch that forks from the landing - * branch prior to the landing branch tip. - * - * In a situation like this: - * -a--------b master - * \ - * w--x landingbranch - * \ \-- g subbranch - * \--y altbranch1 - * \--z altbranch2 - * - * y and z are alternate branches and will get deleted by the squash, - * so we need to detect them and ask the user what they want to do. - * - * @param string The revision id of the landing branch's root commit. - * @param string The revset specifying all the commits in the landing branch. - * @return void - */ - private function handleAlternateBranches($branch_root, $branch_range) { - $repository_api = $this->getRepositoryAPI(); - - // Using the tree in the doccomment, the revset below resolves as follows: - // 1. roots(descendants(w) - descendants(x) - (w::x)) - // 2. roots({x,g,y,z} - {g} - {w,x}) - // 3. roots({y,z}) - // 4. {y,z} - $alt_branch_revset = hgsprintf( - 'roots(descendants(%s)-descendants(%s)-%R)', - $branch_root, - $this->branch, - $branch_range); - list($alt_branches) = $repository_api->execxLocal( - 'log --template %s -r %s', - '{node}\n', - $alt_branch_revset); - - $alt_branches = phutil_split_lines($alt_branches, false); - $alt_branches = array_filter($alt_branches); - - $alt_count = count($alt_branches); - if ($alt_count > 0) { - $input = phutil_console_prompt(pht( - "%s '%s' has %s %s(s) forking off of it that would be deleted ". - "during a squash. Would you like to keep a non-squashed copy, rebase ". - "them on top of '%s', or abort and deal with them yourself? ". - "(k)eep, (r)ebase, (a)bort:", - ucfirst($this->branchType), - $this->branch, - $alt_count, - $this->branchType, - $this->branch)); - - if ($input == 'k' || $input == 'keep') { - $this->keepBranch = true; - } else if ($input == 'r' || $input == 'rebase') { - foreach ($alt_branches as $alt_branch) { - $repository_api->execxLocal( - 'rebase --keep --keepbranches -d %s -s %s', - $this->branch, - $alt_branch); - } - } else if ($input == 'a' || $input == 'abort') { - $branch_string = implode("\n", $alt_branches); - echo - "\n", + 'Land a specific revision, rather than determining revisions '. + 'automatically from the commits that are landing.')), + $this->newWorkflowArgument('preview') + ->setHelp( pht( - "Remove the %s starting at these revisions and run %s again:\n%s", - $this->branchType.'s', - $branch_string, - 'arc land'), - "\n\n"; - throw new ArcanistUserAbortException(); - } else { - throw new ArcanistUsageException( - pht('Invalid choice. Aborting arc land.')); - } - } - } - - private function merge() { - $repository_api = $this->getRepositoryAPI(); - - // In immutable histories, do a --no-ff merge to force a merge commit with - // the right message. - $repository_api->execxLocal('checkout %s', $this->onto); - - chdir($repository_api->getPath()); - if ($this->isGit) { - $err = phutil_passthru( - 'git merge --no-stat --no-ff --no-commit %s', - $this->branch); - - if ($err) { - throw new ArcanistUsageException(pht( - "'%s' failed. Your working copy has been left in a partially ". - "merged state. You can: abort with '%s'; or follow the ". - "instructions to complete the merge.", - 'git merge', - 'git merge --abort')); - } - } else if ($this->isHg) { - // HG arc land currently doesn't support --merge. - // When merging a bookmark branch to a master branch that - // hasn't changed since the fork, mercurial fails to merge. - // Instead of only working in some cases, we just disable --merge - // until there is a demand for it. - // The user should never reach this line, since --merge is - // forbidden at the command line argument level. - throw new ArcanistUsageException( - pht('%s is not currently supported for hg repos.', '--merge')); - } - } - - private function push() { - $repository_api = $this->getRepositoryAPI(); - - // These commands can fail legitimately (e.g. commit hooks) - try { - if ($this->isGit) { - $repository_api->execxLocal('commit -F %s', $this->messageFile); - if (phutil_is_windows()) { - // Occasionally on large repositories on Windows, Git can exit with - // an unclean working copy here. This prevents reverts from being - // pushed to the remote when this occurs. - $this->requireCleanWorkingCopy(); - } - } else if ($this->isHg) { - // hg rebase produces a commit earlier as part of rebase - if (!$this->useSquash) { - $repository_api->execxLocal( - 'commit --logfile %s', - $this->messageFile); - } - } - // We dispatch this event so we can run checks on the merged revision, - // right before it gets pushed out. It's easier to do this in arc land - // than to try to hook into git/hg. - $this->didCommitMerge(); - } catch (Exception $ex) { - $this->executeCleanupAfterFailedPush(); - throw $ex; - } - - if ($this->getArgument('hold')) { - echo phutil_console_format(pht( - 'Holding change in **%s**: it has NOT been pushed yet.', - $this->onto)."\n"); - } else { - echo pht('Pushing change...'), "\n\n"; - - chdir($repository_api->getPath()); - - if ($this->isGitSvn) { - $err = phutil_passthru('git svn dcommit'); - $cmd = 'git svn dcommit'; - } else if ($this->isGit) { - $err = phutil_passthru('git push %s %s', $this->remote, $this->onto); - $cmd = 'git push'; - } else if ($this->isHgSvn) { - // hg-svn doesn't support 'push -r', so we do a normal push - // which hg-svn modifies to only push the current branch and - // ancestors. - $err = $repository_api->execPassthru('push %s', $this->remote); - $cmd = 'hg push'; - } else if ($this->isHg) { - if (strlen($this->remote)) { - $err = $repository_api->execPassthru( - 'push -r %s %s', - $this->onto, - $this->remote); - } else { - $err = $repository_api->execPassthru( - 'push -r %s', - $this->onto); - } - $cmd = 'hg push'; - } - - if ($err) { - echo phutil_console_format( - "** %s **\n", - pht('PUSH FAILED!')); - $this->executeCleanupAfterFailedPush(); - if ($this->isGit) { - throw new ArcanistUsageException(pht( - "'%s' failed! Fix the error and run '%s' again.", - $cmd, - 'arc land')); - } - throw new ArcanistUsageException(pht( - "'%s' failed! Fix the error and push this change manually.", - $cmd)); - } - - $this->didPush(); - - echo "\n"; - } - } - - private function executeCleanupAfterFailedPush() { - $repository_api = $this->getRepositoryAPI(); - if ($this->isGit) { - $repository_api->execxLocal('reset --hard HEAD^'); - $this->restoreBranch(); - } else if ($this->isHg) { - $repository_api->execxLocal( - '--config extensions.mq= strip %s', - $this->onto); - $this->restoreBranch(); - } - } - - private function cleanupBranch() { - $repository_api = $this->getRepositoryAPI(); - - echo pht('Cleaning up feature %s...', $this->branchType), "\n"; - if ($this->isGit) { - list($ref) = $repository_api->execxLocal( - 'rev-parse --verify %s', - $this->branch); - $ref = trim($ref); - $recovery_command = csprintf( - 'git checkout -b %s %s', - $this->branch, - $ref); - echo pht('(Use `%s` if you want it back.)', $recovery_command), "\n"; - $repository_api->execxLocal('branch -D %s', $this->branch); - } else if ($this->isHg) { - $common_ancestor = $repository_api->getCanonicalRevisionName( - hgsprintf('ancestor(%s,%s)', $this->onto, $this->branch)); - - $branch_root = $repository_api->getCanonicalRevisionName( - hgsprintf('first((%s::%s)-%s)', - $common_ancestor, - $this->branch, - $common_ancestor)); - - $repository_api->execxLocal( - '--config extensions.mq= strip -r %s', - $branch_root); - - if ($repository_api->isBookmark($this->branch)) { - $repository_api->execxLocal('bookmark -d %s', $this->branch); - } - } - - if ($this->getArgument('delete-remote')) { - if ($this->isHg) { - // named branches were closed as part of the earlier commit - // so only worry about bookmarks - if ($repository_api->isBookmark($this->branch)) { - $repository_api->execxLocal( - 'push -B %s %s', - $this->branch, - $this->remote); - } - } - } - } - - public function getSupportedRevisionControlSystems() { - return array('git', 'hg'); - } - - private function getBranchOrBookmark() { - $repository_api = $this->getRepositoryAPI(); - if ($this->isGit) { - $branch = $repository_api->getBranchName(); - - // If we don't have a branch name, just use whatever's at HEAD. - if (!strlen($branch) && !$this->isGitSvn) { - $branch = $repository_api->getWorkingCopyRevision(); - } - } else if ($this->isHg) { - $branch = $repository_api->getActiveBookmark(); - if (!$branch) { - $branch = $repository_api->getBranchName(); - } - } - - return $branch; - } - - private function getBranchType($branch) { - $repository_api = $this->getRepositoryAPI(); - if ($this->isHg && $repository_api->isBookmark($branch)) { - return 'bookmark'; - } - return 'branch'; + 'Show the changes that will land. Does not modify the working '. + 'copy or the remote.')), + $this->newWorkflowArgument('into') + ->setParameter('commit-ref') + ->setHelp( + pht( + 'Specify the state to merge into. By default, this is the same '. + 'as the "onto" ref.')), + $this->newWorkflowArgument('into-remote') + ->setParameter('remote-name') + ->setHelp( + pht( + 'Specifies the remote to fetch the "into" ref from. By '. + 'default, this is the same as the "onto" remote.')), + $this->newWorkflowArgument('into-local') + ->setHelp( + pht( + 'Use the local "into" ref state instead of fetching it from '. + 'a remote.')), + $this->newWorkflowArgument('into-empty') + ->setHelp( + pht( + 'Merge into the empty state instead of an existing state. This '. + 'mode is primarily useful when creating a new repository, and '. + 'selected automatically if the "onto" ref does not exist and the '. + '"into" state is not specified.')), + $this->newWorkflowArgument('incremental') + ->setHelp( + array( + pht( + 'When landing multiple revisions at once, push and rebase '. + 'after each merge completes instead of waiting until all '. + 'merges are completed to push.'), + pht( + 'This is slower than the default behavior and not atomic, '. + 'but may make it easier to resolve conflicts and land '. + 'complicated changes by allowing you to make progress one '. + 'step at a time.'), + )), + $this->newWorkflowArgument('pick') + ->setHelp( + pht( + 'Land only the changes directly named by arguments, instead '. + 'of all reachable ancestors.')), + $this->newWorkflowArgument('ref') + ->setWildcard(true), + ); } - /** - * Restore the original branch, e.g. after a successful land or a failed - * pull. - */ - private function restoreBranch() { - $repository_api = $this->getRepositoryAPI(); - $repository_api->execxLocal('checkout %s', $this->oldBranch); - if ($this->isGit) { - $repository_api->execxLocal('submodule update --init --recursive'); - } - echo pht( - "Switched back to %s %s.\n", - $this->branchType, - phutil_console_format('**%s**', $this->oldBranch)); + protected function newPrompts() { + return array( + $this->newPrompt('arc.land.large-working-set') + ->setDescription( + pht( + 'Confirms landing more than %s commit(s) in a single operation.', + new PhutilNumber($this->getLargeWorkingSetLimit()))), + $this->newPrompt('arc.land.confirm') + ->setDescription( + pht( + 'Confirms that the correct changes have been selected to '. + 'land.')), + $this->newPrompt('arc.land.implicit') + ->setDescription( + pht( + 'Confirms that local commits which are not associated with '. + 'a revision have been associated correctly and should land.')), + $this->newPrompt('arc.land.unauthored') + ->setDescription( + pht( + 'Confirms that revisions you did not author should land.')), + $this->newPrompt('arc.land.changes-planned') + ->setDescription( + pht( + 'Confirms that revisions with changes planned should land.')), + $this->newPrompt('arc.land.published') + ->setDescription( + pht( + 'Confirms that revisions that are already published should land.')), + $this->newPrompt('arc.land.not-accepted') + ->setDescription( + pht( + 'Confirms that revisions that are not accepted should land.')), + $this->newPrompt('arc.land.open-parents') + ->setDescription( + pht( + 'Confirms that revisions with open parent revisions should '. + 'land.')), + $this->newPrompt('arc.land.failed-builds') + ->setDescription( + pht( + 'Confirms that revisions with failed builds should land.')), + $this->newPrompt('arc.land.ongoing-builds') + ->setDescription( + pht( + 'Confirms that revisions with ongoing builds should land.')), + $this->newPrompt('arc.land.create') + ->setDescription( + pht( + 'Confirms that new branches or bookmarks should be created '. + 'in the remote.')), + ); } - - /** - * Check if a diff has a running or failed buildable, and prompt the user - * before landing if it does. - */ - private function checkForBuildables($diff_phid) { - // Try to use the more modern check which respects the "Warn on Land" - // behavioral flag on build plans if we can. This newer check won't work - // unless the server is running code from March 2019 or newer since the - // API methods we need won't exist yet. We'll fall back to the older check - // if this one doesn't work out. - try { - $this->checkForBuildablesWithPlanBehaviors($diff_phid); - return; - } catch (ArcanistUserAbortException $abort_ex) { - throw $abort_ex; - } catch (Exception $ex) { - // Continue with the older approach, below. - } - - // NOTE: Since Harbormaster is still beta and this stuff all got added - // recently, just bail if we can't find a buildable. This is just an - // advisory check intended to prevent human error. - - try { - $buildables = $this->getConduit()->callMethodSynchronous( - 'harbormaster.querybuildables', - array( - 'buildablePHIDs' => array($diff_phid), - 'manualBuildables' => false, - )); - } catch (ConduitClientException $ex) { - return; - } - - if (!$buildables['data']) { - // If there's no corresponding buildable, we're done. - return; - } - - $console = PhutilConsole::getConsole(); - - $buildable = head($buildables['data']); - - if ($buildable['buildableStatus'] == 'passed') { - $console->writeOut( - "** %s ** %s\n", - pht('BUILDS PASSED'), - pht('Harbormaster builds for the active diff completed successfully.')); - return; - } - - switch ($buildable['buildableStatus']) { - case 'building': - $message = pht( - 'Harbormaster is still building the active diff for this revision.'); - $prompt = pht('Land revision anyway, despite ongoing build?'); - break; - case 'failed': - $message = pht( - 'Harbormaster failed to build the active diff for this revision.'); - $prompt = pht('Land revision anyway, despite build failures?'); - break; - default: - // If we don't recognize the status, just bail. - return; - } - - $builds = $this->queryBuilds( - array( - 'buildablePHIDs' => array($buildable['phid']), - )); - - $console->writeOut($message."\n\n"); - - $builds = msortv($builds, 'getStatusSortVector'); - foreach ($builds as $build) { - $ansi_color = $build->getStatusANSIColor(); - $status_name = $build->getStatusName(); - $object_name = $build->getObjectName(); - $build_name = $build->getName(); - - echo tsprintf( - " ** %s ** %s: %s\n", - $status_name, - $object_name, - $build_name); - } - - $console->writeOut( - "\n%s\n\n **%s**: __%s__", - pht('You can review build details here:'), - pht('Harbormaster URI'), - $buildable['uri']); - - if (!phutil_console_confirm($prompt)) { - throw new ArcanistUserAbortException(); - } + public function getLargeWorkingSetLimit() { + return 50; } - private function checkForBuildablesWithPlanBehaviors($diff_phid) { - // TODO: These queries should page through all results instead of fetching - // only the first page, but we don't have good primitives to support that - // in "master" yet. - - $this->writeInfo( - pht('BUILDS'), - pht('Checking build status...')); - - $raw_buildables = $this->getConduit()->callMethodSynchronous( - 'harbormaster.buildable.search', - array( - 'constraints' => array( - 'objectPHIDs' => array( - $diff_phid, - ), - 'manual' => false, - ), - )); - - if (!$raw_buildables['data']) { - return; - } - - $buildables = $raw_buildables['data']; - $buildable_phids = ipull($buildables, 'phid'); - - $raw_builds = $this->getConduit()->callMethodSynchronous( - 'harbormaster.build.search', - array( - 'constraints' => array( - 'buildables' => $buildable_phids, - ), - )); - - if (!$raw_builds['data']) { - return; - } - - $builds = array(); - foreach ($raw_builds['data'] as $raw_build) { - $build_ref = ArcanistBuildRef::newFromConduit($raw_build); - $build_phid = $build_ref->getPHID(); - $builds[$build_phid] = $build_ref; - } - - $plan_phids = mpull($builds, 'getBuildPlanPHID'); - $plan_phids = array_values($plan_phids); - - $raw_plans = $this->getConduit()->callMethodSynchronous( - 'harbormaster.buildplan.search', - array( - 'constraints' => array( - 'phids' => $plan_phids, - ), - )); + public function runWorkflow() { + $working_copy = $this->getWorkingCopy(); + $repository_api = $working_copy->getRepositoryAPI(); - $plans = array(); - foreach ($raw_plans['data'] as $raw_plan) { - $plan_ref = ArcanistBuildPlanRef::newFromConduit($raw_plan); - $plan_phid = $plan_ref->getPHID(); - $plans[$plan_phid] = $plan_ref; - } - - $ongoing_builds = array(); - $failed_builds = array(); - - $builds = msortv($builds, 'getStatusSortVector'); - foreach ($builds as $build_ref) { - $plan = idx($plans, $build_ref->getBuildPlanPHID()); - if (!$plan) { - continue; - } - - $plan_behavior = $plan->getBehavior('arc-land', 'always'); - $if_building = ($plan_behavior == 'building'); - $if_complete = ($plan_behavior == 'complete'); - $if_never = ($plan_behavior == 'never'); - - // If the build plan "Never" warns when landing, skip it. - if ($if_never) { - continue; - } - - // If the build plan warns when landing "If Complete" but the build is - // not complete, skip it. - if ($if_complete && !$build_ref->isComplete()) { - continue; - } - - // If the build plan warns when landing "If Building" but the build is - // complete, skip it. - if ($if_building && $build_ref->isComplete()) { - continue; - } - - // Ignore passing builds. - if ($build_ref->isPassed()) { - continue; - } - - if (!$build_ref->isComplete()) { - $ongoing_builds[] = $build_ref; - } else { - $failed_builds[] = $build_ref; - } - } - - if (!$ongoing_builds && !$failed_builds) { - return; - } - - if ($failed_builds) { - $this->writeWarn( - pht('BUILD FAILURES'), + $land_engine = $repository_api->getLandEngine(); + if (!$land_engine) { + throw new PhutilArgumentUsageException( pht( - 'Harbormaster failed to build the active diff for this revision:')); - $prompt = pht('Land revision anyway, despite build failures?'); - } else if ($ongoing_builds) { - $this->writeWarn( - pht('ONGOING BUILDS'), - pht( - 'Harbormaster is still building the active diff for this revision:')); - $prompt = pht('Land revision anyway, despite ongoing build?'); - } - - $show_builds = array_merge($failed_builds, $ongoing_builds); - echo "\n"; - foreach ($show_builds as $build_ref) { - $ansi_color = $build_ref->getStatusANSIColor(); - $status_name = $build_ref->getStatusName(); - $object_name = $build_ref->getObjectName(); - $build_name = $build_ref->getName(); - - echo tsprintf( - " ** %s ** %s: %s\n", - $status_name, - $object_name, - $build_name); - } - - echo tsprintf( - "\n%s\n\n", - pht('You can review build details here:')); - - foreach ($buildables as $buildable) { - $buildable_uri = id(new PhutilURI($this->getConduitURI())) - ->setPath(sprintf('/B%d', $buildable['id'])); - - echo tsprintf( - " **%s**: __%s__\n", - pht('Buildable %d', $buildable['id']), - $buildable_uri); - } - - if (!phutil_console_confirm($prompt)) { - throw new ArcanistUserAbortException(); - } - } - - public function buildEngineMessage(ArcanistLandEngine $engine) { - // TODO: This is oh-so-gross. - $this->findRevision(); - $engine->setCommitMessageFile($this->messageFile); - } - - public function didCommitMerge() { - $this->dispatchEvent( - ArcanistEventType::TYPE_LAND_WILLPUSHREVISION, - array()); - } - - public function didPush() { - $this->askForRepositoryUpdate(); - - $mark_workflow = $this->buildChildWorkflow( - 'close-revision', - array( - '--finalize', - '--quiet', - $this->revision['id'], - )); - $mark_workflow->run(); - } - - private function queryBuilds(array $constraints) { - $conduit = $this->getConduit(); - - // NOTE: This method only loads the 100 most recent builds. It's rare for - // a revision to have more builds than that and there's currently no paging - // wrapper for "*.search" Conduit API calls available in Arcanist. - - try { - $raw_result = $conduit->callMethodSynchronous( - 'harbormaster.build.search', - array( - 'constraints' => $constraints, - )); - } catch (Exception $ex) { - // If the server doesn't have "harbormaster.build.search" yet (Aug 2016), - // try the older "harbormaster.querybuilds" instead. - $raw_result = $conduit->callMethodSynchronous( - 'harbormaster.querybuilds', - $constraints); - } - - $refs = array(); - foreach ($raw_result['data'] as $raw_data) { - $refs[] = ArcanistBuildRef::newFromConduit($raw_data); - } - - return $refs; + '"arc land" must be run in a Git or Mercurial working copy.')); + } + + $is_incremental = $this->getArgument('incremental'); + $source_refs = $this->getArgument('ref'); + + $onto_remote_arg = $this->getArgument('onto-remote'); + $onto_args = $this->getArgument('onto'); + + $into_remote = $this->getArgument('into-remote'); + $into_empty = $this->getArgument('into-empty'); + $into_local = $this->getArgument('into-local'); + $into = $this->getArgument('into'); + + $is_preview = $this->getArgument('preview'); + $should_hold = $this->getArgument('hold'); + $should_keep = $this->getArgument('keep-branches'); + + $revision = $this->getArgument('revision'); + $strategy = $this->getArgument('strategy'); + $pick = $this->getArgument('pick'); + + $land_engine + ->setViewer($this->getViewer()) + ->setWorkflow($this) + ->setLogEngine($this->getLogEngine()) + ->setSourceRefs($source_refs) + ->setShouldHold($should_hold) + ->setShouldKeep($should_keep) + ->setStrategyArgument($strategy) + ->setShouldPreview($is_preview) + ->setOntoRemoteArgument($onto_remote_arg) + ->setOntoArguments($onto_args) + ->setIntoRemoteArgument($into_remote) + ->setIntoEmptyArgument($into_empty) + ->setIntoLocalArgument($into_local) + ->setIntoArgument($into) + ->setPickArgument($pick) + ->setIsIncremental($is_incremental) + ->setRevisionSymbol($revision); + + $land_engine->execute(); } - } diff --git a/src/workflow/ArcanistLookWorkflow.php b/src/workflow/ArcanistLookWorkflow.php new file mode 100644 index 00000000..ac0cedcb --- /dev/null +++ b/src/workflow/ArcanistLookWorkflow.php @@ -0,0 +1,246 @@ +newWorkflowInformation() + ->setSynopsis( + pht('You stand in the middle of a small clearing.')) + ->addExample('**look**') + ->addExample('**look** [options] -- __thing__') + ->setHelp($help); + } + + public function getWorkflowArguments() { + return array( + $this->newWorkflowArgument('argv') + ->setWildcard(true), + ); + } + + public function runWorkflow() { + echo tsprintf( + "%!\n\n", + pht( + 'Arcventure')); + + $argv = $this->getArgument('argv'); + + if ($argv) { + if ($argv === array('remotes')) { + return $this->lookRemotes(); + } + + if ($argv === array('published')) { + return $this->lookPublished(); + } + + echo tsprintf( + "%s\n", + pht( + 'You do not see "%s" anywhere.', + implode(' ', $argv))); + + return 1; + } + + echo tsprintf( + "%W\n\n", + pht( + 'You stand in the middle of a small clearing in the woods.')); + + $now = time(); + $hour = (int)date('G', $now); + + if ($hour >= 5 && $hour <= 7) { + $time = pht( + 'It is early morning. Glimses of sunlight peek through the trees '. + 'and you hear the faint sound of birds overhead.'); + } else if ($hour >= 8 && $hour <= 10) { + $time = pht( + 'It is morning. The sun is high in the sky to the east and you hear '. + 'birds all around you. A gentle breeze rustles the leaves overhead.'); + } else if ($hour >= 11 && $hour <= 13) { + $time = pht( + 'It is midday. The sun is high overhead and the air is still. It is '. + 'very warm. You hear the cry of a hawk high overhead and far in the '. + 'distance.'); + } else if ($hour >= 14 && $hour <= 16) { + $time = pht( + 'It is afternoon. The air has changed and it feels as though it '. + 'may rain. You hear a squirrel chittering high overhead.'); + } else if ($hour >= 17 && $hour <= 19) { + $time = pht( + 'It is nearly dusk. The wind has picked up and the trees around you '. + 'sway and rustle.'); + } else if ($hour >= 21 && $hour <= 23) { + $time = pht( + 'It is late in the evening. The air is cool and still, and filled '. + 'with the sound of crickets.'); + } else { + $phase = new PhutilLunarPhase($now); + if ($phase->isNew()) { + $time = pht( + 'Night has fallen, and the thin sliver of moon overhead offers '. + 'no comfort. It is almost pitch black. The night is bitter '. + 'cold. It will be difficult to look around in these conditions.'); + } else if ($phase->isFull()) { + $time = pht( + 'Night has fallen, but your surroundings are illuminated by the '. + 'silvery glow of a full moon overhead. The night is cool and '. + 'the air is crisp. The trees are calm.'); + } else if ($phase->isWaxing()) { + $time = pht( + 'Night has fallen. The moon overhead is waxing, and provides '. + 'just enough light that you can make out your surroundings. It '. + 'is quite cold.'); + } else if ($phase->isWaning()) { + $time = pht( + 'Night has fallen. The moon overhead is waning. You can barely '. + 'make out your surroundings. It is very cold.'); + } + } + + echo tsprintf( + "%W\n\n", + $time); + + echo tsprintf( + "%W\n\n", + pht( + 'Several small trails and footpaths cross here, twisting away from '. + 'you among the trees.')); + + echo tsprintf( + pht("Just ahead to the north, you can see **remotes**.\n")); + + return 0; + } + + private function lookRemotes() { + echo tsprintf( + "%W\n\n", + pht( + 'You follow a wide, straight path to the north and arrive in a '. + 'grove of fruit trees after a few minutes of walking. The grass '. + 'underfoot is thick and small insects flit through the air.')); + + echo tsprintf( + "%W\n\n", + pht( + 'At the far edge of the grove, you see remotes:')); + + $api = $this->getRepositoryAPI(); + + $remotes = $api->newRemoteRefQuery() + ->execute(); + + $this->loadHardpoints( + $remotes, + ArcanistRemoteRef::HARDPOINT_REPOSITORYREFS); + + foreach ($remotes as $remote) { + + $view = $remote->newRefView(); + + $push_uri = $remote->getPushURI(); + if ($push_uri === null) { + $push_uri = '-'; + } + + $view->appendLine( + pht( + 'Push URI: %s', + $push_uri)); + + $push_repository = $remote->getPushRepositoryRef(); + if ($push_repository) { + $push_display = $push_repository->getDisplayName(); + } else { + $push_display = '-'; + } + + $view->appendLine( + pht( + 'Push Repository: %s', + $push_display)); + + $fetch_uri = $remote->getFetchURI(); + if ($fetch_uri === null) { + $fetch_uri = '-'; + } + + $view->appendLine( + pht( + 'Fetch URI: %s', + $fetch_uri)); + + $fetch_repository = $remote->getFetchRepositoryRef(); + if ($fetch_repository) { + $fetch_display = $fetch_repository->getDisplayName(); + } else { + $fetch_display = '-'; + } + + $view->appendLine( + pht( + 'Fetch Repository: %s', + $fetch_display)); + + echo tsprintf('%s', $view); + } + + echo tsprintf("\n"); + echo tsprintf( + pht( + "Across the grove, a stream flows north toward ". + "**published** commits.\n")); + } + + private function lookPublished() { + echo tsprintf( + "%W\n\n", + pht( + 'You walk along the narrow bank of the stream as it winds lazily '. + 'downhill and turns east, gradually widening into a river.')); + + $api = $this->getRepositoryAPI(); + + $published = $api->getPublishedCommitHashes(); + + if ($published) { + echo tsprintf( + "%W\n\n", + pht( + 'Floating on the water, you see published commits:')); + + foreach ($published as $hash) { + echo tsprintf( + "%s\n", + $hash); + } + + echo tsprintf( + "\n%W\n", + pht( + 'They river bubbles peacefully.')); + } else { + echo tsprintf( + "%W\n", + pht( + 'The river bubbles quietly, but you do not see any published '. + 'commits anywhere.')); + } + } + +} diff --git a/src/workflow/ArcanistMarkersWorkflow.php b/src/workflow/ArcanistMarkersWorkflow.php new file mode 100644 index 00000000..f3933566 --- /dev/null +++ b/src/workflow/ArcanistMarkersWorkflow.php @@ -0,0 +1,291 @@ +getRepositoryAPI(); + + $marker_type = $this->getWorkflowMarkerType(); + + $markers = $api->newMarkerRefQuery() + ->withMarkerTypes(array($marker_type)) + ->execute(); + + $tail_hashes = $api->getPublishedCommitHashes(); + + $heads = mpull($markers, 'getCommitHash'); + + $graph = $api->getGraph(); + $limit = 1000; + + $query = $graph->newQuery() + ->withHeadHashes($heads) + ->setLimit($limit + 1); + + if ($tail_hashes) { + $query->withTailHashes($tail_hashes); + } + + $nodes = $query->execute(); + if (count($nodes) > $limit) { + + // TODO: Show what we can. + + throw new PhutilArgumentUsageException( + pht( + 'Found more than %s unpublished commits which are ancestors of '. + 'heads.', + new PhutilNumber($limit))); + } + + // We may have some markers which point at commits which are already + // published. These markers won't be reached by following heads backwards + // until we reach published commits. + + // Load these markers exactly so they don't vanish in the output. + + // TODO: Mark these sets as published. + + $disjoint_heads = array(); + foreach ($heads as $head) { + if (!isset($nodes[$head])) { + $disjoint_heads[] = $head; + } + } + + if ($disjoint_heads) { + $disjoint_nodes = $graph->newQuery() + ->withExactHashes($disjoint_heads) + ->execute(); + + $nodes += $disjoint_nodes; + } + + $state_refs = array(); + foreach ($nodes as $node) { + $commit_ref = $node->getCommitRef(); + + $state_ref = id(new ArcanistWorkingCopyStateRef()) + ->setCommitRef($commit_ref); + + $state_refs[$node->getCommitHash()] = $state_ref; + } + + $this->loadHardpoints( + $state_refs, + ArcanistWorkingCopyStateRef::HARDPOINT_REVISIONREFS); + + $partitions = $graph->newPartitionQuery() + ->withHeads($heads) + ->withHashes(array_keys($nodes)) + ->execute(); + + $revision_refs = array(); + foreach ($state_refs as $hash => $state_ref) { + $revision_ids = mpull($state_ref->getRevisionRefs(), 'getID'); + $revision_refs[$hash] = array_fuse($revision_ids); + } + + $partition_sets = array(); + $partition_vectors = array(); + foreach ($partitions as $partition_key => $partition) { + $sets = $partition->newSetQuery() + ->setWaypointMap($revision_refs) + ->execute(); + + list($sets, $partition_vector) = $this->sortSets( + $graph, + $sets, + $markers); + + $partition_sets[$partition_key] = $sets; + $partition_vectors[$partition_key] = $partition_vector; + } + + $partition_vectors = msortv($partition_vectors, 'getSelf'); + $partitions = array_select_keys( + $partitions, + array_keys($partition_vectors)); + + $partition_lists = array(); + foreach ($partitions as $partition_key => $partition) { + $sets = $partition_sets[$partition_key]; + + $roots = array(); + foreach ($sets as $set) { + if (!$set->getParentSets()) { + $roots[] = $set; + } + } + + // TODO: When no parent of a set is in the node list, we should render + // a marker showing that the commit sequence is historic. + + $row_lists = array(); + foreach ($roots as $set) { + $view = id(new ArcanistCommitGraphSetTreeView()) + ->setRepositoryAPI($api) + ->setRootSet($set) + ->setMarkers($markers) + ->setStateRefs($state_refs); + + $row_lists[] = $view->draw(); + } + $partition_lists[] = $row_lists; + } + + $grid = id(new ArcanistGridView()); + $grid->newColumn('marker'); + $grid->newColumn('commits'); + $grid->newColumn('status'); + $grid->newColumn('revisions'); + $grid->newColumn('messages') + ->setMinimumWidth(12); + + foreach ($partition_lists as $row_lists) { + foreach ($row_lists as $row_list) { + foreach ($row_list as $row) { + $grid->newRow($row); + } + } + } + + echo tsprintf('%s', $grid->drawGrid()); + } + + final protected function hasMarkerTypeSupport($marker_type) { + $api = $this->getRepositoryAPI(); + + $types = $api->getSupportedMarkerTypes(); + $types = array_fuse($types); + + return isset($types[$marker_type]); + } + + private function sortSets( + ArcanistCommitGraph $graph, + array $sets, + array $markers) { + + $marker_groups = mgroup($markers, 'getCommitHash'); + $sets = mpull($sets, null, 'getSetID'); + + $active_markers = array(); + foreach ($sets as $set_id => $set) { + foreach ($set->getHashes() as $hash) { + $markers = idx($marker_groups, $hash, array()); + + $has_active = false; + foreach ($markers as $marker) { + if ($marker->getIsActive()) { + $has_active = true; + break; + } + } + + if ($has_active) { + $active_markers[$set_id] = $set; + break; + } + } + } + + $stack = array_select_keys($sets, array_keys($active_markers)); + while ($stack) { + $cursor = array_pop($stack); + foreach ($cursor->getParentSets() as $parent_id => $parent) { + if (isset($active_markers[$parent_id])) { + continue; + } + $active_markers[$parent_id] = $parent; + $stack[] = $parent; + } + } + + $partition_epoch = 0; + $partition_names = array(); + + $vectors = array(); + foreach ($sets as $set_id => $set) { + if (isset($active_markers[$set_id])) { + $has_active = 1; + } else { + $has_active = 0; + } + + $max_epoch = 0; + $marker_names = array(); + foreach ($set->getHashes() as $hash) { + $node = $graph->getNode($hash); + $max_epoch = max($max_epoch, $node->getCommitEpoch()); + + $markers = idx($marker_groups, $hash, array()); + foreach ($markers as $marker) { + $marker_names[] = $marker->getName(); + } + } + + $partition_epoch = max($partition_epoch, $max_epoch); + + if ($marker_names) { + $has_markers = 1; + natcasesort($marker_names); + $max_name = last($marker_names); + + $partition_names[] = $max_name; + } else { + $has_markers = 0; + $max_name = ''; + } + + + $vector = id(new PhutilSortVector()) + ->addInt($has_active) + ->addInt($max_epoch) + ->addInt($has_markers) + ->addString($max_name); + + $vectors[$set_id] = $vector; + } + + $vectors = msortv_natural($vectors, 'getSelf'); + $vector_keys = array_keys($vectors); + + foreach ($sets as $set_id => $set) { + $child_sets = $set->getDisplayChildSets(); + $child_sets = array_select_keys($child_sets, $vector_keys); + $set->setDisplayChildSets($child_sets); + } + + $sets = array_select_keys($sets, $vector_keys); + + if ($active_markers) { + $any_active = true; + } else { + $any_active = false; + } + + if ($partition_names) { + $has_markers = 1; + natcasesort($partition_names); + $partition_name = last($partition_names); + } else { + $has_markers = 0; + $partition_name = ''; + } + + $partition_vector = id(new PhutilSortVector()) + ->addInt($any_active) + ->addInt($partition_epoch) + ->addInt($has_markers) + ->addString($partition_name); + + return array($sets, $partition_vector); + } + +} diff --git a/src/workflow/ArcanistPasteWorkflow.php b/src/workflow/ArcanistPasteWorkflow.php index 24468e7a..c447cf29 100644 --- a/src/workflow/ArcanistPasteWorkflow.php +++ b/src/workflow/ArcanistPasteWorkflow.php @@ -1,172 +1,171 @@ newWorkflowInformation() ->addExample('**paste** [__options__] --') ->addExample('**paste** [__options__] -- __object__') ->setHelp($help); } public function getWorkflowArguments() { return array( $this->newWorkflowArgument('title') ->setParameter('title') ->setHelp(pht('Title for the paste.')), $this->newWorkflowArgument('lang') ->setParameter('language') ->setHelp(pht('Language for the paste.')), $this->newWorkflowArgument('input') ->setParameter('path') ->setIsPathArgument(true) ->setHelp(pht('Create a paste using the content in a file.')), $this->newWorkflowArgument('browse') ->setHelp(pht('After creating a paste, open it in a web browser.')), $this->newWorkflowArgument('argv') ->setWildcard(true), ); } public function runWorkflow() { $set_language = $this->getArgument('lang'); $set_title = $this->getArgument('title'); $is_browse = $this->getArgument('browse'); $input_path = $this->getArgument('input'); $argv = $this->getArgument('argv'); if (count($argv) > 1) { throw new PhutilArgumentUsageException( pht( 'Specify only one paste to retrieve.')); } $is_read = (count($argv) === 1); $symbols = $this->getSymbolEngine(); if (count($argv) === 1) { if ($set_language !== null) { throw new PhutilArgumentUsageException( pht( 'Flag "--lang" is not supported when reading pastes.')); } if ($set_title !== null) { throw new PhutilArgumentUsageException( pht( 'Flag "--title" is not supported when reading pastes.')); } if ($is_browse) { throw new PhutilArgumentUsageException( pht( 'Flag "--browse" is not supported when reading pastes. Use '. '"arc browse" to browse known objects.')); } if ($input_path !== null) { throw new PhutilArgumentUsageException( pht( 'Flag "--input" is not supported when reading pastes.')); } $paste_symbol = $argv[0]; $paste_ref = $symbols->loadPasteForSymbol($paste_symbol); if (!$paste_ref) { throw new PhutilArgumentUsageException( pht( 'Paste "%s" does not exist, or you do not have access '. 'to see it.', $paste_symbol)); } echo $paste_ref->getContent(); return 0; } if ($input_path === null || $input_path === '-') { $content = $this->readStdin(); } else { $content = Filesystem::readFile($input_path); } $xactions = array(); if ($set_title === null) { $set_title = pht('Command-Line Input'); } $xactions[] = array( 'type' => 'title', 'value' => $set_title, ); if ($set_language !== null) { $xactions[] = array( 'type' => 'language', 'value' => $set_language, ); } $xactions[] = array( 'type' => 'text', 'value' => $content, ); $method = 'paste.edit'; $parameters = array( 'transactions' => $xactions, ); $conduit_engine = $this->getConduitEngine(); - $conduit_call = $conduit_engine->newCall($method, $parameters); - $conduit_future = $conduit_engine->newFuture($conduit_call); + $conduit_future = $conduit_engine->newFuture($method, $parameters); $result = $conduit_future->resolve(); $paste_phid = idxv($result, array('object', 'phid')); $paste_ref = $symbols->loadPasteForSymbol($paste_phid); $uri = $paste_ref->getURI(); $uri = $this->getAbsoluteURI($uri); $log = $this->getLogEngine(); $log->writeSuccess( pht('DONE'), pht('Created a new paste.')); echo tsprintf( '%s', - $paste_ref->newDisplayRef() + $paste_ref->newRefView() ->setURI($uri)); if ($is_browse) { $this->openURIsInBrowser(array($uri)); } return 0; } } diff --git a/src/workflow/ArcanistUploadWorkflow.php b/src/workflow/ArcanistUploadWorkflow.php index c9c9dd83..d888c464 100644 --- a/src/workflow/ArcanistUploadWorkflow.php +++ b/src/workflow/ArcanistUploadWorkflow.php @@ -1,223 +1,144 @@ newWorkflowInformation() ->setSynopsis(pht('Upload files.')) ->addExample(pht('**upload** [__options__] -- __file__ [__file__ ...]')) ->setHelp($help); } public function getWorkflowArguments() { return array( $this->newWorkflowArgument('json') ->setHelp(pht('Output upload information in JSON format.')), $this->newWorkflowArgument('browse') ->setHelp( pht( 'After the upload completes, open the files in a web browser.')), $this->newWorkflowArgument('temporary') ->setHelp( pht( 'Mark the file as temporary. Temporary files will be '. 'deleted after 24 hours.')), $this->newWorkflowArgument('paths') ->setWildcard(true) ->setIsPathArgument(true), ); } public function runWorkflow() { if (!$this->getArgument('paths')) { throw new PhutilArgumentUsageException( pht('Specify one or more paths to files you want to upload.')); } $is_temporary = $this->getArgument('temporary'); $is_json = $this->getArgument('json'); $is_browse = $this->getArgument('browse'); $paths = $this->getArgument('paths'); $conduit = $this->getConduitEngine(); $results = array(); $uploader = id(new ArcanistFileUploader()) ->setConduitEngine($conduit); foreach ($paths as $key => $path) { $file = id(new ArcanistFileDataRef()) ->setName(basename($path)) ->setPath($path); if ($is_temporary) { $expires_at = time() + phutil_units('24 hours in seconds'); $file->setDeleteAfterEpoch($expires_at); } $uploader->addFile($file); } $files = $uploader->uploadFiles(); $phids = array(); foreach ($files as $file) { // TODO: This could be handled more gracefully. if ($file->getErrors()) { throw new Exception(implode("\n", $file->getErrors())); } $phids[] = $file->getPHID(); } $symbols = $this->getSymbolEngine(); $symbol_refs = $symbols->loadFilesForSymbols($phids); $refs = array(); foreach ($symbol_refs as $symbol_ref) { $ref = $symbol_ref->getObject(); if ($ref === null) { throw new Exception( pht( 'Failed to resolve symbol ref "%s".', $symbol_ref->getSymbol())); } $refs[] = $ref; } if ($is_json) { $json = array(); foreach ($refs as $key => $ref) { $uri = $ref->getURI(); $uri = $this->getAbsoluteURI($uri); $map = array( 'argument' => $paths[$key], 'id' => $ref->getID(), 'phid' => $ref->getPHID(), 'name' => $ref->getName(), 'uri' => $uri, ); $json[] = $map; } echo id(new PhutilJSON())->encodeAsList($json); } else { foreach ($refs as $ref) { $uri = $ref->getURI(); $uri = $this->getAbsoluteURI($uri); echo tsprintf( '%s', - $ref->newDisplayRef() + $ref->newRefView() ->setURI($uri)); } } if ($is_browse) { $uris = array(); foreach ($refs as $ref) { $uri = $ref->getURI(); $uri = $this->getAbsoluteURI($uri); $uris[] = $uri; } $this->openURIsInBrowser($uris); } return 0; } private function writeStatus($line) { $this->writeStatusMessage($line."\n"); } - private function uploadChunks($file_phid, $path) { - $conduit = $this->getConduit(); - - $f = @fopen($path, 'rb'); - if (!$f) { - throw new Exception(pht('Unable to open file "%s"', $path)); - } - - $this->writeStatus(pht('Beginning chunked upload of large file...')); - $chunks = $conduit->resolveCall( - 'file.querychunks', - array( - 'filePHID' => $file_phid, - )); - - $remaining = array(); - foreach ($chunks as $chunk) { - if (!$chunk['complete']) { - $remaining[] = $chunk; - } - } - - $done = (count($chunks) - count($remaining)); - - if ($done) { - $this->writeStatus( - pht( - 'Resuming upload (%s of %s chunks remain).', - phutil_count($remaining), - phutil_count($chunks))); - } else { - $this->writeStatus( - pht( - 'Uploading chunks (%s chunks to upload).', - phutil_count($remaining))); - } - - $progress = new PhutilConsoleProgressBar(); - $progress->setTotal(count($chunks)); - - for ($ii = 0; $ii < $done; $ii++) { - $progress->update(1); - } - - $progress->draw(); - - // TODO: We could do these in parallel to improve upload performance. - foreach ($remaining as $chunk) { - $offset = $chunk['byteStart']; - - $ok = fseek($f, $offset); - if ($ok !== 0) { - throw new Exception( - pht( - 'Failed to %s!', - 'fseek()')); - } - - $data = fread($f, $chunk['byteEnd'] - $chunk['byteStart']); - if ($data === false) { - throw new Exception( - pht( - 'Failed to %s!', - 'fread()')); - } - - $conduit->resolveCall( - 'file.uploadchunk', - array( - 'filePHID' => $file_phid, - 'byteStart' => $offset, - 'dataEncoding' => 'base64', - 'data' => base64_encode($data), - )); - - $progress->update(1); - } - } - } diff --git a/src/workflow/ArcanistWorkWorkflow.php b/src/workflow/ArcanistWorkWorkflow.php new file mode 100644 index 00000000..3696bb17 --- /dev/null +++ b/src/workflow/ArcanistWorkWorkflow.php @@ -0,0 +1,95 @@ +newWorkflowArgument('start') + ->setParameter('symbol') + ->setHelp( + pht( + 'When creating a new branch or bookmark, use this as the '. + 'branch point.')), + $this->newWorkflowArgument('symbol') + ->setWildcard(true), + ); + } + + public function getWorkflowInformation() { + $help = pht(<<newWorkflowInformation() + ->setSynopsis(pht('Begin or resume work.')) + ->addExample(pht('**work** [--start __start__] __symbol__')) + ->setHelp($help); + } + + public function runWorkflow() { + $api = $this->getRepositoryAPI(); + + $work_engine = $api->getWorkEngine(); + if (!$work_engine) { + throw new PhutilArgumentUsageException( + pht( + '"arc work" must be run in a Git or Mercurial working copy.')); + } + + $argv = $this->getArgument('symbol'); + if (count($argv) === 0) { + throw new PhutilArgumentUsageException( + pht( + 'Provide a branch, bookmark, task, or revision name to begin '. + 'or resume work on.')); + } else if (count($argv) === 1) { + $symbol_argument = $argv[0]; + if (!strlen($symbol_argument)) { + throw new PhutilArgumentUsageException( + pht( + 'Provide a nonempty symbol to begin or resume work on.')); + } + } else { + throw new PhutilArgumentUsageException( + pht( + 'Too many arguments: provide exactly one argument.')); + } + + $start_argument = $this->getArgument('start'); + + $work_engine + ->setViewer($this->getViewer()) + ->setWorkflow($this) + ->setLogEngine($this->getLogEngine()) + ->setSymbolArgument($symbol_argument) + ->setStartArgument($start_argument) + ->execute(); + + return 0; + } + +} diff --git a/src/workflow/ArcanistWorkflow.php b/src/workflow/ArcanistWorkflow.php index f4514698..edad2d8d 100644 --- a/src/workflow/ArcanistWorkflow.php +++ b/src/workflow/ArcanistWorkflow.php @@ -1,2443 +1,2467 @@ toolset = $toolset; return $this; } final public function getToolset() { return $this->toolset; } final public function setRuntime(ArcanistRuntime $runtime) { $this->runtime = $runtime; return $this; } final public function getRuntime() { return $this->runtime; } final public function setConfigurationEngine( ArcanistConfigurationEngine $engine) { $this->configurationEngine = $engine; return $this; } final public function getConfigurationEngine() { return $this->configurationEngine; } final public function setConfigurationSourceList( ArcanistConfigurationSourceList $list) { $this->configurationSourceList = $list; return $this; } final public function getConfigurationSourceList() { return $this->configurationSourceList; } public function newPhutilWorkflow() { $arguments = $this->getWorkflowArguments(); assert_instances_of($arguments, 'ArcanistWorkflowArgument'); $specs = mpull($arguments, 'getPhutilSpecification'); $phutil_workflow = id(new ArcanistPhutilWorkflow()) ->setName($this->getWorkflowName()) ->setWorkflow($this) ->setArguments($specs); $information = $this->getWorkflowInformation(); if ($information !== null) { if (!($information instanceof ArcanistWorkflowInformation)) { throw new Exception( pht( 'Expected workflow ("%s", of class "%s") to return an '. '"ArcanistWorkflowInformation" object from call to '. '"getWorkflowInformation()", got %s.', $this->getWorkflowName(), get_class($this), phutil_describe_type($information))); } } if ($information) { $synopsis = $information->getSynopsis(); if (strlen($synopsis)) { $phutil_workflow->setSynopsis($synopsis); } $examples = $information->getExamples(); if ($examples) { $examples = implode("\n", $examples); $phutil_workflow->setExamples($examples); } $help = $information->getHelp(); if (strlen($help)) { // Unwrap linebreaks in the help text so we don't get weird formatting. $help = preg_replace("/(?<=\S)\n(?=\S)/", ' ', $help); $phutil_workflow->setHelp($help); } } return $phutil_workflow; } final public function newLegacyPhutilWorkflow() { $phutil_workflow = id(new ArcanistPhutilWorkflow()) ->setName($this->getWorkflowName()); $arguments = $this->getArguments(); $specs = array(); foreach ($arguments as $key => $argument) { if ($key == '*') { $key = $argument; $argument = array( 'wildcard' => true, ); } unset($argument['paramtype']); unset($argument['supports']); unset($argument['nosupport']); unset($argument['passthru']); unset($argument['conflict']); $spec = array( 'name' => $key, ) + $argument; $specs[] = $spec; } $phutil_workflow->setArguments($specs); $synopses = $this->getCommandSynopses(); $phutil_workflow->setSynopsis($synopses); $help = $this->getCommandHelp(); if (strlen($help)) { $phutil_workflow->setHelp($help); } return $phutil_workflow; } final protected function newWorkflowArgument($key) { return id(new ArcanistWorkflowArgument()) ->setKey($key); } final protected function newWorkflowInformation() { return new ArcanistWorkflowInformation(); } final public function executeWorkflow(PhutilArgumentParser $args) { $runtime = $this->getRuntime(); $this->arguments = $args; $caught = null; $runtime->pushWorkflow($this); try { $err = $this->runWorkflow($args); } catch (Exception $ex) { $caught = $ex; } try { $this->runWorkflowCleanup(); } catch (Exception $ex) { phlog($ex); } $runtime->popWorkflow(); if ($caught) { throw $caught; } return $err; } - final protected function getLogEngine() { + final public function getLogEngine() { return $this->getRuntime()->getLogEngine(); } protected function runWorkflowCleanup() { // TOOLSETS: Do we need this? return; } public function __construct() {} public function run() { throw new PhutilMethodNotImplementedException(); } /** * Finalizes any cleanup operations that need to occur regardless of * whether the command succeeded or failed. */ public function finalize() { $this->finalizeWorkingCopy(); } /** * Return the command used to invoke this workflow from the command like, * e.g. "help" for @{class:ArcanistHelpWorkflow}. * * @return string The command a user types to invoke this workflow. */ abstract public function getWorkflowName(); /** * Return console formatted string with all command synopses. * * @return string 6-space indented list of available command synopses. */ public function getCommandSynopses() { return array(); } /** * Return console formatted string with command help printed in `arc help`. * * @return string 10-space indented help to use the command. */ public function getCommandHelp() { return null; } public function supportsToolset(ArcanistToolset $toolset) { return false; } /* -( Conduit )------------------------------------------------------------ */ /** * Set the URI which the workflow will open a conduit connection to when * @{method:establishConduit} is called. Arcanist makes an effort to set * this by default for all workflows (by reading ##.arcconfig## and/or the * value of ##--conduit-uri##) even if they don't need Conduit, so a workflow * can generally upgrade into a conduit workflow later by just calling * @{method:establishConduit}. * * You generally should not need to call this method unless you are * specifically overriding the default URI. It is normally sufficient to * just invoke @{method:establishConduit}. * * NOTE: You can not call this after a conduit has been established. * * @param string The URI to open a conduit to when @{method:establishConduit} * is called. * @return this * @task conduit */ final public function setConduitURI($conduit_uri) { if ($this->conduit) { throw new Exception( pht( '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( pht( 'You must specify a Conduit URI with %s before you can '. 'establish a conduit.', 'setConduitURI()')); } $this->conduit = new ConduitClient($this->conduitURI); if ($this->conduitTimeout) { $this->conduit->setTimeout($this->conduitTimeout); } return $this; } final public function getConfigFromAnySource($key) { $source_list = $this->getConfigurationSourceList(); if ($source_list) { $value_list = $source_list->getStorageValueList($key); if ($value_list) { return last($value_list)->getValue(); } return null; } return $this->configurationManager->getConfigFromAnySource($key); } /** * 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( pht('You may not set new credentials after authenticating conduit.')); } $this->conduitCredentials = $credentials; return $this; } /** * Get the protocol version the client should identify with. * * @return int Version the client should claim to be. * @task conduit */ final public function getConduitVersion() { return 6; } /** * 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; try { if (!$credentials) { throw new Exception( pht( 'Set conduit credentials with %s before authenticating conduit!', 'setConduitCredentials()')); } // If we have `token`, this server supports the simpler, new-style // token-based authentication. Use that instead of all the certificate // stuff. $token = idx($credentials, 'token'); if (strlen($token)) { $conduit = $this->getConduit(); $conduit->setConduitToken($token); try { $result = $this->getConduit()->callMethodSynchronous( 'user.whoami', array()); $this->userName = $result['userName']; $this->userPHID = $result['phid']; $this->conduitAuthenticated = true; return $this; } catch (Exception $ex) { $conduit->setConduitToken(null); throw $ex; } } if (empty($credentials['user'])) { throw new ConduitClientException( 'ERR-INVALID-USER', pht('Empty user in credentials.')); } if (empty($credentials['certificate'])) { throw new ConduitClientException( 'ERR-NO-CERTIFICATE', pht('Empty certificate in credentials.')); } $description = idx($credentials, 'description', ''); $user = $credentials['user']; $certificate = $credentials['certificate']; $connection = $this->getConduit()->callMethodSynchronous( 'conduit.connect', array( 'client' => 'arc', 'clientVersion' => $this->getConduitVersion(), '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' || $ex->getErrorCode() == 'ERR-INVALID-AUTH') { $conduit_uri = $this->conduitURI; $message = phutil_console_format( "\n%s\n\n %s\n\n%s\n%s", pht('YOU NEED TO __INSTALL A CERTIFICATE__ TO LOGIN TO PHABRICATOR'), pht('To do this, run: **%s**', 'arc install-certificate'), pht("The server '%s' rejected your request:", $conduit_uri), $ex->getMessage()); throw new ArcanistUsageException($message); } else if ($ex->getErrorCode() == 'NEW-ARC-VERSION') { // Cleverly disguise this as being AWESOME!!! echo phutil_console_format("**%s**\n\n", pht('New Version Available!')); echo phutil_console_wrap($ex->getMessage()); echo "\n\n"; echo pht('In most cases, arc can be upgraded automatically.')."\n"; $ok = phutil_console_confirm( pht('Upgrade arc now?'), $default_no = false); if (!$ok) { throw $ex; } $root = dirname(phutil_get_library_root('arcanist')); chdir($root); $err = phutil_passthru('%s upgrade', $root.'/bin/arc'); if (!$err) { echo "\n".pht('Try running your arc command again.')."\n"; } exit(1); } 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( pht( "This workflow ('%s') requires authentication, override ". "%s to return true.", $workflow, 'requiresAuthentication()')); } return $this->userPHID; } /** * 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( pht( "This workflow ('%s') requires a Conduit, override ". "%s to return true.", $workflow, 'requiresConduit()')); } return $this->conduit; } final public function setArcanistConfiguration( ArcanistConfiguration $arcanist_configuration) { $this->arcanistConfiguration = $arcanist_configuration; return $this; } final public function getArcanistConfiguration() { return $this->arcanistConfiguration; } final public function setConfigurationManager( ArcanistConfigurationManager $arcanist_configuration_manager) { $this->configurationManager = $arcanist_configuration_manager; return $this; } final public function getConfigurationManager() { return $this->configurationManager; } public function requiresWorkingCopy() { return false; } public function desiresWorkingCopy() { return false; } public function requiresRepositoryAPI() { return false; } public function desiresRepositoryAPI() { return false; } final public function setCommand($command) { $this->command = $command; return $this; } final public function getCommand() { return $this->command; } public function getArguments() { return array(); } final public function setWorkingDirectory($working_directory) { $this->workingDirectory = $working_directory; return $this; } final public function getWorkingDirectory() { return $this->workingDirectory; } final private function setParentWorkflow($parent_workflow) { $this->parentWorkflow = $parent_workflow; return $this; } final protected function getParentWorkflow() { return $this->parentWorkflow; } final public function buildChildWorkflow($command, array $argv) { $arc_config = $this->getArcanistConfiguration(); $workflow = $arc_config->buildWorkflow($command); $workflow->setParentWorkflow($this); $workflow->setConduitEngine($this->getConduitEngine()); $workflow->setCommand($command); $workflow->setConfigurationManager($this->getConfigurationManager()); 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; $workflow->setConduitCredentials($this->conduitCredentials); $workflow->conduitAuthenticated = $this->conduitAuthenticated; } $workflow->setArcanistConfiguration($arc_config); $workflow->parseArguments(array_values($argv)); return $workflow; } final public function getArgument($key, $default = null) { // TOOLSETS: Remove this legacy code. if (is_array($this->arguments)) { return idx($this->arguments, $key, $default); } return $this->arguments->getArg($key); } final public function getCompleteArgumentSpecification() { $spec = $this->getArguments(); $arc_config = $this->getArcanistConfiguration(); $command = $this->getCommand(); $spec += $arc_config->getCustomArgumentsForCommand($command); return $spec; } final 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; } } foreach ($spec as $long => $options) { if (!empty($options['repeat'])) { $dict[$long] = array(); } } $more = array(); $size = count($args); for ($ii = 0; $ii < $size; $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); $parts = explode('=', $arg_key, 2); if (count($parts) == 2) { list($arg_key, $val) = $parts; array_splice($args, $ii, 1, array('--'.$arg_key, $val)); $size++; } if (!array_key_exists($arg_key, $spec)) { $corrected = PhutilArgumentSpellingCorrector::newFlagCorrector() ->correctSpelling($arg_key, array_keys($spec)); if (count($corrected) == 1) { PhutilConsole::getConsole()->writeErr( pht( "(Assuming '%s' is the British spelling of '%s'.)", '--'.$arg_key, '--'.head($corrected))."\n"); $arg_key = head($corrected); } else { throw new ArcanistUsageException( pht( "Unknown argument '%s'. Try '%s'.", $arg_key, 'arc help')); } } } else if (!strncmp($arg, '-', 1)) { $arg_key = substr($arg, 1); if (empty($short_to_long_map[$arg_key])) { throw new ArcanistUsageException( pht( "Unknown argument '%s'. Try '%s'.", $arg_key, '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 == $size - 1) { throw new ArcanistUsageException( pht( "Option '%s' requires a parameter.", $arg)); } if (!empty($options['repeat'])) { $dict[$arg_key][] = $args[$ii + 1]; } else { $dict[$arg_key] = $args[$ii + 1]; } $ii++; } } if ($more) { if ($more_key) { $dict[$more_key] = $more; } else { $example = reset($more); throw new ArcanistUsageException( pht( "Unrecognized argument '%s'. Try '%s'.", $example, '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( pht( "Arguments '%s' and '%s' are mutually exclusive", "--{$key}", "--{$conflict}").$more); } } } $this->arguments = $dict; $this->didParseArguments(); return $this; } protected function didParseArguments() { // Override this to customize workflow argument behavior. } final public function getWorkingCopy() { $configuration_engine = $this->getConfigurationEngine(); // TOOLSETS: Remove this once all workflows are toolset workflows. if (!$configuration_engine) { throw new Exception( pht( 'This workflow has not yet been updated to Toolsets and can '. 'not retrieve a modern WorkingCopy object. Use '. '"getWorkingCopyIdentity()" to retrieve a previous-generation '. 'object.')); } return $configuration_engine->getWorkingCopy(); } final public function getWorkingCopyIdentity() { $configuration_engine = $this->getConfigurationEngine(); if ($configuration_engine) { $working_copy = $configuration_engine->getWorkingCopy(); $working_path = $working_copy->getWorkingDirectory(); return ArcanistWorkingCopyIdentity::newFromPath($working_path); } $working_copy = $this->getConfigurationManager()->getWorkingCopyIdentity(); if (!$working_copy) { $workflow = get_class($this); throw new Exception( pht( "This workflow ('%s') requires a working copy, override ". "%s to return true.", $workflow, 'requiresWorkingCopy()')); } return $working_copy; } final public function setRepositoryAPI($api) { $this->repositoryAPI = $api; return $this; } final public function hasRepositoryAPI() { try { return (bool)$this->getRepositoryAPI(); } catch (Exception $ex) { return false; } } final public function getRepositoryAPI() { $configuration_engine = $this->getConfigurationEngine(); if ($configuration_engine) { $working_copy = $configuration_engine->getWorkingCopy(); return $working_copy->getRepositoryAPI(); } if (!$this->repositoryAPI) { $workflow = get_class($this); throw new Exception( pht( "This workflow ('%s') requires a Repository API, override ". "%s to return true.", $workflow, 'requiresRepositoryAPI()')); } return $this->repositoryAPI; } final protected function shouldRequireCleanUntrackedFiles() { return empty($this->arguments['allow-untracked']); } final public function setCommitMode($mode) { $this->commitMode = $mode; return $this; } final public function finalizeWorkingCopy() { if ($this->stashed) { $api = $this->getRepositoryAPI(); $api->unstashChanges(); echo pht('Restored stashed changes to the working directory.')."\n"; } } final public function requireCleanWorkingCopy() { $api = $this->getRepositoryAPI(); $must_commit = array(); $working_copy_desc = phutil_console_format( " %s: __%s__\n\n", pht('Working copy'), $api->getPath()); // NOTE: this is a subversion-only concept. $incomplete = $api->getIncompleteChanges(); if ($incomplete) { throw new ArcanistUsageException( sprintf( "%s\n\n%s %s\n %s\n\n%s", pht( "You have incompletely checked out directories in this working ". "copy. Fix them before proceeding.'"), $working_copy_desc, pht('Incomplete directories in working copy:'), implode("\n ", $incomplete), pht( "You can fix these paths by running '%s' on them.", 'svn update'))); } $conflicts = $api->getMergeConflicts(); if ($conflicts) { throw new ArcanistUsageException( sprintf( "%s\n\n%s %s\n %s", pht( 'You have merge conflicts in this working copy. Resolve merge '. 'conflicts before proceeding.'), $working_copy_desc, pht('Conflicts in working copy:'), implode("\n ", $conflicts))); } $missing = $api->getMissingChanges(); if ($missing) { throw new ArcanistUsageException( sprintf( "%s\n\n%s %s\n %s\n", pht( 'You have missing files in this working copy. Revert or formally '. 'remove them (with `%s`) before proceeding.', 'svn rm'), $working_copy_desc, pht('Missing files in working copy:'), implode("\n ", $missing))); } $externals = $api->getDirtyExternalChanges(); // TODO: This state can exist in Subversion, but it is currently handled // elsewhere. It should probably be handled here, eventually. if ($api instanceof ArcanistSubversionAPI) { $externals = array(); } if ($externals) { $message = pht( '%s submodule(s) have uncommitted or untracked changes:', new PhutilNumber(count($externals))); $prompt = pht( 'Ignore the changes to these %s submodule(s) and continue?', new PhutilNumber(count($externals))); $list = id(new PhutilConsoleList()) ->setWrap(false) ->addItems($externals); id(new PhutilConsoleBlock()) ->addParagraph($message) ->addList($list) ->draw(); $ok = phutil_console_confirm($prompt, $default_no = false); if (!$ok) { throw new ArcanistUserAbortException(); } } $uncommitted = $api->getUncommittedChanges(); $unstaged = $api->getUnstagedChanges(); // We already dealt with externals. $unstaged = array_diff($unstaged, $externals); // We only want files which are purely uncommitted. $uncommitted = array_diff($uncommitted, $unstaged); $uncommitted = array_diff($uncommitted, $externals); $untracked = $api->getUntrackedChanges(); if (!$this->shouldRequireCleanUntrackedFiles()) { $untracked = array(); } if ($untracked) { echo sprintf( "%s\n\n%s", pht('You have untracked files in this working copy.'), $working_copy_desc); if ($api instanceof ArcanistGitAPI) { $hint = pht( '(To ignore these %s change(s), add them to "%s".)', phutil_count($untracked), '.git/info/exclude'); } else if ($api instanceof ArcanistSubversionAPI) { $hint = pht( '(To ignore these %s change(s), add them to "%s".)', phutil_count($untracked), 'svn:ignore'); } else if ($api instanceof ArcanistMercurialAPI) { $hint = pht( '(To ignore these %s change(s), add them to "%s".)', phutil_count($untracked), '.hgignore'); } $untracked_list = " ".implode("\n ", $untracked); echo sprintf( " %s\n %s\n%s", pht('Untracked changes in working copy:'), $hint, $untracked_list); $prompt = pht( 'Ignore these %s untracked file(s) and continue?', phutil_count($untracked)); if (!phutil_console_confirm($prompt)) { throw new ArcanistUserAbortException(); } } $should_commit = false; if ($unstaged || $uncommitted) { // NOTE: We're running this because it builds a cache and can take a // perceptible amount of time to arrive at an answer, but we don't want // to pause in the middle of printing the output below. $this->getShouldAmend(); echo sprintf( "%s\n\n%s", pht('You have uncommitted changes in this working copy.'), $working_copy_desc); $lists = array(); if ($unstaged) { $unstaged_list = " ".implode("\n ", $unstaged); $lists[] = sprintf( " %s\n%s", pht('Unstaged changes in working copy:'), $unstaged_list); } if ($uncommitted) { $uncommitted_list = " ".implode("\n ", $uncommitted); $lists[] = sprintf( "%s\n%s", pht('Uncommitted changes in working copy:'), $uncommitted_list); } echo implode("\n\n", $lists)."\n"; $all_uncommitted = array_merge($unstaged, $uncommitted); if ($this->askForAdd($all_uncommitted)) { if ($unstaged) { $api->addToCommit($unstaged); } $should_commit = true; } else { $permit_autostash = $this->getConfigFromAnySource('arc.autostash'); if ($permit_autostash && $api->canStashChanges()) { echo pht( 'Stashing uncommitted changes. (You can restore them with `%s`).', 'git stash pop')."\n"; $api->stashChanges(); $this->stashed = true; } else { throw new ArcanistUsageException( pht( 'You can not continue with uncommitted changes. '. 'Commit or discard them before proceeding.')); } } } if ($should_commit) { if ($this->getShouldAmend()) { $commit = head($api->getLocalCommitInformation()); $api->amendCommit($commit['message']); } else if ($api->supportsLocalCommits()) { $template = sprintf( "\n\n# %s\n#\n# %s\n#\n", pht('Enter a commit message.'), pht('Changes:')); $paths = array_merge($uncommitted, $unstaged); $paths = array_unique($paths); sort($paths); foreach ($paths as $path) { $template .= "# ".$path."\n"; } $commit_message = $this->newInteractiveEditor($template) ->setName(pht('commit-message')) ->editInteractively(); if ($commit_message === $template) { throw new ArcanistUsageException( pht('You must provide a commit message.')); } $commit_message = ArcanistCommentRemover::removeComments( $commit_message); if (!strlen($commit_message)) { throw new ArcanistUsageException( pht('You must provide a nonempty commit message.')); } $api->doCommit($commit_message); } } } private function getShouldAmend() { if ($this->shouldAmend === null) { $this->shouldAmend = $this->calculateShouldAmend(); } return $this->shouldAmend; } private function calculateShouldAmend() { $api = $this->getRepositoryAPI(); if ($this->isHistoryImmutable() || !$api->supportsAmend()) { return false; } $commits = $api->getLocalCommitInformation(); if (!$commits) { return false; } $commit = reset($commits); $message = ArcanistDifferentialCommitMessage::newFromRawCorpus( $commit['message']); if ($message->getGitSVNBaseRevision()) { return false; } if ($api->getAuthor() != $commit['author']) { return false; } if ($message->getRevisionID() && $this->getArgument('create')) { return false; } // TODO: Check commits since tracking branch. If empty then return false. // Don't amend the current commit if it has already been published. $repository = $this->loadProjectRepository(); if ($repository) { $repo_id = $repository['id']; $commit_hash = $commit['commit']; $callsign = idx($repository, 'callsign'); if ($callsign) { // The server might be too old to support the new style commit names, // so prefer the old way $commit_name = "r{$callsign}{$commit_hash}"; } else { $commit_name = "R{$repo_id}:{$commit_hash}"; } $result = $this->getConduit()->callMethodSynchronous( 'diffusion.querycommits', array('names' => array($commit_name))); $known_commit = idx($result['identifierMap'], $commit_name); if ($known_commit) { return false; } } if (!$message->getRevisionID()) { return true; } $in_working_copy = $api->loadWorkingCopyDifferentialRevisions( $this->getConduit(), array( 'authors' => array($this->getUserPHID()), 'status' => 'status-open', )); if ($in_working_copy) { return true; } return false; } private function askForAdd(array $files) { if ($this->commitMode == self::COMMIT_DISABLE) { return false; } if ($this->commitMode == self::COMMIT_ENABLE) { return true; } $prompt = $this->getAskForAddPrompt($files); return phutil_console_confirm($prompt); } private function getAskForAddPrompt(array $files) { if ($this->getShouldAmend()) { $prompt = pht( 'Do you want to amend these %s change(s) to the current commit?', phutil_count($files)); } else { $prompt = pht( 'Do you want to create a new commit with these %s change(s)?', phutil_count($files)); } return $prompt; } final protected function loadDiffBundleFromConduit( ConduitClient $conduit, $diff_id) { return $this->loadBundleFromConduit( $conduit, array( 'ids' => array($diff_id), )); } final protected function loadRevisionBundleFromConduit( ConduitClient $conduit, $revision_id) { return $this->loadBundleFromConduit( $conduit, array( 'revisionIDs' => array($revision_id), )); } final private function loadBundleFromConduit( ConduitClient $conduit, $params) { $future = $conduit->callMethod('differential.querydiffs', $params); $diff = head($future->resolve()); if ($diff == null) { throw new Exception( phutil_console_wrap( pht("The diff or revision you specified is either invalid or you ". "don't have permission to view it.")) ); } $changes = array(); foreach ($diff['changes'] as $changedict) { $changes[] = ArcanistDiffChange::newFromDictionary($changedict); } $bundle = ArcanistBundle::newFromChanges($changes); $bundle->setConduit($conduit); // since the conduit method has changes, assume that these fields // could be unset $bundle->setBaseRevision(idx($diff, 'sourceControlBaseRevision')); $bundle->setRevisionID(idx($diff, 'revisionID')); $bundle->setAuthorName(idx($diff, 'authorName')); $bundle->setAuthorEmail(idx($diff, 'authorEmail')); 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. */ final protected function getChangedLines($path, $mode) { $repository_api = $this->getRepositoryAPI(); $full_path = $repository_api->getPath($path); if (is_dir($full_path)) { return null; } if (!file_exists($full_path)) { return null; } $change = $this->getChange($path); if ($change->getFileType() !== ArcanistDiffChangeType::FILE_TEXT) { return null; } $lines = $change->getChangedLines($mode); return array_keys($lines); } final protected function getChange($path) { $repository_api = $this->getRepositoryAPI(); // TODO: Very gross $is_git = ($repository_api instanceof ArcanistGitAPI); $is_hg = ($repository_api instanceof ArcanistMercurialAPI); $is_svn = ($repository_api instanceof ArcanistSubversionAPI); if ($is_svn) { // 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 = $this->newDiffParser(); $changes = $parser->parseDiff($diff); if (count($changes) != 1) { throw new Exception(pht('Expected exactly one change.')); } $this->changeCache[$path] = reset($changes); } } else if ($is_git || $is_hg) { if (empty($this->changeCache)) { $changes = $repository_api->getAllLocalChanges(); foreach ($changes as $change) { $this->changeCache[$change->getCurrentPath()] = $change; } } } else { throw new Exception(pht('Missing VCS support.')); } if (empty($this->changeCache[$path])) { if ($is_git || $is_hg) { // This can legitimately occur under git/hg if you make a change, // "git/hg 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( pht( "Trying to get change for unchanged path '%s'!", $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( pht( "Option '%s' is not supported under %s.", "--{$arg}", $system_name). $extended_info); } } } } final protected function normalizeRevisionID($revision_id) { return preg_replace('/^D/i', '', $revision_id); } protected function shouldShellComplete() { return true; } protected function getShellCompletions(array $argv) { return array(); } public function getSupportedRevisionControlSystems() { return array('git', 'hg', 'svn'); } final 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; } final 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; } /** * 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 */ final protected function writeStatusMessage($msg) { fwrite(STDERR, $msg); } final public function writeInfo($title, $message) { $this->writeStatusMessage( phutil_console_format( "** %s ** %s\n", $title, $message)); } final public function writeWarn($title, $message) { $this->writeStatusMessage( phutil_console_format( "** %s ** %s\n", $title, $message)); } final public function writeOkay($title, $message) { $this->writeStatusMessage( phutil_console_format( "** %s ** %s\n", $title, $message)); } final protected function isHistoryImmutable() { $repository_api = $this->getRepositoryAPI(); $config = $this->getConfigFromAnySource('history.immutable'); if ($config !== null) { return $config; } return $repository_api->isHistoryDefaultImmutable(); } /** * 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 * specifying 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. * @param mask Mask of ArcanistRepositoryAPI flags to exclude. * Defaults to ArcanistRepositoryAPI::FLAG_UNTRACKED. * @return list List of paths the workflow should act on. */ final protected function selectPathsForWorkflow( array $paths, $rev, $omit_mask = null) { if ($omit_mask === null) { $omit_mask = ArcanistRepositoryAPI::FLAG_UNTRACKED; } if ($paths) { $working_copy = $this->getWorkingCopyIdentity(); foreach ($paths as $key => $path) { $full_path = Filesystem::resolvePath($path); if (!Filesystem::pathExists($full_path)) { throw new ArcanistUsageException( pht( "Path '%s' does not exist!", $path)); } $relative_path = Filesystem::readablePath( $full_path, $working_copy->getProjectRoot()); $paths[$key] = $relative_path; } } else { $repository_api = $this->getRepositoryAPI(); if ($rev) { $this->parseBaseCommitArgument(array($rev)); } $paths = $repository_api->getWorkingCopyStatus(); foreach ($paths as $path => $flags) { if ($flags & $omit_mask) { unset($paths[$path]); } } $paths = array_keys($paths); } return array_values($paths); } final 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 */ final protected function readScratchFile($path) { if (!$this->repositoryAPI) { return false; } return $this->getRepositoryAPI()->readScratchFile($path); } /** * Try to read a scratch JSON file, if it exists and is readable. * * @param string Scratch file name. * @return array Empty array for failure. * @task scratch */ final protected function readScratchJSONFile($path) { $file = $this->readScratchFile($path); if (!$file) { return array(); } return phutil_json_decode($file); } /** * 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 */ final protected function writeScratchFile($path, $data) { if (!$this->repositoryAPI) { return false; } return $this->getRepositoryAPI()->writeScratchFile($path, $data); } /** * Try to write a scratch JSON file, if there's somewhere to put it and we can * write there. * * @param string Scratch file name to write. * @param array Data to write. * @return bool True on success, false on failure. * @task scratch */ final protected function writeScratchJSONFile($path, array $data) { return $this->writeScratchFile($path, json_encode($data)); } /** * Try to remove a scratch file. * * @param string Scratch file name to remove. * @return bool True if the file was removed successfully. * @task scratch */ final protected function removeScratchFile($path) { if (!$this->repositoryAPI) { return false; } return $this->getRepositoryAPI()->removeScratchFile($path); } /** * Get a human-readable description of the scratch file location. * * @param string Scratch file name. * @return mixed String, or false on failure. * @task scratch */ final protected function getReadableScratchFilePath($path) { if (!$this->repositoryAPI) { return false; } return $this->getRepositoryAPI()->getReadableScratchFilePath($path); } /** * Get the path to a scratch file, if possible. * * @param string Scratch file name. * @return mixed File path, or false on failure. * @task scratch */ final protected function getScratchFilePath($path) { if (!$this->repositoryAPI) { return false; } return $this->getRepositoryAPI()->getScratchFilePath($path); } final protected function getRepositoryEncoding() { return nonempty( idx($this->loadProjectRepository(), 'encoding'), 'UTF-8'); } final protected function loadProjectRepository() { list($info, $reasons) = $this->loadRepositoryInformation(); return coalesce($info, array()); } final protected function newInteractiveEditor($text) { $editor = new PhutilInteractiveEditor($text); $preferred = $this->getConfigFromAnySource('editor'); if ($preferred) { $editor->setPreferredEditor($preferred); } return $editor; } final protected function newDiffParser() { $parser = new ArcanistDiffParser(); if ($this->repositoryAPI) { $parser->setRepositoryAPI($this->getRepositoryAPI()); } $parser->setWriteDiffOnFailure(true); return $parser; } - final protected function resolveCall(ConduitFuture $method) { - try { - return $method->resolve(); - } catch (ConduitClientException $ex) { - if ($ex->getErrorCode() == 'ERR-CONDUIT-CALL') { - echo phutil_console_wrap( - pht( - 'This feature requires a newer version of Phabricator. Please '. - 'update it using these instructions: %s', - 'https://secure.phabricator.com/book/phabricator/article/'. - 'upgrading/')."\n\n"); - } - throw $ex; - } - } - final protected function dispatchEvent($type, array $data) { $data += array( 'workflow' => $this, ); $event = new PhutilEvent($type, $data); PhutilEventEngine::dispatchEvent($event); return $event; } final public function parseBaseCommitArgument(array $argv) { if (!count($argv)) { return; } $api = $this->getRepositoryAPI(); if (!$api->supportsCommitRanges()) { throw new ArcanistUsageException( pht('This version control system does not support commit ranges.')); } if (count($argv) > 1) { throw new ArcanistUsageException( pht( 'Specify exactly one base commit. The end of the commit range is '. 'always the working copy state.')); } $api->setBaseCommit(head($argv)); return $this; } final protected function getRepositoryVersion() { if (!$this->repositoryVersion) { $api = $this->getRepositoryAPI(); $commit = $api->getSourceControlBaseRevision(); $versions = array('' => $commit); foreach ($api->getChangedFiles($commit) as $path => $mask) { $versions[$path] = (Filesystem::pathExists($path) ? md5_file($path) : ''); } $this->repositoryVersion = md5(json_encode($versions)); } return $this->repositoryVersion; } /* -( Phabricator Repositories )------------------------------------------- */ /** * Get the PHID of the Phabricator repository this working copy corresponds * to. Returns `null` if no repository can be identified. * * @return phid|null Repository PHID, or null if no repository can be * identified. * * @task phabrep */ final protected function getRepositoryPHID() { return idx($this->getRepositoryInformation(), 'phid'); } /** * Get the name of the Phabricator repository this working copy * corresponds to. Returns `null` if no repository can be identified. * * @return string|null Repository name, or null if no repository can be * identified. * * @task phabrep */ final protected function getRepositoryName() { return idx($this->getRepositoryInformation(), 'name'); } /** * Get the URI of the Phabricator repository this working copy * corresponds to. Returns `null` if no repository can be identified. * * @return string|null Repository URI, or null if no repository can be * identified. * * @task phabrep */ final protected function getRepositoryURI() { return idx($this->getRepositoryInformation(), 'uri'); } final protected function getRepositoryStagingConfiguration() { return idx($this->getRepositoryInformation(), 'staging'); } /** * Get human-readable reasoning explaining how `arc` evaluated which * Phabricator repository corresponds to this working copy. Used by * `arc which` to explain the process to users. * * @return list Human-readable explanation of the repository * association process. * * @task phabrep */ final protected function getRepositoryReasons() { $this->getRepositoryInformation(); return $this->repositoryReasons; } /** * @task phabrep */ private function getRepositoryInformation() { if ($this->repositoryInfo === null) { list($info, $reasons) = $this->loadRepositoryInformation(); $this->repositoryInfo = nonempty($info, array()); $this->repositoryReasons = $reasons; } return $this->repositoryInfo; } /** * @task phabrep */ private function loadRepositoryInformation() { list($query, $reasons) = $this->getRepositoryQuery(); if (!$query) { return array(null, $reasons); } try { $method = 'repository.query'; - $results = $this->getConduitEngine()->newCall($method, $query) + $results = $this->getConduitEngine() + ->newFuture($method, $query) ->resolve(); } catch (ConduitClientException $ex) { if ($ex->getErrorCode() == 'ERR-CONDUIT-CALL') { $reasons[] = pht( 'This version of Arcanist is more recent than the version of '. 'Phabricator you are connecting to: the Phabricator install is '. 'out of date and does not have support for identifying '. 'repositories by callsign or URI. Update Phabricator to enable '. 'these features.'); return array(null, $reasons); } throw $ex; } $result = null; if (!$results) { $reasons[] = pht( 'No repositories matched the query. Check that your configuration '. 'is correct, or use "%s" to select a repository explicitly.', 'repository.callsign'); } else if (count($results) > 1) { $reasons[] = pht( 'Multiple repostories (%s) matched the query. You can use the '. '"%s" configuration to select the one you want.', implode(', ', ipull($results, 'callsign')), 'repository.callsign'); } else { $result = head($results); $reasons[] = pht('Found a unique matching repository.'); } return array($result, $reasons); } /** * @task phabrep */ private function getRepositoryQuery() { $reasons = array(); $callsign = $this->getConfigFromAnySource('repository.callsign'); if ($callsign) { $query = array( 'callsigns' => array($callsign), ); $reasons[] = pht( 'Configuration value "%s" is set to "%s".', 'repository.callsign', $callsign); return array($query, $reasons); } else { $reasons[] = pht( 'Configuration value "%s" is empty.', 'repository.callsign'); } $uuid = $this->getRepositoryAPI()->getRepositoryUUID(); if ($uuid !== null) { $query = array( 'uuids' => array($uuid), ); $reasons[] = pht( 'The UUID for this working copy is "%s".', $uuid); return array($query, $reasons); } else { $reasons[] = pht( 'This repository has no VCS UUID (this is normal for git/hg).'); } + // TODO: Swap this for a RemoteRefQuery. + $remote_uri = $this->getRepositoryAPI()->getRemoteURI(); if ($remote_uri !== null) { $query = array( 'remoteURIs' => array($remote_uri), ); $reasons[] = pht( 'The remote URI for this working copy is "%s".', $remote_uri); return array($query, $reasons); } else { $reasons[] = pht( 'Unable to determine the remote URI for this repository.'); } return array(null, $reasons); } /** * Build a new lint engine for the current working copy. * * Optionally, you can pass an explicit engine class name to build an engine * of a particular class. Normally this is used to implement an `--engine` * flag from the CLI. * * @param string Optional explicit engine class name. * @return ArcanistLintEngine Constructed engine. */ protected function newLintEngine($engine_class = null) { $working_copy = $this->getWorkingCopyIdentity(); $config = $this->getConfigurationManager(); if (!$engine_class) { $engine_class = $config->getConfigFromAnySource('lint.engine'); } if (!$engine_class) { if (Filesystem::pathExists($working_copy->getProjectPath('.arclint'))) { $engine_class = 'ArcanistConfigurationDrivenLintEngine'; } } if (!$engine_class) { throw new ArcanistNoEngineException( pht( "No lint engine is configured for this project. Create an '%s' ". "file, or configure an advanced engine with '%s' in '%s'.", '.arclint', 'lint.engine', '.arcconfig')); } $base_class = 'ArcanistLintEngine'; if (!class_exists($engine_class) || !is_subclass_of($engine_class, $base_class)) { throw new ArcanistUsageException( pht( 'Configured lint engine "%s" is not a subclass of "%s", but must be.', $engine_class, $base_class)); } $engine = newv($engine_class, array()) ->setWorkingCopy($working_copy) ->setConfigurationManager($config); return $engine; } /** * Build a new unit test engine for the current working copy. * * Optionally, you can pass an explicit engine class name to build an engine * of a particular class. Normally this is used to implement an `--engine` * flag from the CLI. * * @param string Optional explicit engine class name. * @return ArcanistUnitTestEngine Constructed engine. */ protected function newUnitTestEngine($engine_class = null) { $working_copy = $this->getWorkingCopyIdentity(); $config = $this->getConfigurationManager(); if (!$engine_class) { $engine_class = $config->getConfigFromAnySource('unit.engine'); } if (!$engine_class) { if (Filesystem::pathExists($working_copy->getProjectPath('.arcunit'))) { $engine_class = 'ArcanistConfigurationDrivenUnitTestEngine'; } } if (!$engine_class) { throw new ArcanistNoEngineException( pht( "No unit test engine is configured for this project. Create an ". "'%s' file, or configure an advanced engine with '%s' in '%s'.", '.arcunit', 'unit.engine', '.arcconfig')); } $base_class = 'ArcanistUnitTestEngine'; if (!class_exists($engine_class) || !is_subclass_of($engine_class, $base_class)) { throw new ArcanistUsageException( pht( 'Configured unit test engine "%s" is not a subclass of "%s", '. 'but must be.', $engine_class, $base_class)); } $engine = newv($engine_class, array()) ->setWorkingCopy($working_copy) ->setConfigurationManager($config); return $engine; } protected function openURIsInBrowser(array $uris) { $browser = $this->getBrowserCommand(); // The "browser" may actually be a list of arguments. if (!is_array($browser)) { $browser = array($browser); } foreach ($uris as $uri) { $err = phutil_passthru('%LR %R', $browser, $uri); if ($err) { throw new ArcanistUsageException( pht( 'Failed to open URI "%s" in browser ("%s"). '. 'Check your "browser" config option.', $uri, implode(' ', $browser))); } } } private function getBrowserCommand() { $config = $this->getConfigFromAnySource('browser'); if ($config) { return $config; } if (phutil_is_windows()) { // See T13504. We now use "bypass_shell", so "start" alone is no longer // a valid binary to invoke directly. return array( 'cmd', '/c', 'start', ); } $candidates = array( 'sensible-browser' => array('sensible-browser'), 'xdg-open' => array('xdg-open'), 'open' => array('open', '--'), ); // NOTE: The "open" command works well on OS X, but on many Linuxes "open" // exists and is not a browser. For now, we're just looking for other // commands first, but we might want to be smarter about selecting "open" // only on OS X. foreach ($candidates as $cmd => $argv) { if (Filesystem::binaryExists($cmd)) { return $argv; } } throw new ArcanistUsageException( pht( "Unable to find a browser command to run. Set '%s' in your ". "Arcanist config to specify a command to use.", 'browser')); } /** * Ask Phabricator to update the current repository as soon as possible. * * Calling this method after pushing commits allows Phabricator to discover * the commits more quickly, so the system overall is more responsive. * * @return void */ protected function askForRepositoryUpdate() { // If we know which repository we're in, try to tell Phabricator that we // pushed commits to it so it can update. This hint can help pull updates // more quickly, especially in rarely-used repositories. if ($this->getRepositoryPHID()) { try { $this->getConduit()->callMethodSynchronous( 'diffusion.looksoon', array( 'repositories' => array($this->getRepositoryPHID()), )); } catch (ConduitClientException $ex) { // If we hit an exception, just ignore it. Likely, we are running // against a Phabricator which is too old to support this method. // Since this hint is purely advisory, it doesn't matter if it has // no effect. } } } protected function getModernLintDictionary(array $map) { $map = $this->getModernCommonDictionary($map); return $map; } protected function getModernUnitDictionary(array $map) { $map = $this->getModernCommonDictionary($map); $details = idx($map, 'userData'); if (strlen($details)) { $map['details'] = (string)$details; } unset($map['userData']); return $map; } private function getModernCommonDictionary(array $map) { foreach ($map as $key => $value) { if ($value === null) { unset($map[$key]); } } return $map; } final public function setConduitEngine( ArcanistConduitEngine $conduit_engine) { $this->conduitEngine = $conduit_engine; return $this; } final public function getConduitEngine() { return $this->conduitEngine; } final public function getRepositoryRef() { $configuration_engine = $this->getConfigurationEngine(); if ($configuration_engine) { // This is a toolset workflow and can always build a repository ref. } else { if (!$this->getConfigurationManager()->getWorkingCopyIdentity()) { return null; } if (!$this->repositoryAPI) { return null; } } if (!$this->repositoryRef) { $ref = id(new ArcanistRepositoryRef()) ->setPHID($this->getRepositoryPHID()) ->setBrowseURI($this->getRepositoryURI()); $this->repositoryRef = $ref; } return $this->repositoryRef; } final public function getToolsetKey() { return $this->getToolset()->getToolsetKey(); } final public function getConfig($key) { return $this->getConfigurationSourceList()->getConfig($key); } public function canHandleSignal($signo) { return false; } public function handleSignal($signo) { return; } final public function newCommand(PhutilExecutableFuture $future) { return id(new ArcanistCommand()) ->setLogEngine($this->getLogEngine()) ->setExecutableFuture($future); } final public function loadHardpoints( $objects, $requests) { return $this->getRuntime()->loadHardpoints($objects, $requests); } protected function newPrompts() { return array(); } protected function newPrompt($key) { return id(new ArcanistPrompt()) ->setWorkflow($this) ->setKey($key); } public function hasPrompt($key) { $map = $this->getPromptMap(); return isset($map[$key]); } public function getPromptMap() { if ($this->promptMap === null) { $prompts = $this->newPrompts(); assert_instances_of($prompts, 'ArcanistPrompt'); + // TODO: Move this somewhere modular. + + $prompts[] = $this->newPrompt('arc.state.stash') + ->setDescription( + pht( + 'Prompts the user to stash changes and continue when the '. + 'working copy has untracked, uncommitted, or unstaged '. + 'changes.')); + + // TODO: Swap to ArrayCheck? + $map = array(); foreach ($prompts as $prompt) { $key = $prompt->getKey(); if (isset($map[$key])) { throw new Exception( pht( 'Workflow ("%s") generates two prompts with the same '. 'key ("%s"). Each prompt a workflow generates must have a '. 'unique key.', get_class($this), $key)); } $map[$key] = $prompt; } $this->promptMap = $map; } return $this->promptMap; } - protected function getPrompt($key) { + final public function getPrompt($key) { $map = $this->getPromptMap(); $prompt = idx($map, $key); if (!$prompt) { throw new Exception( pht( 'Workflow ("%s") is requesting a prompt ("%s") but it did not '. 'generate any prompt with that name in "newPrompts()".', get_class($this), $key)); } return clone $prompt; } final protected function getSymbolEngine() { return $this->getRuntime()->getSymbolEngine(); } final protected function getViewer() { return $this->getRuntime()->getViewer(); } final protected function readStdin() { $log = $this->getLogEngine(); $log->writeWaitingForInput(); // NOTE: We can't just "file_get_contents()" here because signals don't // interrupt it. If the user types "^C", we want to interrupt the read. $raw_handle = fopen('php://stdin', 'rb'); $stdin = new PhutilSocketChannel($raw_handle); while ($stdin->update()) { PhutilChannel::waitForAny(array($stdin)); } return $stdin->read(); } - protected function getAbsoluteURI($raw_uri) { + final public function getAbsoluteURI($raw_uri) { // TODO: "ArcanistRevisionRef", at least, may return a relative URI. // If we get a relative URI, guess the correct absolute URI based on // the Conduit URI. This might not be correct for Conduit over SSH. $raw_uri = new PhutilURI($raw_uri); if (!strlen($raw_uri->getDomain())) { $base_uri = $this->getConduitEngine() ->getConduitURI(); $raw_uri = id(new PhutilURI($base_uri)) ->setPath($raw_uri->getPath()); } $raw_uri = phutil_string_cast($raw_uri); return $raw_uri; } + final public function writeToPager($corpus) { + $is_tty = (function_exists('posix_isatty') && posix_isatty(STDOUT)); + + if (!$is_tty) { + echo $corpus; + } else { + $pager = $this->getConfig('pager'); + + if (!$pager) { + $pager = array('less', '-R', '--'); + } + + // Try to show the content through a pager. + $err = id(new PhutilExecPassthru('%Ls', $pager)) + ->write($corpus) + ->resolve(); + + // If the pager exits with an error, print the content normally. + if ($err) { + echo $corpus; + } + } + + return $this; + } + } diff --git a/src/xsprintf/hgsprintf.php b/src/xsprintf/hgsprintf.php index 326d0e14..c1006593 100644 --- a/src/xsprintf/hgsprintf.php +++ b/src/xsprintf/hgsprintf.php @@ -1,33 +1,40 @@ ** %s **', $value); $value = PhutilTerminalString::escapeStringValue($value, false); $type = 's'; break; + case '?': + $value = tsprintf('** ? ** %s', $value); + $value = PhutilTerminalString::escapeStringValue($value, false); + $value = phutil_console_wrap($value, 6, false); + $type = 's'; + break; + case '>': + $value = tsprintf(" **$ %s**\n", $value); + $value = PhutilTerminalString::escapeStringValue($value, false); + $type = 's'; + break; case 'd': $type = 'd'; break; default: throw new Exception( pht( 'Unsupported escape sequence "%s" found in pattern: %s', $type, $pattern)); break; } $pattern[$pos] = $type; } diff --git a/support/hg/arc-hg.py b/support/hg/arc-hg.py new file mode 100644 index 00000000..a11cef73 --- /dev/null +++ b/support/hg/arc-hg.py @@ -0,0 +1,185 @@ +from __future__ import absolute_import + +import os +import json + +from mercurial import ( + cmdutil, + bookmarks, + bundlerepo, + error, + hg, + i18n, + node, + registrar, +) + +_ = i18n._ +cmdtable = {} +command = registrar.command(cmdtable) + +@command( + "arc-ls-markers", + [('', 'output', '', + _('file to output refs to'), _('FILE')), + ] + cmdutil.remoteopts, + _('[--output FILENAME] [SOURCE]')) +def lsmarkers(ui, repo, source=None, **opts): + """list markers + + Show the current branch heads and bookmarks in the local working copy, or + a specified path/URL. + + Markers are printed to stdout in JSON. + + (This is an Arcanist extension to Mercurial.) + + Returns 0 if listing the markers succeeds, 1 otherwise. + """ + + if source is None: + markers = localmarkers(ui, repo) + else: + markers = remotemarkers(ui, repo, source, opts) + + json_opts = { + 'indent': 2, + 'sort_keys': True, + } + + output_file = opts.get('output') + if output_file: + if os.path.exists(output_file): + raise error.Abort(_('File "%s" already exists.' % output_file)) + with open(output_file, 'w+') as f: + json.dump(markers, f, **json_opts) + else: + print json.dumps(markers, output_file, **json_opts) + + return 0 + +def localmarkers(ui, repo): + markers = [] + + active_node = repo['.'].node() + all_heads = set(repo.heads()) + current_name = repo.dirstate.branch() + saw_current = False + saw_active = False + + branch_list = repo.branchmap().iterbranches() + for branch_name, branch_heads, tip_node, is_closed in branch_list: + for head_node in branch_heads: + is_active = (head_node == active_node) + is_tip = (head_node == tip_node) + is_current = (branch_name == current_name) + + if is_current: + saw_current = True + + if is_active: + saw_active = True + + if is_closed: + head_closed = True + else: + head_closed = bool(head_node not in all_heads) + + description = repo[head_node].description() + + markers.append({ + 'type': 'branch', + 'name': branch_name, + 'node': node.hex(head_node), + 'isActive': is_active, + 'isClosed': head_closed, + 'isTip': is_tip, + 'isCurrent': is_current, + 'description': description, + }) + + # If the current branch (selected with "hg branch X") is not reflected in + # the list of heads we selected, add a virtual head for it so callers get + # a complete picture of repository marker state. + + if not saw_current: + markers.append({ + 'type': 'branch', + 'name': current_name, + 'node': None, + 'isActive': False, + 'isClosed': False, + 'isTip': False, + 'isCurrent': True, + 'description': None, + }) + + bookmarks = repo._bookmarks + active_bookmark = repo._activebookmark + + for bookmark_name, bookmark_node in bookmarks.iteritems(): + is_active = (active_bookmark == bookmark_name) + description = repo[bookmark_node].description() + + if is_active: + saw_active = True + + markers.append({ + 'type': 'bookmark', + 'name': bookmark_name, + 'node': node.hex(bookmark_node), + 'isActive': is_active, + 'description': description, + }) + + # If the current working copy state is not the head of a branch and there is + # also no active bookmark, add a virtual marker for it so callers can figure + # out exactly where we are. + + if not saw_active: + markers.append({ + 'type': 'commit', + 'name': None, + 'node': node.hex(active_node), + 'isActive': False, + 'isClosed': False, + 'isTip': False, + 'isCurrent': True, + 'description': repo['.'].description(), + }) + + return markers + +def remotemarkers(ui, repo, source, opts): + # Disable status output from fetching a remote. + ui.quiet = True + + markers = [] + + source, branches = hg.parseurl(ui.expandpath(source)) + remote = hg.peer(repo, opts, source) + + with remote.commandexecutor() as e: + branchmap = e.callcommand('branchmap', {}).result() + + for branch_name in branchmap: + for branch_node in branchmap[branch_name]: + markers.append({ + 'type': 'branch', + 'name': branch_name, + 'node': node.hex(branch_node), + }) + + with remote.commandexecutor() as e: + remotemarks = bookmarks.unhexlifybookmarks(e.callcommand('listkeys', { + 'namespace': 'bookmarks', + }).result()) + + for mark in remotemarks: + markers.append({ + 'type': 'bookmark', + 'name': mark, + 'node': node.hex(remotemarks[mark]), + }) + + return markers