diff --git a/.gitignore b/.gitignore index 096c30cb..d40646a4 100644 --- a/.gitignore +++ b/.gitignore @@ -33,3 +33,6 @@ # 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 @@ -55,6 +55,12 @@ $base_args->parsePartial( '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', @@ -89,7 +95,9 @@ $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()); 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 @@ -49,11 +49,11 @@ phutil_register_library_map(array( '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', @@ -64,8 +64,14 @@ phutil_register_library_map(array( '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', @@ -100,6 +106,16 @@ phutil_register_library_map(array( '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', @@ -110,7 +126,8 @@ phutil_register_library_map(array( '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', @@ -162,8 +179,6 @@ phutil_register_library_map(array( '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', @@ -186,8 +201,6 @@ phutil_register_library_map(array( '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', @@ -209,10 +222,17 @@ phutil_register_library_map(array( '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', @@ -221,6 +241,10 @@ phutil_register_library_map(array( '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', @@ -280,7 +304,11 @@ phutil_register_library_map(array( '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', @@ -309,12 +337,23 @@ phutil_register_library_map(array( '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', @@ -322,6 +361,7 @@ phutil_register_library_map(array( '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', @@ -377,6 +417,8 @@ phutil_register_library_map(array( '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', @@ -390,17 +432,30 @@ phutil_register_library_map(array( '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', @@ -411,7 +466,6 @@ phutil_register_library_map(array( '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', @@ -424,10 +478,12 @@ phutil_register_library_map(array( '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', @@ -435,6 +491,7 @@ phutil_register_library_map(array( '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', @@ -506,12 +563,15 @@ phutil_register_library_map(array( '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', @@ -885,6 +945,8 @@ phutil_register_library_map(array( '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', @@ -939,6 +1001,7 @@ phutil_register_library_map(array( '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', @@ -1007,7 +1070,7 @@ phutil_register_library_map(array( 'ArcanistAliasFunctionXHPASTLinterRule' => 'ArcanistXHPASTLinterRule', 'ArcanistAliasFunctionXHPASTLinterRuleTestCase' => 'ArcanistXHPASTLinterRuleTestCase', 'ArcanistAliasWorkflow' => 'ArcanistWorkflow', - 'ArcanistAliasesConfigOption' => 'ArcanistListConfigOption', + 'ArcanistAliasesConfigOption' => 'ArcanistMultiSourceConfigOption', 'ArcanistAmendWorkflow' => 'ArcanistArcWorkflow', 'ArcanistAnoidWorkflow' => 'ArcanistArcWorkflow', 'ArcanistArcConfigurationEngineExtension' => 'ArcanistConfigurationEngineExtension', @@ -1031,11 +1094,11 @@ phutil_register_library_map(array( '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', @@ -1046,8 +1109,14 @@ phutil_register_library_map(array( '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', @@ -1082,6 +1151,16 @@ phutil_register_library_map(array( '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', @@ -1092,7 +1171,8 @@ phutil_register_library_map(array( 'ArcanistComprehensiveLintEngine' => 'ArcanistLintEngine', 'ArcanistConcatenationOperatorXHPASTLinterRule' => 'ArcanistXHPASTLinterRule', 'ArcanistConcatenationOperatorXHPASTLinterRuleTestCase' => 'ArcanistXHPASTLinterRuleTestCase', - 'ArcanistConduitCall' => 'Phobject', + 'ArcanistConduitAuthenticationException' => 'Exception', + 'ArcanistConduitCallFuture' => 'FutureProxy', 'ArcanistConduitEngine' => 'Phobject', 'ArcanistConduitException' => 'Exception', 'ArcanistConfigOption' => 'Phobject', @@ -1144,10 +1224,6 @@ phutil_register_library_map(array( 'ArcanistDifferentialDependencyGraph' => 'AbstractDirectedGraph', 'ArcanistDifferentialRevisionHash' => 'Phobject', 'ArcanistDifferentialRevisionStatus' => 'Phobject', - 'ArcanistDisplayRef' => array( - 'Phobject', - 'ArcanistTerminalStringInterface', - ), 'ArcanistDoubleQuoteXHPASTLinterRule' => 'ArcanistXHPASTLinterRule', 'ArcanistDoubleQuoteXHPASTLinterRuleTestCase' => 'ArcanistXHPASTLinterRuleTestCase', 'ArcanistDownloadWorkflow' => 'ArcanistArcWorkflow', @@ -1170,14 +1246,9 @@ phutil_register_library_map(array( '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', @@ -1196,10 +1267,17 @@ phutil_register_library_map(array( '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', @@ -1208,6 +1286,10 @@ phutil_register_library_map(array( 'ArcanistGoLintLinterTestCase' => 'ArcanistExternalLinterTestCase', 'ArcanistGoTestResultParser' => 'ArcanistTestResultParser', 'ArcanistGoTestResultParserTestCase' => 'PhutilTestCase', + 'ArcanistGridCell' => 'Phobject', + 'ArcanistGridColumn' => 'Phobject', + 'ArcanistGridRow' => 'Phobject', + 'ArcanistGridView' => 'Phobject', 'ArcanistHLintLinter' => 'ArcanistExternalLinter', 'ArcanistHLintLinterTestCase' => 'ArcanistExternalLinterTestCase', 'ArcanistHardpoint' => 'Phobject', @@ -1267,8 +1349,12 @@ phutil_register_library_map(array( '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', @@ -1289,19 +1375,30 @@ phutil_register_library_map(array( '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', @@ -1309,6 +1406,7 @@ phutil_register_library_map(array( 'ArcanistMissingLinterException' => 'Exception', 'ArcanistModifierOrderingXHPASTLinterRule' => 'ArcanistXHPASTLinterRule', 'ArcanistModifierOrderingXHPASTLinterRuleTestCase' => 'ArcanistXHPASTLinterRuleTestCase', + 'ArcanistMultiSourceConfigOption' => 'ArcanistConfigOption', 'ArcanistNamespaceFirstStatementXHPASTLinterRule' => 'ArcanistXHPASTLinterRule', 'ArcanistNamespaceFirstStatementXHPASTLinterRuleTestCase' => 'ArcanistXHPASTLinterRuleTestCase', 'ArcanistNamingConventionsXHPASTLinterRule' => 'ArcanistXHPASTLinterRule', @@ -1348,10 +1446,7 @@ phutil_register_library_map(array( 'ArcanistParenthesesSpacingXHPASTLinterRuleTestCase' => 'ArcanistXHPASTLinterRuleTestCase', 'ArcanistParseStrUseXHPASTLinterRule' => 'ArcanistXHPASTLinterRule', 'ArcanistParseStrUseXHPASTLinterRuleTestCase' => 'ArcanistXHPASTLinterRuleTestCase', - 'ArcanistPasteRef' => array( - 'ArcanistRef', - 'ArcanistDisplayRefInterface', - ), + 'ArcanistPasteRef' => 'ArcanistRef', 'ArcanistPasteSymbolRef' => 'ArcanistSimpleSymbolRef', 'ArcanistPasteWorkflow' => 'ArcanistArcWorkflow', 'ArcanistPatchWorkflow' => 'ArcanistWorkflow', @@ -1367,6 +1462,8 @@ phutil_register_library_map(array( 'ArcanistPlusOperatorOnStringsXHPASTLinterRuleTestCase' => 'ArcanistXHPASTLinterRuleTestCase', 'ArcanistProjectConfigurationSource' => 'ArcanistWorkingCopyConfigurationSource', 'ArcanistPrompt' => 'Phobject', + 'ArcanistPromptResponse' => 'Phobject', + 'ArcanistPromptsConfigOption' => 'ArcanistMultiSourceConfigOption', 'ArcanistPromptsWorkflow' => 'ArcanistWorkflow', 'ArcanistPublicPropertyXHPASTLinterRule' => 'ArcanistXHPASTLinterRule', 'ArcanistPublicPropertyXHPASTLinterRuleTestCase' => 'ArcanistXHPASTLinterRuleTestCase', @@ -1380,21 +1477,34 @@ phutil_register_library_map(array( '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', @@ -1403,7 +1513,6 @@ phutil_register_library_map(array( 'ArcanistRubyLinterTestCase' => 'ArcanistExternalLinterTestCase', 'ArcanistRuntimeConfigurationSource' => 'ArcanistDictionaryConfigurationSource', 'ArcanistRuntimeHardpointQuery' => 'ArcanistHardpointQuery', - 'ArcanistScalarConfigOption' => 'ArcanistConfigOption', 'ArcanistScalarHardpoint' => 'ArcanistHardpoint', 'ArcanistScriptAndRegexLinter' => 'ArcanistLinter', 'ArcanistSelfClassReferenceXHPASTLinterRule' => 'ArcanistXHPASTLinterRule', @@ -1416,17 +1525,20 @@ phutil_register_library_map(array( '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', @@ -1434,10 +1546,7 @@ phutil_register_library_map(array( 'ArcanistSymbolRef' => 'ArcanistRef', 'ArcanistSyntaxErrorXHPASTLinterRule' => 'ArcanistXHPASTLinterRule', 'ArcanistSystemConfigurationSource' => 'ArcanistFilesystemConfigurationSource', - 'ArcanistTaskRef' => array( - 'ArcanistRef', - 'ArcanistDisplayRefInterface', - ), + 'ArcanistTaskRef' => 'ArcanistRef', 'ArcanistTaskSymbolRef' => 'ArcanistSimpleSymbolRef', 'ArcanistTasksWorkflow' => 'ArcanistWorkflow', 'ArcanistTautologicalExpressionXHPASTLinterRule' => 'ArcanistXHPASTLinterRule', @@ -1487,10 +1596,7 @@ phutil_register_library_map(array( 'ArcanistUselessOverridingMethodXHPASTLinterRuleTestCase' => 'ArcanistXHPASTLinterRuleTestCase', 'ArcanistUserAbortException' => 'ArcanistUsageException', 'ArcanistUserConfigurationSource' => 'ArcanistFilesystemConfigurationSource', - 'ArcanistUserRef' => array( - 'ArcanistRef', - 'ArcanistDisplayRefInterface', - ), + 'ArcanistUserRef' => 'ArcanistRef', 'ArcanistUserSymbolHardpointQuery' => 'ArcanistRuntimeHardpointQuery', 'ArcanistUserSymbolRef' => 'ArcanistSymbolRef', 'ArcanistUserSymbolRefInspector' => 'ArcanistRefInspector', @@ -1503,12 +1609,15 @@ phutil_register_library_map(array( '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', 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 @@ -39,28 +39,17 @@ final class ArcanistConduitEngine 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() { 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 @@ -5,7 +5,6 @@ final class ConduitFuture extends FutureProxy { private $client; private $engine; private $conduitMethod; - private $profilerCallID; public function setClient(ConduitClient $client, $method) { $this->client = $client; @@ -13,29 +12,19 @@ final class ConduitFuture extends FutureProxy { return $this; } - public function isReady() { - if ($this->profilerCallID === null) { - $profiler = PhutilServiceProfiler::getInstance(); + protected function getServiceProfilerStartParameters() { + return array( + 'type' => 'conduit', + 'method' => $this->conduitMethod, + 'size' => $this->getProxiedFuture()->getHTTPRequestByteLength(), + ); + } - $this->profilerCallID = $profiler->beginServiceCall( - 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; 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 @@ -104,8 +104,7 @@ final class ConduitSearchFuture $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; } 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 @@ -35,4 +35,11 @@ abstract class FutureAgent 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 @@ -6,6 +6,7 @@ final class ArcanistArcConfigurationEngineExtension const EXTENSIONKEY = 'arc'; const KEY_ALIASES = 'aliases'; + const KEY_PROMPTS = 'prompts'; public function newConfigurationOptions() { // TOOLSETS: Restore "load", and maybe this other stuff. @@ -21,46 +22,6 @@ final class ArcanistArcConfigurationEngineExtension '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( @@ -111,9 +72,9 @@ final class ArcanistArcConfigurationEngineExtension ->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', @@ -145,6 +106,58 @@ final class ArcanistArcConfigurationEngineExtension 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,7 +1,7 @@ '; 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); - } + 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))); + } - return $result_list; + foreach ($json_value as $idx => $item) { + $this->validateListItem($idx, $item); + } + + return $json_value; } - protected function didReadStorageValueList(array $list) { - assert_instances_of($list, 'ArcanistConfigurationSourceValue'); - return mpull($list, 'getValue'); + final public function getValueFromStorageValue($value) { + if (!is_array($value)) { + throw new Exception(pht('Expected a 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; } + 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/ArcanistMultiSourceConfigOption.php b/src/config/option/ArcanistMultiSourceConfigOption.php new file mode 100644 index 00000000..822b4ab3 --- /dev/null +++ b/src/config/option/ArcanistMultiSourceConfigOption.php @@ -0,0 +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/ArcanistPromptsConfigOption.php b/src/config/option/ArcanistPromptsConfigOption.php new file mode 100644 index 00000000..99a0935a --- /dev/null +++ b/src/config/option/ArcanistPromptsConfigOption.php @@ -0,0 +1,51 @@ +'; + } + + public function getValueFromStorageValue($value) { + if (!is_array($value)) { + throw new Exception(pht('Expected a list!')); + } + + if (!phutil_is_natural_list($value)) { + throw new Exception(pht('Expected a natural list!')); + } + + $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 "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,6 +1,6 @@ '; + } + + 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 @@ -4,6 +4,7 @@ abstract class ArcanistConfigurationSource extends Phobject { const SCOPE_USER = 'user'; + const SCOPE_WORKING_COPY = 'working-copy'; abstract public function getSourceDisplayName(); abstract public function getAllKeys(); 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 @@ -7,4 +7,12 @@ final class ArcanistLocalConfigurationSource return pht('Local Config File'); } + public function isWritableConfigurationSource() { + return true; + } + + public function getConfigurationSourceScope() { + return ArcanistConfigurationSource::SCOPE_WORKING_COPY; + } + } diff --git a/src/configuration/ArcanistConfiguration.php b/src/configuration/ArcanistConfiguration.php index d2e8ae5c..9662feec 100644 --- a/src/configuration/ArcanistConfiguration.php +++ b/src/configuration/ArcanistConfiguration.php @@ -26,9 +26,6 @@ class ArcanistConfiguration extends Phobject { // Special-case "arc --help" to behave like "arc help" instead of telling // you to type "arc help" without being helpful. $command = 'help'; - } else if ($command == '--version') { - // Special-case "arc --version" to behave like "arc version". - $command = 'version'; } $workflow = idx($this->buildAllWorkflows(), $command); 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 @@ -65,13 +65,6 @@ final class ArcanistSettings extends Phobject { '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( diff --git a/src/console/grid/ArcanistGridCell.php b/src/console/grid/ArcanistGridCell.php new file mode 100644 index 00000000..b18560af --- /dev/null +++ b/src/console/grid/ArcanistGridCell.php @@ -0,0 +1,56 @@ +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 @@ -57,9 +57,9 @@ final class PhutilConsoleTable extends PhutilConsoleView { /* -( 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; @@ -85,6 +85,16 @@ final class PhutilConsoleTable extends PhutilConsoleView { return $this; } + public function drawRows(array $rows) { + $this->data = array(); + $this->widths = array(); + + foreach ($rows as $row) { + $this->addRow($row); + } + + return $this->draw(); + } /* -( Drawing )------------------------------------------------------------ */ 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 @@ -63,7 +63,7 @@ abstract class Future extends Phobject { $this->hasStarted = true; $this->startServiceProfiler(); - $this->isReady(); + $this->updateFuture(); } final public function updateFuture() { @@ -115,6 +115,10 @@ abstract class Future extends Phobject { $params = $this->getServiceProfilerStartParameters(); + if ($params === null) { + return; + } + $profiler = PhutilServiceProfiler::getInstance(); $call_id = $profiler->beginServiceCall($params); 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 @@ -27,27 +27,29 @@ abstract class FutureProxy extends Future { } public function isReady() { - if ($this->hasResult()) { + if ($this->hasResult() || $this->hasException()) { return true; } $proxied = $this->getProxiedFuture(); + $proxied->updateFuture(); - $is_ready = $proxied->isReady(); + 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); + } - if ($proxied->hasResult()) { - $result = $proxied->getResult(); - $result = $this->didReceiveResult($result); $this->setResult($result); + + return true; } - return $is_ready; - } - - public function resolve() { - $this->getProxiedFuture()->resolve(); - $this->isReady(); - return $this->getResult(); + return false; } public function getReadSockets() { @@ -73,4 +75,8 @@ abstract class FutureProxy extends Future { 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 @@ -20,6 +20,12 @@ */ final class PhutilExecPassthru extends PhutilExecutableFuture { + private $stdinData; + + public function write($data) { + $this->stdinData = $data; + return $this; + } /* -( Executing Passthru Commands )---------------------------------------- */ @@ -34,7 +40,15 @@ final class PhutilExecPassthru extends PhutilExecutableFuture { 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(); @@ -81,6 +95,11 @@ final class PhutilExecPassthru extends PhutilExecutableFuture { $errors)); } } else { + if ($is_write) { + fwrite($pipes[0], $this->stdinData); + fclose($pipes[0]); + } + $err = proc_close($proc); } 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 @@ -192,7 +192,8 @@ final class ArcanistHardpointEngine $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); } 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 @@ -5,6 +5,12 @@ abstract class ArcanistHardpointObject private $hardpointList; + public function __clone() { + if ($this->hardpointList) { + $this->hardpointList = clone $this->hardpointList; + } + } + final public function getHardpoint($hardpoint) { return $this->getHardpointList()->getHardpoint( $this, 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 @@ -3,6 +3,17 @@ abstract class ArcanistRefInspector extends Phobject { + private $workflow; + + final public function setWorkflow(ArcanistWorkflow $workflow) { + $this->workflow = $workflow; + return $this; + } + + final public function getWorkflow() { + return $this->workflow; + } + abstract public function getInspectFunctionName(); abstract public function newInspectRef(array $argv); 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 @@ -115,6 +115,21 @@ final class PhutilArgumentSpellingCorrector extends Phobject { $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) { 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 @@ -2,6 +2,17 @@ final class PhutilHelpArgumentWorkflow extends PhutilArgumentWorkflow { + private $workflow; + + public function setWorkflow($workflow) { + $this->workflow = $workflow; + return $this; + } + + public function getWorkflow() { + return $this->workflow; + } + protected function didConstruct() { $this->setName('help'); $this->setExamples(<<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,12 +1,16 @@ ref = $ref; @@ -17,6 +21,24 @@ final class ArcanistDisplayRef 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; @@ -26,16 +48,29 @@ final class ArcanistDisplayRef 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; @@ -43,10 +78,18 @@ final class ArcanistDisplayRef $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. @@ -69,20 +112,34 @@ final class ArcanistDisplayRef $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 @@ -3,6 +3,7 @@ final class ArcanistRepositoryRef extends ArcanistRef { + private $parameters = array(); private $phid; private $browseURI; @@ -24,6 +25,37 @@ final class ArcanistRepositoryRef 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, @@ -67,9 +99,48 @@ final class ArcanistRepositoryRef } 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/buildplan/ArcanistBuildPlanRef.php b/src/ref/buildplan/ArcanistBuildPlanRef.php new file mode 100644 index 00000000..ea0ad609 --- /dev/null +++ b/src/ref/buildplan/ArcanistBuildPlanRef.php @@ -0,0 +1,43 @@ +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 getBehavior($behavior_key, $default = null) { + return idxv( + $this->parameters, + array('fields', 'behaviors', $behavior_key, 'value'), + $default); + } + + 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,9 +1,7 @@ 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,9 +1,7 @@ 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,11 +1,12 @@ newHardpoint(self::HARDPOINT_COMMITMESSAGE), + $this->newHardpoint(self::HARDPOINT_AUTHORREF), + $this->newHardpoint(self::HARDPOINT_BUILDABLEREF), + $this->newTemplateHardpoint( + self::HARDPOINT_PARENTREVISIONREFS, + $object_list), ); } @@ -42,6 +49,32 @@ final class ArcanistRevisionRef 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'), @@ -49,7 +82,10 @@ final class ArcanistRevisionRef 'status' => array( 'name' => $status_name, 'closed' => $is_closed, + 'value' => $status_value, + 'color.ansi' => $ansi_color, ), + 'dateCreated' => $date_created, ); return self::newFromConduit($dict); @@ -59,10 +95,54 @@ final class ArcanistRevisionRef 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')); } @@ -93,6 +173,10 @@ final class ArcanistRevisionRef 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')); } @@ -114,12 +198,22 @@ final class ArcanistRevisionRef return $this->getHardpoint(self::HARDPOINT_COMMITMESSAGE); } - public function getDisplayRefObjectName() { - return $this->getMonogram(); + public function getAuthorRef() { + return $this->getHardpoint(self::HARDPOINT_AUTHORREF); } - public function getDisplayRefTitle() { - return $this->getName(); + public function getParentRevisionRefs() { + return $this->getHardpoint(self::HARDPOINT_PARENTREVISIONREFS); + } + + public function getBuildableRef() { + return $this->getHardpoint(self::HARDPOINT_BUILDABLEREF); + } + + 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 @@ -22,6 +22,10 @@ abstract class ArcanistSimpleSymbolRef $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); @@ -46,7 +50,10 @@ abstract class ArcanistSimpleSymbolRef $symbol)); } - abstract protected function getSimpleSymbolPrefixPattern(); + protected function getSimpleSymbolPrefixPattern() { + return null; + } + abstract protected function getSimpleSymbolPHIDType(); abstract public function getSimpleSymbolConduitSearchMethodName(); abstract public function getSimpleSymbolInspectFunctionName(); 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,9 +1,7 @@ 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,9 +1,7 @@ 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 @@ -20,12 +20,11 @@ final class ArcanistGitAPI extends ArcanistRepositoryAPI { protected function buildLocalFuture(array $argv) { $argv[0] = 'git '.$argv[0]; - $future = newv('ExecFuture', $argv); - $future->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; @@ -43,10 +42,10 @@ final class ArcanistGitAPI extends ArcanistRepositoryAPI { $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'; } @@ -598,16 +597,16 @@ final class ArcanistGitAPI extends ArcanistRepositoryAPI { 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); } @@ -1057,7 +1056,7 @@ final class ArcanistGitAPI extends ArcanistRepositoryAPI { * * @return list> Dictionary of branch information. */ - public function getAllBranches() { + private function getAllBranches() { $field_list = array( '%(refname)', '%(objectname)', @@ -1103,27 +1102,6 @@ final class ArcanistGitAPI extends ArcanistRepositoryAPI { 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(); @@ -1564,6 +1542,11 @@ final class ArcanistGitAPI extends ArcanistRepositoryAPI { 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); } @@ -1739,4 +1722,102 @@ final class ArcanistGitAPI extends ArcanistRepositoryAPI { 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 @@ -9,8 +9,8 @@ final class ArcanistMercurialAPI extends ArcanistRepositoryAPI { private $localCommitInfo; private $rawDiffCache = array(); - private $supportsRebase; - private $supportsPhases; + private $featureResults = array(); + private $featureFutures = array(); protected function buildLocalFuture(array $argv) { $env = $this->getMercurialEnvironmentVariables(); @@ -24,18 +24,16 @@ final class ArcanistMercurialAPI extends ArcanistRepositoryAPI { 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() { @@ -51,42 +49,11 @@ final class ArcanistMercurialAPI extends ArcanistRepositoryAPI { } 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; } @@ -144,19 +111,10 @@ final class ArcanistMercurialAPI extends ArcanistRepositoryAPI { 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); @@ -507,24 +465,6 @@ final class ArcanistMercurialAPI extends ArcanistRepositoryAPI { } } - 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; } @@ -533,43 +473,6 @@ final class ArcanistMercurialAPI extends ArcanistRepositoryAPI { 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(); @@ -955,10 +858,6 @@ final class ArcanistMercurialAPI extends ArcanistRepositoryAPI { } - public function isHgSubversionRepo() { - return file_exists($this->getPath('.hg/svn/rev_map')); - } - public function getSubversionInfo() { $info = array(); $base_path = null; @@ -990,96 +889,21 @@ final class ArcanistMercurialAPI extends ArcanistRepositoryAPI { } public function getActiveBookmark() { - $bookmarks = $this->getBookmarks(); - foreach ($bookmarks as $bookmark) { - if ($bookmark['is_active']) { - return $bookmark['name']; - } + $bookmark = $this->newMarkerRefQuery() + ->withMarkerTypes(ArcanistMarkerRef::TYPE_BOOKMARK) + ->withIsActive(true) + ->executeOne(); + + if (!$bookmark) { + return null; } - 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); - - $is_active = false; - if ('*' === $name[0]) { - $is_active = true; - $name = substr($name, 2); - } - - $bookmarks[] = array( - 'is_active' => $is_active, - 'name' => $name, - 'revision' => $revision, - ); - } - } - - 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); @@ -1108,4 +932,127 @@ final class ArcanistMercurialAPI extends ArcanistRepositoryAPI { 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 @@ -42,6 +42,7 @@ abstract class ArcanistRepositoryAPI extends Phobject { private $runtime; private $currentWorkingCopyStateRef = false; private $currentCommitRef = false; + private $graph; abstract public function getSourceControlSystemName(); @@ -369,15 +370,6 @@ abstract class ArcanistRepositoryAPI extends Phobject { 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); } @@ -675,6 +667,20 @@ abstract class ArcanistRepositoryAPI extends Phobject { ->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; @@ -731,7 +737,101 @@ abstract class ArcanistRepositoryAPI extends Phobject { 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 @@ -41,6 +41,8 @@ final class ArcanistRuntime { $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; @@ -884,7 +886,6 @@ final class ArcanistRuntime { $legacy[] = new ArcanistGetConfigWorkflow(); $legacy[] = new ArcanistSetConfigWorkflow(); $legacy[] = new ArcanistInstallCertificateWorkflow(); - $legacy[] = new ArcanistLandWorkflow(); $legacy[] = new ArcanistLintersWorkflow(); $legacy[] = new ArcanistLintWorkflow(); $legacy[] = new ArcanistListWorkflow(); 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 @@ -16,10 +16,6 @@ final class ArcanistArcToolset extends ArcanistToolset { '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 @@ -70,8 +70,10 @@ final class ArcanistPrompt $this->getKey())); } - $options = '[y/N]'; - $default = 'N'; + $options = '[y/N/?]'; + $default = 'n'; + + $saved_response = $this->getSavedResponse(); try { phutil_console_require_tty(); @@ -84,51 +86,103 @@ final class ArcanistPrompt 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; - while (true) { - $read = array($stdin); - $write = array(); - $except = array(); + $response = $saved_response; + $saved_response = null; + } else { + echo tsprintf( + '** %s ** %s %s ', + '>>>', + $query, + $options); - $ok = stream_select($read, $write, $except, 1); - if ($ok === false) { - throw new Exception(pht('stream_select() failed!')); + $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. + + // 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. + } + + $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') { @@ -140,12 +194,127 @@ final class ArcanistPrompt $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 @@ -8,6 +8,9 @@ final class ArcanistWorkflowArgument private $wildcard; private $parameter; private $isPathArgument; + private $shortFlag; + private $repeatable; + private $relatedConfig = array(); public function setKey($key) { $this->key = $key; @@ -27,6 +30,33 @@ final class ArcanistWorkflowArgument 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(), @@ -43,13 +73,41 @@ final class ArcanistWorkflowArgument $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; } 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 @@ -5,6 +5,8 @@ final class ArcanistCommand private $logEngine; private $executableFuture; + private $resolveOnError = false; + private $displayCommand; public function setExecutableFuture(PhutilExecutableFuture $future) { $this->executableFuture = $future; @@ -24,10 +26,36 @@ final class ArcanistCommand 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(); @@ -41,7 +69,7 @@ final class ArcanistCommand $log->writeNewline(); - if ($err) { + if ($err && !$this->getResolveOnError()) { $log->writeError( pht('ERROR'), pht( @@ -55,5 +83,7 @@ final class ArcanistCommand '', ''); } + + 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 @@ -77,8 +77,7 @@ abstract class ArcanistRuntimeHardpointQuery $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; } 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 @@ -8,7 +8,8 @@ final class ArcanistHelpWorkflow } public function newPhutilWorkflow() { - return new PhutilHelpArgumentWorkflow(); + return id(new PhutilHelpArgumentWorkflow()) + ->setWorkflow($this); } public function supportsToolset(ArcanistToolset $toolset) { 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,6 +1,7 @@ + $ 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 ); @@ -65,16 +86,51 @@ EOTEXT 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 @@ -585,12 +585,18 @@ EOTEXT $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. 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 @@ -118,11 +118,7 @@ final class ArcanistFileUploader extends Phobject { $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); @@ -217,11 +213,12 @@ final class ArcanistFileUploader extends Phobject { 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) { @@ -258,7 +255,7 @@ final class ArcanistFileUploader extends Phobject { foreach ($remaining as $chunk) { $data = $file->readBytes($chunk['byteStart'], $chunk['byteEnd']); - $conduit->resolveCall( + $future = $conduit->newFuture( 'file.uploadchunk', array( 'filePHID' => $file_phid, @@ -266,6 +263,7 @@ final class ArcanistFileUploader extends Phobject { 'dataEncoding' => 'base64', 'data' => base64_encode($data), )); + $future->resolve(); $progress->update(1); } @@ -282,11 +280,13 @@ final class ArcanistFileUploader extends Phobject { $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(); } 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 @@ -966,4 +966,38 @@ final class PhutilUtilsTestCase extends PhutilTestCase { } } + 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 @@ -433,6 +433,14 @@ function msort(array $list, $method) { * @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; @@ -455,7 +463,7 @@ function msortv(array $list, $method) { $surrogate[$key] = (string)$value; } - asort($surrogate, SORT_STRING); + asort($surrogate, $flags); $result = array(); foreach ($surrogate as $key => $value) { @@ -1966,3 +1974,32 @@ function phutil_glue(array $list, $glue) { 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 @@ -108,7 +108,7 @@ EOTEXT 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); @@ -193,7 +193,7 @@ EOTEXT "%!\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( @@ -233,7 +233,7 @@ EOTEXT '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?', 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 @@ -53,8 +53,7 @@ EOTEXT } $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; 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 @@ -13,11 +13,9 @@ final class ArcanistDiffWorkflow extends ArcanistWorkflow { private $console; private $hasWarnedExternals = false; private $unresolvedLint; - private $excuses = array('lint' => null, 'unit' => null); private $testResults; private $diffID; private $revisionID; - private $haveUncommittedChanges = false; private $diffPropertyFutures = array(); private $commitMessageFromRevision; private $hitAutotargets; @@ -78,10 +76,6 @@ EOTEXT return true; } - if ($this->getArgument('use-commit-message')) { - return true; - } - return false; } @@ -106,19 +100,6 @@ EOTEXT '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', @@ -137,11 +118,8 @@ EOTEXT '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.', @@ -163,11 +141,8 @@ EOTEXT '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( @@ -209,8 +184,6 @@ EOTEXT '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'), ), @@ -227,40 +200,6 @@ EOTEXT '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 '. @@ -327,7 +266,6 @@ EOTEXT 'git', ), 'conflicts' => array( - 'use-commit-message' => true, 'update' => true, 'only' => true, 'raw' => true, @@ -357,9 +295,6 @@ EOTEXT '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.'), @@ -399,10 +334,6 @@ EOTEXT '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'), - ), ), ); @@ -431,8 +362,6 @@ EOTEXT $revision = $this->buildRevisionFromCommitMessage($commit_message); } - $server = $this->console->getServer(); - $server->setHandler(array($this, 'handleServerMessage')); $data = $this->runLintUnit(); $lint_result = $data['lintResult']; @@ -440,20 +369,6 @@ EOTEXT $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( @@ -669,9 +584,6 @@ EOTEXT } $repository_api = $this->getRepositoryAPI(); - if ($this->getArgument('less-context')) { - $repository_api->setDiffLinesOfContext(3); - } $repository_api->setBaseCommitArgumentRules( $this->getArgument('base', '')); @@ -818,10 +730,6 @@ EOTEXT return false; } - if ($this->getArgument('use-commit-message')) { - return false; - } - if ($this->isRawDiffSource()) { return true; } @@ -1001,11 +909,6 @@ EOTEXT "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.' '. @@ -1198,10 +1101,6 @@ EOTEXT return false; } - if ($this->haveUncommittedChanges) { - return false; - } - if ($this->getArgument('no-amend')) { return false; } @@ -1269,34 +1168,22 @@ EOTEXT 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; } @@ -1348,32 +1235,26 @@ EOTEXT 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; } @@ -1398,66 +1279,6 @@ EOTEXT 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 )----------------------------------------- */ @@ -1473,13 +1294,8 @@ EOTEXT $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(); } @@ -1534,18 +1350,6 @@ EOTEXT } - /** - * @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 */ @@ -2480,12 +2284,6 @@ EOTEXT * @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( @@ -2504,11 +2302,6 @@ EOTEXT * @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)); 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 @@ -setWorkflow($this); + } + if (!$objects) { echo tsprintf( "%s\n\n", 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 @@ -3,1608 +3,345 @@ /** * Lands a branch by rebasing, merging and amending it. */ -final class ArcanistLandWorkflow extends ArcanistWorkflow { - - private $isGit; - private $isGitSvn; - private $isHg; - private $isHgSvn; - - private $oldBranch; - private $branch; - private $onto; - private $ontoRemoteBranch; - private $remote; - private $useSquash; - private $keepBranch; - private $branchType; - private $ontoType; - private $preview; - - private $revision; - private $messageFile; - - const REFTYPE_BRANCH = 'branch'; - const REFTYPE_BOOKMARK = 'bookmark'; - - public function getRevisionDict() { - return $this->revision; - } +final class ArcanistLandWorkflow + extends ArcanistArcWorkflow { public function getWorkflowName() { return 'land'; } - public function getCommandSynopses() { - return phutil_console_format(<<newWorkflowInformation() + ->setSynopsis(pht('Publish reviewed changes.')) + ->addExample(pht('**land** [__options__] -- [__ref__ ...]')) + ->setHelp($help); } - public function getCommandHelp() { - 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', + $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( + '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( + 'Land a specific revision, rather than determining revisions '. + 'automatically from the commits that are landing.')), + $this->newWorkflowArgument('preview') + ->setHelp( + pht( + '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), ); } - public function run() { - $this->readArguments(); - - $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; - } - - $this->oldBranch = $this->getBranchOrBookmark(); - } - - 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( - 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( + protected function newPrompts() { + return array( + $this->newPrompt('arc.land.large-working-set') + ->setDescription( 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", + 'Confirms landing more than %s commit(s) in a single operation.', + new PhutilNumber($this->getLargeWorkingSetLimit()))), + $this->newPrompt('arc.land.confirm') + ->setDescription( 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.')); - } - } + '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.')), + ); } - 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')); - } + public function getLargeWorkingSetLimit() { + return 50; } - private function push() { - $repository_api = $this->getRepositoryAPI(); + public function runWorkflow() { + $working_copy = $this->getWorkingCopy(); + $repository_api = $working_copy->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'; - } - - /** - * 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)); - } - - - /** - * 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(); - } - } - - 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, - ), - )); - - $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?'); + '"arc land" must be run in a Git or Mercurial working copy.')); } - $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(); + $is_incremental = $this->getArgument('incremental'); + $source_refs = $this->getArgument('ref'); - echo tsprintf( - " ** %s ** %s: %s\n", - $status_name, - $object_name, - $build_name); - } + $onto_remote_arg = $this->getArgument('onto-remote'); + $onto_args = $this->getArgument('onto'); - echo tsprintf( - "\n%s\n\n", - pht('You can review build details here:')); + $into_remote = $this->getArgument('into-remote'); + $into_empty = $this->getArgument('into-empty'); + $into_local = $this->getArgument('into-local'); + $into = $this->getArgument('into'); - foreach ($buildables as $buildable) { - $buildable_uri = id(new PhutilURI($this->getConduitURI())) - ->setPath(sprintf('/B%d', $buildable['id'])); + $is_preview = $this->getArgument('preview'); + $should_hold = $this->getArgument('hold'); + $should_keep = $this->getArgument('keep-branches'); - echo tsprintf( - " **%s**: __%s__\n", - pht('Buildable %d', $buildable['id']), - $buildable_uri); - } + $revision = $this->getArgument('revision'); + $strategy = $this->getArgument('strategy'); + $pick = $this->getArgument('pick'); - if (!phutil_console_confirm($prompt)) { - throw new ArcanistUserAbortException(); - } + $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(); } - 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; - } - - } 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 @@ -141,8 +141,7 @@ EOTEXT ); $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')); @@ -159,7 +158,7 @@ EOTEXT echo tsprintf( '%s', - $paste_ref->newDisplayRef() + $paste_ref->newRefView() ->setURI($uri)); if ($is_browse) { 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 @@ -119,7 +119,7 @@ EOTEXT $uri = $this->getAbsoluteURI($uri); echo tsprintf( '%s', - $ref->newDisplayRef() + $ref->newRefView() ->setURI($uri)); } } @@ -141,83 +141,4 @@ EOTEXT $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 @@ -244,7 +244,7 @@ abstract class ArcanistWorkflow extends Phobject { return $err; } - final protected function getLogEngine() { + final public function getLogEngine() { return $this->getRuntime()->getLogEngine(); } @@ -1808,22 +1808,6 @@ abstract class ArcanistWorkflow extends Phobject { 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, @@ -1964,7 +1948,8 @@ abstract class ArcanistWorkflow extends Phobject { 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') { @@ -2036,6 +2021,8 @@ abstract class ArcanistWorkflow extends Phobject { '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( @@ -2357,6 +2344,17 @@ abstract class ArcanistWorkflow extends Phobject { $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(); @@ -2380,7 +2378,7 @@ abstract class ArcanistWorkflow extends Phobject { return $this->promptMap; } - protected function getPrompt($key) { + final public function getPrompt($key) { $map = $this->getPromptMap(); $prompt = idx($map, $key); @@ -2421,7 +2419,7 @@ abstract class ArcanistWorkflow extends Phobject { 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. @@ -2440,4 +2438,30 @@ abstract class ArcanistWorkflow extends Phobject { 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 @@ -22,7 +22,14 @@ function xsprintf_mercurial($userdata, &$pattern, &$pos, &$value, &$length) { switch ($type) { case 's': - $value = "'".addcslashes($value, "'\\")."'"; + // If this is symbol only has "safe" alphanumeric latin characters, + // and is at least one character long, we can let it through without + // escaping it. This tends to produce more readable commands. + if (preg_match('(^[a-zA-Z0-9]+\z)', $value)) { + $value = $value; + } else { + $value = "'".addcslashes($value, "'\\")."'"; + } break; case 'R': $type = 's'; diff --git a/src/xsprintf/tsprintf.php b/src/xsprintf/tsprintf.php index aea1e7f4..a2100bfd 100644 --- a/src/xsprintf/tsprintf.php +++ b/src/xsprintf/tsprintf.php @@ -48,6 +48,17 @@ function xsprintf_terminal($userdata, &$pattern, &$pos, &$value, &$length) { $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; 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