diff --git a/src/__phutil_library_map__.php b/src/__phutil_library_map__.php index 215a7a85..8676fb1f 100644 --- a/src/__phutil_library_map__.php +++ b/src/__phutil_library_map__.php @@ -1,373 +1,372 @@ 2, 'class' => array( 'ArcanistAliasWorkflow' => 'workflow/ArcanistAliasWorkflow.php', 'ArcanistAmendWorkflow' => 'workflow/ArcanistAmendWorkflow.php', 'ArcanistAnoidWorkflow' => 'workflow/ArcanistAnoidWorkflow.php', 'ArcanistArcanistLinterTestCase' => 'lint/linter/__tests__/ArcanistArcanistLinterTestCase.php', 'ArcanistBackoutWorkflow' => 'workflow/ArcanistBackoutWorkflow.php', 'ArcanistBaseCommitParser' => 'parser/ArcanistBaseCommitParser.php', 'ArcanistBaseCommitParserTestCase' => 'parser/__tests__/ArcanistBaseCommitParserTestCase.php', 'ArcanistBaseTestResultParser' => 'unit/engine/ArcanistBaseTestResultParser.php', 'ArcanistBaseUnitTestEngine' => 'unit/engine/ArcanistBaseUnitTestEngine.php', 'ArcanistBaseWorkflow' => 'workflow/ArcanistBaseWorkflow.php', 'ArcanistBaseXHPASTLinter' => 'lint/linter/ArcanistBaseXHPASTLinter.php', 'ArcanistBookmarkWorkflow' => 'workflow/ArcanistBookmarkWorkflow.php', 'ArcanistBranchWorkflow' => 'workflow/ArcanistBranchWorkflow.php', 'ArcanistBritishTestCase' => 'configuration/__tests__/ArcanistBritishTestCase.php', 'ArcanistBrowseWorkflow' => 'workflow/ArcanistBrowseWorkflow.php', 'ArcanistBundle' => 'parser/ArcanistBundle.php', 'ArcanistBundleTestCase' => 'parser/__tests__/ArcanistBundleTestCase.php', 'ArcanistCSSLintLinter' => 'lint/linter/ArcanistCSSLintLinter.php', 'ArcanistCSSLintLinterTestCase' => 'lint/linter/__tests__/ArcanistCSSLintLinterTestCase.php', 'ArcanistCSharpLinter' => 'lint/linter/ArcanistCSharpLinter.php', 'ArcanistCallConduitWorkflow' => 'workflow/ArcanistCallConduitWorkflow.php', 'ArcanistCapabilityNotSupportedException' => 'workflow/exception/ArcanistCapabilityNotSupportedException.php', 'ArcanistChmodLinter' => 'lint/linter/ArcanistChmodLinter.php', 'ArcanistChooseInvalidRevisionException' => 'exception/ArcanistChooseInvalidRevisionException.php', 'ArcanistChooseNoRevisionsException' => 'exception/ArcanistChooseNoRevisionsException.php', 'ArcanistCloseRevisionWorkflow' => 'workflow/ArcanistCloseRevisionWorkflow.php', 'ArcanistCloseWorkflow' => 'workflow/ArcanistCloseWorkflow.php', 'ArcanistClosureLinter' => 'lint/linter/ArcanistClosureLinter.php', 'ArcanistClosureLinterTestCase' => 'lint/linter/__tests__/ArcanistClosureLinterTestCase.php', 'ArcanistCoffeeLintLinter' => 'lint/linter/ArcanistCoffeeLintLinter.php', 'ArcanistCoffeeLintLinterTestCase' => 'lint/linter/__tests__/ArcanistCoffeeLintLinterTestCase.php', 'ArcanistCommentRemover' => 'parser/ArcanistCommentRemover.php', 'ArcanistCommentRemoverTestCase' => 'parser/__tests__/ArcanistCommentRemoverTestCase.php', 'ArcanistCommitWorkflow' => 'workflow/ArcanistCommitWorkflow.php', 'ArcanistConduitLinter' => 'lint/linter/ArcanistConduitLinter.php', 'ArcanistConfiguration' => 'configuration/ArcanistConfiguration.php', 'ArcanistConfigurationDrivenLintEngine' => 'lint/engine/ArcanistConfigurationDrivenLintEngine.php', 'ArcanistConfigurationManager' => 'configuration/ArcanistConfigurationManager.php', 'ArcanistCoverWorkflow' => 'workflow/ArcanistCoverWorkflow.php', 'ArcanistCppcheckLinter' => 'lint/linter/ArcanistCppcheckLinter.php', 'ArcanistCppcheckLinterTestCase' => 'lint/linter/__tests__/ArcanistCppcheckLinterTestCase.php', 'ArcanistCpplintLinter' => 'lint/linter/ArcanistCpplintLinter.php', 'ArcanistCpplintLinterTestCase' => 'lint/linter/__tests__/ArcanistCpplintLinterTestCase.php', 'ArcanistDiffChange' => 'parser/diff/ArcanistDiffChange.php', 'ArcanistDiffChangeType' => 'parser/diff/ArcanistDiffChangeType.php', 'ArcanistDiffHunk' => 'parser/diff/ArcanistDiffHunk.php', 'ArcanistDiffParser' => 'parser/ArcanistDiffParser.php', 'ArcanistDiffParserTestCase' => 'parser/__tests__/ArcanistDiffParserTestCase.php', 'ArcanistDiffUtils' => 'difference/ArcanistDiffUtils.php', 'ArcanistDiffUtilsTestCase' => 'difference/__tests__/ArcanistDiffUtilsTestCase.php', 'ArcanistDiffWorkflow' => 'workflow/ArcanistDiffWorkflow.php', 'ArcanistDifferentialCommitMessage' => 'differential/ArcanistDifferentialCommitMessage.php', 'ArcanistDifferentialCommitMessageParserException' => 'differential/ArcanistDifferentialCommitMessageParserException.php', 'ArcanistDifferentialDependencyGraph' => 'differential/ArcanistDifferentialDependencyGraph.php', 'ArcanistDifferentialRevisionHash' => 'differential/constants/ArcanistDifferentialRevisionHash.php', 'ArcanistDifferentialRevisionStatus' => 'differential/constants/ArcanistDifferentialRevisionStatus.php', 'ArcanistDownloadWorkflow' => 'workflow/ArcanistDownloadWorkflow.php', 'ArcanistEventType' => 'events/constant/ArcanistEventType.php', 'ArcanistExportWorkflow' => 'workflow/ArcanistExportWorkflow.php', 'ArcanistExternalLinter' => 'lint/linter/ArcanistExternalLinter.php', 'ArcanistFeatureWorkflow' => 'workflow/ArcanistFeatureWorkflow.php', 'ArcanistFilenameLinter' => 'lint/linter/ArcanistFilenameLinter.php', 'ArcanistFlagWorkflow' => 'workflow/ArcanistFlagWorkflow.php', 'ArcanistFlake8Linter' => 'lint/linter/ArcanistFlake8Linter.php', 'ArcanistFlake8LinterTestCase' => 'lint/linter/__tests__/ArcanistFlake8LinterTestCase.php', 'ArcanistFutureLinter' => 'lint/linter/ArcanistFutureLinter.php', 'ArcanistGeneratedLinter' => 'lint/linter/ArcanistGeneratedLinter.php', 'ArcanistGetConfigWorkflow' => 'workflow/ArcanistGetConfigWorkflow.php', 'ArcanistGitAPI' => 'repository/api/ArcanistGitAPI.php', 'ArcanistGitHookPreReceiveWorkflow' => 'workflow/ArcanistGitHookPreReceiveWorkflow.php', 'ArcanistGoLintLinter' => 'lint/linter/ArcanistGoLintLinter.php', 'ArcanistGoLintLinterTestCase' => 'lint/linter/__tests__/ArcanistGoLintLinterTestCase.php', 'ArcanistHelpWorkflow' => 'workflow/ArcanistHelpWorkflow.php', 'ArcanistHgClientChannel' => 'hgdaemon/ArcanistHgClientChannel.php', 'ArcanistHgProxyClient' => 'hgdaemon/ArcanistHgProxyClient.php', 'ArcanistHgProxyServer' => 'hgdaemon/ArcanistHgProxyServer.php', 'ArcanistHgServerChannel' => 'hgdaemon/ArcanistHgServerChannel.php', 'ArcanistHookAPI' => 'repository/hookapi/ArcanistHookAPI.php', 'ArcanistInfrastructureTestCase' => '__tests__/ArcanistInfrastructureTestCase.php', 'ArcanistInlinesWorkflow' => 'workflow/ArcanistInlinesWorkflow.php', 'ArcanistInstallCertificateWorkflow' => 'workflow/ArcanistInstallCertificateWorkflow.php', 'ArcanistJSHintLinter' => 'lint/linter/ArcanistJSHintLinter.php', 'ArcanistJSHintLinterTestCase' => 'lint/linter/__tests__/ArcanistJSHintLinterTestCase.php', 'ArcanistJSONLintLinter' => 'lint/linter/ArcanistJSONLintLinter.php', 'ArcanistJSONLintLinterTestCase' => 'lint/linter/__tests__/ArcanistJSONLintLinterTestCase.php', 'ArcanistJSONLinter' => 'lint/linter/ArcanistJSONLinter.php', 'ArcanistJSONLinterTestCase' => 'lint/linter/__tests__/ArcanistJSONLinterTestCase.php', 'ArcanistJscsLinter' => 'lint/linter/ArcanistJscsLinter.php', 'ArcanistJscsLinterTestCase' => 'lint/linter/__tests__/ArcanistJscsLinterTestCase.php', 'ArcanistLandWorkflow' => 'workflow/ArcanistLandWorkflow.php', 'ArcanistLesscLinter' => 'lint/linter/ArcanistLesscLinter.php', 'ArcanistLesscLinterTestCase' => 'lint/linter/__tests__/ArcanistLesscLinterTestCase.php', 'ArcanistLiberateWorkflow' => 'workflow/ArcanistLiberateWorkflow.php', 'ArcanistLintCheckstyleXMLRenderer' => 'lint/renderer/ArcanistLintCheckstyleXMLRenderer.php', 'ArcanistLintConsoleRenderer' => 'lint/renderer/ArcanistLintConsoleRenderer.php', 'ArcanistLintEngine' => 'lint/engine/ArcanistLintEngine.php', 'ArcanistLintJSONRenderer' => 'lint/renderer/ArcanistLintJSONRenderer.php', 'ArcanistLintLikeCompilerRenderer' => 'lint/renderer/ArcanistLintLikeCompilerRenderer.php', 'ArcanistLintMessage' => 'lint/ArcanistLintMessage.php', 'ArcanistLintNoneRenderer' => 'lint/renderer/ArcanistLintNoneRenderer.php', 'ArcanistLintPatcher' => 'lint/ArcanistLintPatcher.php', 'ArcanistLintRenderer' => 'lint/renderer/ArcanistLintRenderer.php', 'ArcanistLintResult' => 'lint/ArcanistLintResult.php', 'ArcanistLintSeverity' => 'lint/ArcanistLintSeverity.php', 'ArcanistLintSummaryRenderer' => 'lint/renderer/ArcanistLintSummaryRenderer.php', 'ArcanistLintWorkflow' => 'workflow/ArcanistLintWorkflow.php', 'ArcanistLinter' => 'lint/linter/ArcanistLinter.php', 'ArcanistLinterTestCase' => 'lint/linter/__tests__/ArcanistLinterTestCase.php', 'ArcanistLintersWorkflow' => 'workflow/ArcanistLintersWorkflow.php', 'ArcanistListWorkflow' => 'workflow/ArcanistListWorkflow.php', 'ArcanistMercurialAPI' => 'repository/api/ArcanistMercurialAPI.php', 'ArcanistMercurialParser' => 'repository/parser/ArcanistMercurialParser.php', 'ArcanistMercurialParserTestCase' => 'repository/parser/__tests__/ArcanistMercurialParserTestCase.php', 'ArcanistMergeConflictLinter' => 'lint/linter/ArcanistMergeConflictLinter.php', 'ArcanistMergeConflictLinterTestCase' => 'lint/linter/__tests__/ArcanistMergeConflictLinterTestCase.php', 'ArcanistNoEffectException' => 'exception/usage/ArcanistNoEffectException.php', 'ArcanistNoEngineException' => 'exception/usage/ArcanistNoEngineException.php', 'ArcanistNoLintLinter' => 'lint/linter/ArcanistNoLintLinter.php', 'ArcanistNoLintTestCaseMisnamed' => 'lint/linter/__tests__/ArcanistNoLintTestCase.php', 'ArcanistPEP8Linter' => 'lint/linter/ArcanistPEP8Linter.php', 'ArcanistPEP8LinterTestCase' => 'lint/linter/__tests__/ArcanistPEP8LinterTestCase.php', 'ArcanistPHPCSLinterTestCase' => 'lint/linter/__tests__/ArcanistPHPCSLinterTestCase.php', 'ArcanistPasteWorkflow' => 'workflow/ArcanistPasteWorkflow.php', 'ArcanistPatchWorkflow' => 'workflow/ArcanistPatchWorkflow.php', 'ArcanistPhpcsLinter' => 'lint/linter/ArcanistPhpcsLinter.php', 'ArcanistPhutilLibraryLinter' => 'lint/linter/ArcanistPhutilLibraryLinter.php', 'ArcanistPhutilTestCase' => 'unit/engine/phutil/ArcanistPhutilTestCase.php', 'ArcanistPhutilTestCaseTestCase' => 'unit/engine/phutil/testcase/ArcanistPhutilTestCaseTestCase.php', 'ArcanistPhutilTestSkippedException' => 'unit/engine/phutil/testcase/ArcanistPhutilTestSkippedException.php', 'ArcanistPhutilTestTerminatedException' => 'unit/engine/phutil/testcase/ArcanistPhutilTestTerminatedException.php', 'ArcanistPhutilXHPASTLinter' => 'lint/linter/ArcanistPhutilXHPASTLinter.php', 'ArcanistPhutilXHPASTLinterTestCase' => 'lint/linter/__tests__/ArcanistPhutilXHPASTLinterTestCase.php', 'ArcanistPuppetLintLinter' => 'lint/linter/ArcanistPuppetLintLinter.php', 'ArcanistPuppetLintLinterTestCase' => 'lint/linter/__tests__/ArcanistPuppetLintLinterTestCase.php', 'ArcanistPyFlakesLinter' => 'lint/linter/ArcanistPyFlakesLinter.php', 'ArcanistPyFlakesLinterTestCase' => 'lint/linter/__tests__/ArcanistPyFlakesLinterTestCase.php', 'ArcanistPyLintLinter' => 'lint/linter/ArcanistPyLintLinter.php', 'ArcanistPyLintLinterTestCase' => 'lint/linter/__tests__/ArcanistPyLintLinterTestCase.php', 'ArcanistRepositoryAPI' => 'repository/api/ArcanistRepositoryAPI.php', 'ArcanistRepositoryAPIMiscTestCase' => 'repository/api/__tests__/ArcanistRepositoryAPIMiscTestCase.php', 'ArcanistRepositoryAPIStateTestCase' => 'repository/api/__tests__/ArcanistRepositoryAPIStateTestCase.php', 'ArcanistRevertWorkflow' => 'workflow/ArcanistRevertWorkflow.php', 'ArcanistRubyLinter' => 'lint/linter/ArcanistRubyLinter.php', 'ArcanistRubyLinterTestCase' => 'lint/linter/__tests__/ArcanistRubyLinterTestCase.php', 'ArcanistScriptAndRegexLinter' => 'lint/linter/ArcanistScriptAndRegexLinter.php', 'ArcanistSetConfigWorkflow' => 'workflow/ArcanistSetConfigWorkflow.php', 'ArcanistSettings' => 'configuration/ArcanistSettings.php', 'ArcanistShellCompleteWorkflow' => 'workflow/ArcanistShellCompleteWorkflow.php', 'ArcanistSingleLintEngine' => 'lint/engine/ArcanistSingleLintEngine.php', 'ArcanistSpellingLinter' => 'lint/linter/ArcanistSpellingLinter.php', 'ArcanistSpellingLinterTestCase' => 'lint/linter/__tests__/ArcanistSpellingLinterTestCase.php', 'ArcanistSubversionAPI' => 'repository/api/ArcanistSubversionAPI.php', 'ArcanistSubversionHookAPI' => 'repository/hookapi/ArcanistSubversionHookAPI.php', 'ArcanistSvnHookPreCommitWorkflow' => 'workflow/ArcanistSvnHookPreCommitWorkflow.php', 'ArcanistTasksWorkflow' => 'workflow/ArcanistTasksWorkflow.php', 'ArcanistTestCase' => 'infrastructure/testing/ArcanistTestCase.php', 'ArcanistTextLinter' => 'lint/linter/ArcanistTextLinter.php', 'ArcanistTextLinterTestCase' => 'lint/linter/__tests__/ArcanistTextLinterTestCase.php', 'ArcanistTodoWorkflow' => 'workflow/ArcanistTodoWorkflow.php', 'ArcanistUncommittedChangesException' => 'exception/usage/ArcanistUncommittedChangesException.php', 'ArcanistUnitConsoleRenderer' => 'unit/renderer/ArcanistUnitConsoleRenderer.php', 'ArcanistUnitRenderer' => 'unit/renderer/ArcanistUnitRenderer.php', 'ArcanistUnitTestResult' => 'unit/ArcanistUnitTestResult.php', 'ArcanistUnitWorkflow' => 'workflow/ArcanistUnitWorkflow.php', 'ArcanistUpgradeWorkflow' => 'workflow/ArcanistUpgradeWorkflow.php', 'ArcanistUploadWorkflow' => 'workflow/ArcanistUploadWorkflow.php', 'ArcanistUsageException' => 'exception/ArcanistUsageException.php', 'ArcanistUserAbortException' => 'exception/usage/ArcanistUserAbortException.php', 'ArcanistVersionWorkflow' => 'workflow/ArcanistVersionWorkflow.php', 'ArcanistWhichWorkflow' => 'workflow/ArcanistWhichWorkflow.php', 'ArcanistWorkingCopyIdentity' => 'workingcopyidentity/ArcanistWorkingCopyIdentity.php', 'ArcanistXHPASTLintNamingHook' => 'lint/linter/xhpast/ArcanistXHPASTLintNamingHook.php', 'ArcanistXHPASTLintNamingHookTestCase' => 'lint/linter/xhpast/__tests__/ArcanistXHPASTLintNamingHookTestCase.php', 'ArcanistXHPASTLintSwitchHook' => 'lint/linter/xhpast/ArcanistXHPASTLintSwitchHook.php', 'ArcanistXHPASTLintTestSwitchHook' => 'lint/linter/__tests__/ArcanistXHPASTLintTestSwitchHook.php', 'ArcanistXHPASTLinter' => 'lint/linter/ArcanistXHPASTLinter.php', 'ArcanistXHPASTLinterTestCase' => 'lint/linter/__tests__/ArcanistXHPASTLinterTestCase.php', 'ArcanistXMLLinter' => 'lint/linter/ArcanistXMLLinter.php', 'ArcanistXMLLinterTestCase' => 'lint/linter/__tests__/ArcanistXMLLinterTestCase.php', 'ArcanistXUnitTestResultParser' => 'unit/engine/ArcanistXUnitTestResultParser.php', 'CSharpToolsTestEngine' => 'unit/engine/CSharpToolsTestEngine.php', 'ComprehensiveLintEngine' => 'lint/engine/ComprehensiveLintEngine.php', 'GoTestResultParser' => 'unit/engine/GoTestResultParser.php', 'GoTestResultParserTestCase' => 'unit/engine/__tests__/GoTestResultParserTestCase.php', 'NoseTestEngine' => 'unit/engine/NoseTestEngine.php', 'PHPUnitTestEngineTestCase' => 'unit/engine/__tests__/PHPUnitTestEngineTestCase.php', 'PhpunitResultParser' => 'unit/engine/PhpunitResultParser.php', 'PhpunitTestEngine' => 'unit/engine/PhpunitTestEngine.php', 'PhutilUnitTestEngine' => 'unit/engine/PhutilUnitTestEngine.php', 'PhutilUnitTestEngineTestCase' => 'unit/engine/__tests__/PhutilUnitTestEngineTestCase.php', 'PytestTestEngine' => 'unit/engine/PytestTestEngine.php', 'UnitTestableArcanistLintEngine' => 'lint/engine/UnitTestableArcanistLintEngine.php', 'XUnitTestEngine' => 'unit/engine/XUnitTestEngine.php', 'XUnitTestResultParserTestCase' => 'unit/engine/__tests__/XUnitTestResultParserTestCase.php', ), 'function' => array( ), 'xmap' => array( 'ArcanistAliasWorkflow' => 'ArcanistBaseWorkflow', 'ArcanistAmendWorkflow' => 'ArcanistBaseWorkflow', 'ArcanistAnoidWorkflow' => 'ArcanistBaseWorkflow', 'ArcanistArcanistLinterTestCase' => 'ArcanistLinterTestCase', 'ArcanistBackoutWorkflow' => 'ArcanistBaseWorkflow', 'ArcanistBaseCommitParserTestCase' => 'ArcanistTestCase', 'ArcanistBaseWorkflow' => 'Phobject', 'ArcanistBaseXHPASTLinter' => 'ArcanistFutureLinter', 'ArcanistBookmarkWorkflow' => 'ArcanistFeatureWorkflow', 'ArcanistBranchWorkflow' => 'ArcanistFeatureWorkflow', 'ArcanistBritishTestCase' => 'ArcanistTestCase', 'ArcanistBrowseWorkflow' => 'ArcanistBaseWorkflow', 'ArcanistBundleTestCase' => 'ArcanistTestCase', 'ArcanistCSSLintLinter' => 'ArcanistExternalLinter', 'ArcanistCSSLintLinterTestCase' => 'ArcanistArcanistLinterTestCase', 'ArcanistCSharpLinter' => 'ArcanistLinter', 'ArcanistCallConduitWorkflow' => 'ArcanistBaseWorkflow', 'ArcanistCapabilityNotSupportedException' => 'Exception', 'ArcanistChmodLinter' => 'ArcanistLinter', 'ArcanistChooseInvalidRevisionException' => 'Exception', 'ArcanistChooseNoRevisionsException' => 'Exception', 'ArcanistCloseRevisionWorkflow' => 'ArcanistBaseWorkflow', 'ArcanistCloseWorkflow' => 'ArcanistBaseWorkflow', 'ArcanistClosureLinter' => 'ArcanistExternalLinter', 'ArcanistClosureLinterTestCase' => 'ArcanistArcanistLinterTestCase', 'ArcanistCoffeeLintLinter' => 'ArcanistExternalLinter', 'ArcanistCoffeeLintLinterTestCase' => 'ArcanistArcanistLinterTestCase', 'ArcanistCommentRemoverTestCase' => 'ArcanistTestCase', 'ArcanistCommitWorkflow' => 'ArcanistBaseWorkflow', 'ArcanistConduitLinter' => 'ArcanistLinter', 'ArcanistConfigurationDrivenLintEngine' => 'ArcanistLintEngine', 'ArcanistCoverWorkflow' => 'ArcanistBaseWorkflow', 'ArcanistCppcheckLinter' => 'ArcanistExternalLinter', 'ArcanistCppcheckLinterTestCase' => 'ArcanistArcanistLinterTestCase', 'ArcanistCpplintLinter' => 'ArcanistExternalLinter', 'ArcanistCpplintLinterTestCase' => 'ArcanistArcanistLinterTestCase', 'ArcanistDiffParserTestCase' => 'ArcanistTestCase', 'ArcanistDiffUtilsTestCase' => 'ArcanistTestCase', 'ArcanistDiffWorkflow' => 'ArcanistBaseWorkflow', 'ArcanistDifferentialCommitMessageParserException' => 'Exception', 'ArcanistDifferentialDependencyGraph' => 'AbstractDirectedGraph', 'ArcanistDownloadWorkflow' => 'ArcanistBaseWorkflow', 'ArcanistEventType' => 'PhutilEventType', 'ArcanistExportWorkflow' => 'ArcanistBaseWorkflow', 'ArcanistExternalLinter' => 'ArcanistFutureLinter', 'ArcanistFeatureWorkflow' => 'ArcanistBaseWorkflow', 'ArcanistFilenameLinter' => 'ArcanistLinter', 'ArcanistFlagWorkflow' => 'ArcanistBaseWorkflow', 'ArcanistFlake8Linter' => 'ArcanistExternalLinter', 'ArcanistFlake8LinterTestCase' => 'ArcanistArcanistLinterTestCase', 'ArcanistFutureLinter' => 'ArcanistLinter', 'ArcanistGeneratedLinter' => 'ArcanistLinter', 'ArcanistGetConfigWorkflow' => 'ArcanistBaseWorkflow', 'ArcanistGitAPI' => 'ArcanistRepositoryAPI', 'ArcanistGitHookPreReceiveWorkflow' => 'ArcanistBaseWorkflow', 'ArcanistGoLintLinter' => 'ArcanistExternalLinter', 'ArcanistGoLintLinterTestCase' => 'ArcanistArcanistLinterTestCase', 'ArcanistHelpWorkflow' => 'ArcanistBaseWorkflow', 'ArcanistHgClientChannel' => 'PhutilProtocolChannel', 'ArcanistHgServerChannel' => 'PhutilProtocolChannel', 'ArcanistInfrastructureTestCase' => 'ArcanistTestCase', 'ArcanistInlinesWorkflow' => 'ArcanistBaseWorkflow', 'ArcanistInstallCertificateWorkflow' => 'ArcanistBaseWorkflow', 'ArcanistJSHintLinter' => 'ArcanistExternalLinter', 'ArcanistJSHintLinterTestCase' => 'ArcanistArcanistLinterTestCase', 'ArcanistJSONLintLinter' => 'ArcanistExternalLinter', 'ArcanistJSONLintLinterTestCase' => 'ArcanistArcanistLinterTestCase', 'ArcanistJSONLinter' => 'ArcanistLinter', 'ArcanistJSONLinterTestCase' => 'ArcanistArcanistLinterTestCase', 'ArcanistJscsLinter' => 'ArcanistExternalLinter', 'ArcanistJscsLinterTestCase' => 'ArcanistArcanistLinterTestCase', 'ArcanistLandWorkflow' => 'ArcanistBaseWorkflow', 'ArcanistLesscLinter' => 'ArcanistExternalLinter', 'ArcanistLesscLinterTestCase' => 'ArcanistArcanistLinterTestCase', 'ArcanistLiberateWorkflow' => 'ArcanistBaseWorkflow', 'ArcanistLintCheckstyleXMLRenderer' => 'ArcanistLintRenderer', 'ArcanistLintConsoleRenderer' => 'ArcanistLintRenderer', 'ArcanistLintJSONRenderer' => 'ArcanistLintRenderer', 'ArcanistLintLikeCompilerRenderer' => 'ArcanistLintRenderer', 'ArcanistLintNoneRenderer' => 'ArcanistLintRenderer', 'ArcanistLintSummaryRenderer' => 'ArcanistLintRenderer', 'ArcanistLintWorkflow' => 'ArcanistBaseWorkflow', 'ArcanistLinterTestCase' => 'ArcanistPhutilTestCase', 'ArcanistLintersWorkflow' => 'ArcanistBaseWorkflow', 'ArcanistListWorkflow' => 'ArcanistBaseWorkflow', 'ArcanistMercurialAPI' => 'ArcanistRepositoryAPI', 'ArcanistMercurialParserTestCase' => 'ArcanistTestCase', 'ArcanistMergeConflictLinter' => 'ArcanistLinter', 'ArcanistMergeConflictLinterTestCase' => 'ArcanistArcanistLinterTestCase', 'ArcanistNoEffectException' => 'ArcanistUsageException', 'ArcanistNoEngineException' => 'ArcanistUsageException', 'ArcanistNoLintLinter' => 'ArcanistLinter', 'ArcanistNoLintTestCaseMisnamed' => 'ArcanistLinterTestCase', 'ArcanistPEP8Linter' => 'ArcanistExternalLinter', 'ArcanistPEP8LinterTestCase' => 'ArcanistArcanistLinterTestCase', 'ArcanistPHPCSLinterTestCase' => 'ArcanistArcanistLinterTestCase', 'ArcanistPasteWorkflow' => 'ArcanistBaseWorkflow', 'ArcanistPatchWorkflow' => 'ArcanistBaseWorkflow', 'ArcanistPhpcsLinter' => 'ArcanistExternalLinter', 'ArcanistPhutilLibraryLinter' => 'ArcanistLinter', 'ArcanistPhutilTestCaseTestCase' => 'ArcanistPhutilTestCase', 'ArcanistPhutilTestSkippedException' => 'Exception', 'ArcanistPhutilTestTerminatedException' => 'Exception', 'ArcanistPhutilXHPASTLinter' => 'ArcanistBaseXHPASTLinter', 'ArcanistPhutilXHPASTLinterTestCase' => 'ArcanistArcanistLinterTestCase', 'ArcanistPuppetLintLinter' => 'ArcanistExternalLinter', 'ArcanistPuppetLintLinterTestCase' => 'ArcanistArcanistLinterTestCase', 'ArcanistPyFlakesLinter' => 'ArcanistExternalLinter', 'ArcanistPyFlakesLinterTestCase' => 'ArcanistArcanistLinterTestCase', 'ArcanistPyLintLinter' => 'ArcanistLinter', 'ArcanistPyLintLinterTestCase' => 'ArcanistArcanistLinterTestCase', 'ArcanistRepositoryAPIMiscTestCase' => 'ArcanistTestCase', 'ArcanistRepositoryAPIStateTestCase' => 'ArcanistTestCase', 'ArcanistRevertWorkflow' => 'ArcanistBaseWorkflow', 'ArcanistRubyLinter' => 'ArcanistExternalLinter', 'ArcanistRubyLinterTestCase' => 'ArcanistArcanistLinterTestCase', 'ArcanistScriptAndRegexLinter' => 'ArcanistLinter', 'ArcanistSetConfigWorkflow' => 'ArcanistBaseWorkflow', 'ArcanistShellCompleteWorkflow' => 'ArcanistBaseWorkflow', 'ArcanistSingleLintEngine' => 'ArcanistLintEngine', 'ArcanistSpellingLinter' => 'ArcanistLinter', 'ArcanistSpellingLinterTestCase' => 'ArcanistArcanistLinterTestCase', 'ArcanistSubversionAPI' => 'ArcanistRepositoryAPI', 'ArcanistSubversionHookAPI' => 'ArcanistHookAPI', 'ArcanistSvnHookPreCommitWorkflow' => 'ArcanistBaseWorkflow', 'ArcanistTasksWorkflow' => 'ArcanistBaseWorkflow', 'ArcanistTestCase' => 'ArcanistPhutilTestCase', 'ArcanistTextLinter' => 'ArcanistLinter', 'ArcanistTextLinterTestCase' => 'ArcanistArcanistLinterTestCase', 'ArcanistTodoWorkflow' => 'ArcanistBaseWorkflow', 'ArcanistUncommittedChangesException' => 'ArcanistUsageException', 'ArcanistUnitConsoleRenderer' => 'ArcanistUnitRenderer', 'ArcanistUnitWorkflow' => 'ArcanistBaseWorkflow', 'ArcanistUpgradeWorkflow' => 'ArcanistBaseWorkflow', 'ArcanistUploadWorkflow' => 'ArcanistBaseWorkflow', 'ArcanistUsageException' => 'Exception', 'ArcanistUserAbortException' => 'ArcanistUsageException', 'ArcanistVersionWorkflow' => 'ArcanistBaseWorkflow', 'ArcanistWhichWorkflow' => 'ArcanistBaseWorkflow', 'ArcanistXHPASTLintNamingHookTestCase' => 'ArcanistTestCase', 'ArcanistXHPASTLintTestSwitchHook' => 'ArcanistXHPASTLintSwitchHook', 'ArcanistXHPASTLinter' => 'ArcanistBaseXHPASTLinter', 'ArcanistXHPASTLinterTestCase' => 'ArcanistArcanistLinterTestCase', 'ArcanistXMLLinter' => 'ArcanistLinter', 'ArcanistXMLLinterTestCase' => 'ArcanistArcanistLinterTestCase', 'CSharpToolsTestEngine' => 'XUnitTestEngine', 'ComprehensiveLintEngine' => 'ArcanistLintEngine', 'GoTestResultParser' => 'ArcanistBaseTestResultParser', 'GoTestResultParserTestCase' => 'ArcanistTestCase', 'NoseTestEngine' => 'ArcanistBaseUnitTestEngine', 'PHPUnitTestEngineTestCase' => 'ArcanistTestCase', 'PhpunitResultParser' => 'ArcanistBaseTestResultParser', 'PhpunitTestEngine' => 'ArcanistBaseUnitTestEngine', 'PhutilUnitTestEngine' => 'ArcanistBaseUnitTestEngine', 'PhutilUnitTestEngineTestCase' => 'ArcanistTestCase', 'PytestTestEngine' => 'ArcanistBaseUnitTestEngine', 'UnitTestableArcanistLintEngine' => 'ArcanistLintEngine', 'XUnitTestEngine' => 'ArcanistBaseUnitTestEngine', 'XUnitTestResultParserTestCase' => 'ArcanistTestCase', ), )); diff --git a/src/parser/ArcanistDiffParser.php b/src/parser/ArcanistDiffParser.php index f9f9e441..50d9c3bd 100644 --- a/src/parser/ArcanistDiffParser.php +++ b/src/parser/ArcanistDiffParser.php @@ -1,1408 +1,1416 @@ repositoryAPI = $repository_api; return $this; } public function setDetectBinaryFiles($detect) { $this->detectBinaryFiles = $detect; return $this; } public function setTryEncoding($encoding) { $this->tryEncoding = $encoding; return $this; } public function forcePath($path) { $this->forcePath = $path; return $this; } public function setChanges(array $changes) { assert_instances_of($changes, 'ArcanistDiffChange'); $this->changes = mpull($changes, null, 'getCurrentPath'); return $this; } public function parseSubversionDiff(ArcanistSubversionAPI $api, $paths) { $this->setRepositoryAPI($api); $diffs = array(); foreach ($paths as $path => $status) { if ($status & ArcanistRepositoryAPI::FLAG_UNTRACKED || $status & ArcanistRepositoryAPI::FLAG_CONFLICT || $status & ArcanistRepositoryAPI::FLAG_MISSING) { unset($paths[$path]); } } $root = null; $from = array(); foreach ($paths as $path => $status) { $change = $this->buildChange($path); if ($status & ArcanistRepositoryAPI::FLAG_ADDED) { $change->setType(ArcanistDiffChangeType::TYPE_ADD); } else if ($status & ArcanistRepositoryAPI::FLAG_DELETED) { $change->setType(ArcanistDiffChangeType::TYPE_DELETE); } else { $change->setType(ArcanistDiffChangeType::TYPE_CHANGE); } $is_dir = is_dir($api->getPath($path)); if ($is_dir) { $change->setFileType(ArcanistDiffChangeType::FILE_DIRECTORY); // We have to go hit the diff even for directories because they may // have property changes or moves, etc. } $is_link = is_link($api->getPath($path)); if ($is_link) { $change->setFileType(ArcanistDiffChangeType::FILE_SYMLINK); } $diff = $api->getRawDiffText($path); if ($diff) { $this->parseDiff($diff); } $info = $api->getSVNInfo($path); if (idx($info, 'Copied From URL')) { if (!$root) { $rinfo = $api->getSVNInfo('.'); $root = $rinfo['URL'].'/'; } $cpath = $info['Copied From URL']; $root_len = strlen($root); if (!strncmp($cpath, $root, $root_len)) { $cpath = substr($cpath, $root_len); // The user can "svn cp /path/to/file@12345 x", which pulls a file out // of version history at a specific revision. If we just use the path, // we'll collide with possible changes to that path in the working // copy below. In particular, "svn cp"-ing a path which no longer // exists somewhere in the working copy and then adding that path // gets us to the "origin change type" branches below with a // TYPE_ADD state on the path. To avoid this, append the origin // revision to the path so we'll necessarily generate a new change. // TODO: In theory, you could have an '@' in your path and this could // cause a collision, e.g. two files named 'f' and 'f@12345'. This is // at least somewhat the user's fault, though. if ($info['Copied From Rev']) { if ($info['Copied From Rev'] != $info['Revision']) { $cpath .= '@'.$info['Copied From Rev']; } } $change->setOldPath($cpath); $from[$path] = $cpath; } } $type = $change->getType(); if (($type === ArcanistDiffChangeType::TYPE_MOVE_AWAY || $type === ArcanistDiffChangeType::TYPE_DELETE) && idx($info, 'Node Kind') === 'directory') { $change->setFileType(ArcanistDiffChangeType::FILE_DIRECTORY); } } foreach ($paths as $path => $status) { $change = $this->buildChange($path); if (empty($from[$path])) { continue; } if (empty($this->changes[$from[$path]])) { if ($change->getType() == ArcanistDiffChangeType::TYPE_COPY_HERE) { // If the origin path wasn't changed (or isn't included in this diff) // and we only copied it, don't generate a changeset for it. This // keeps us out of trouble when we go to 'arc commit' and need to // figure out which files should be included in the commit list. continue; } } $origin = $this->buildChange($from[$path]); $origin->addAwayPath($change->getCurrentPath()); $type = $origin->getType(); switch ($type) { case ArcanistDiffChangeType::TYPE_MULTICOPY: case ArcanistDiffChangeType::TYPE_COPY_AWAY: // "Add" is possible if you do some bizarre tricks with svn:ignore and // "svn copy"'ing URLs straight from the repository; you can end up with // a file that is a copy of itself. See T271. case ArcanistDiffChangeType::TYPE_ADD: break; case ArcanistDiffChangeType::TYPE_DELETE: $origin->setType(ArcanistDiffChangeType::TYPE_MOVE_AWAY); break; case ArcanistDiffChangeType::TYPE_MOVE_AWAY: $origin->setType(ArcanistDiffChangeType::TYPE_MULTICOPY); break; case ArcanistDiffChangeType::TYPE_CHANGE: $origin->setType(ArcanistDiffChangeType::TYPE_COPY_AWAY); break; default: throw new Exception("Bad origin state {$type}."); } $type = $origin->getType(); switch ($type) { case ArcanistDiffChangeType::TYPE_MULTICOPY: case ArcanistDiffChangeType::TYPE_MOVE_AWAY: $change->setType(ArcanistDiffChangeType::TYPE_MOVE_HERE); break; case ArcanistDiffChangeType::TYPE_ADD: case ArcanistDiffChangeType::TYPE_COPY_AWAY: $change->setType(ArcanistDiffChangeType::TYPE_COPY_HERE); break; default: throw new Exception("Bad origin state {$type}."); } } return $this->changes; } public function parseDiff($diff) { if (!strlen(trim($diff))) { throw new Exception("Can't parse an empty diff!"); } // Detect `git-format-patch`, by looking for a "---" line somewhere in // the file and then a footer with Git version number, which looks like // this: // // -- // 1.8.4.2 // // Note that `git-format-patch` adds a space after the "--", but we don't // require it when detecting patches, as trailing whitespace can easily be // lost in transit. $detect_patch = '/^---$.*^-- ?[\s\d.]+\z/ms'; $message = null; if (preg_match($detect_patch, $diff)) { list($message, $diff) = $this->stripGitFormatPatch($diff); } $this->didStartParse($diff); // Strip off header comments. While `patch` allows comments anywhere in the // file, `git apply` is more strict. We get these comments in `hg export` // diffs, and Eclipse can also produce them. $line = $this->getLineTrimmed(); while (preg_match('/^#/', $line)) { $line = $this->nextLine(); } if (strlen($message)) { // If we found a message during pre-parse steps, add it to the resulting // changes here. $change = $this->buildChange(null) ->setType(ArcanistDiffChangeType::TYPE_MESSAGE) ->setMetadata('message', $message); } do { $patterns = array( // This is a normal SVN text change, probably from "svn diff". '(?PIndex): (?P.+)', // This is an SVN text change, probably from "svnlook diff". '(?PModified|Added|Deleted|Copied): (?P.+)', // This is an SVN property change, probably from "svn diff". '(?PProperty changes on): (?P.+)', // This is a git commit message, probably from "git show". '(?Pcommit) (?P[a-f0-9]+)(?: \(.*\))?', // This is a git diff, probably from "git show" or "git diff". // Note that the filenames may appear quoted. '(?Pdiff --git) (?P.*)', // RCS Diff '(?Prcsdiff -u) (?P.*)', // This is a unified diff, probably from "diff -u" or synthetic diffing. '(?P---) (?P.+)\s+\d{4}-\d{2}-\d{2}.*', '(?PBinary files|Files) '. '(?P.+)\s+\d{4}-\d{2}-\d{2} and '. '(?P.+)\s+\d{4}-\d{2}-\d{2} differ.*', // This is a normal Mercurial text change, probably from "hg diff". It // may have two "-r" blocks if it came from "hg diff -r x:y". '(?Pdiff -r) (?P[a-f0-9]+) (?:-r [a-f0-9]+ )?(?P.+)', ); $line = $this->getLineTrimmed(); $match = null; $ok = $this->tryMatchHeader($patterns, $line, $match); $failed_parse = false; if (!$ok && $this->isFirstNonEmptyLine()) { // 'hg export' command creates so called "extended diff" that // contains some meta information and comment at the beginning // (isFirstNonEmptyLine() to check for beginning). Actual mercurial // code detects where comment ends and unified diff starts by // searching for "diff -r" or "diff --git" in the text. $this->saveLine(); $line = $this->nextLineThatLooksLikeDiffStart(); if (!$this->tryMatchHeader($patterns, $line, $match)) { // Restore line before guessing to display correct error. $this->restoreLine(); $failed_parse = true; } } else if (!$ok) { $failed_parse = true; } if ($failed_parse) { $this->didFailParse( "Expected a hunk header, like 'Index: /path/to/file.ext' (svn), ". "'Property changes on: /path/to/file.ext' (svn properties), ". "'commit 59bcc3ad6775562f845953cf01624225' (git show), ". "'diff --git' (git diff), '--- filename' (unified diff), or ". "'diff -r' (hg diff or patch)."); } if (isset($match['type'])) { if ($match['type'] == 'diff --git') { list($old, $new) = self::splitGitDiffPaths($match['oldnew']); $match['old'] = $old; $match['cur'] = $new; } } $change = $this->buildChange(idx($match, 'cur')); if (isset($match['old'])) { $change->setOldPath($match['old']); } if (isset($match['hash'])) { $change->setCommitHash($match['hash']); } if (isset($match['binary'])) { $change->setFileType(ArcanistDiffChangeType::FILE_BINARY); $line = $this->nextNonemptyLine(); continue; } $line = $this->nextLine(); switch ($match['type']) { case 'Index': case 'Modified': case 'Added': case 'Deleted': case 'Copied': $this->parseIndexHunk($change); break; case 'Property changes on': $this->parsePropertyHunk($change); break; case 'diff --git': $this->setIsGit(true); $this->parseIndexHunk($change); break; case 'commit': $this->setIsGit(true); $this->parseCommitMessage($change); break; case '---': $ok = preg_match( '@^(?:\+\+\+) (.*)\s+\d{4}-\d{2}-\d{2}.*$@', $line, $match); if (!$ok) { $this->didFailParse("Expected '+++ filename' in unified diff."); } $change->setCurrentPath($match[1]); $line = $this->nextLine(); $this->parseChangeset($change); break; case 'diff -r': $this->setIsMercurial(true); $this->parseIndexHunk($change); break; case 'rcsdiff -u': $this->isRCS = true; $this->parseIndexHunk($change); break; default: $this->didFailParse('Unknown diff type.'); break; } } while ($this->getLine() !== null); $this->didFinishParse(); $this->loadSyntheticData(); return $this->changes; } protected function tryMatchHeader($patterns, $line, &$match) { foreach ($patterns as $pattern) { if (preg_match('@^'.$pattern.'$@', $line, $match)) { return true; } } return false; } protected function parseCommitMessage(ArcanistDiffChange $change) { $change->setType(ArcanistDiffChangeType::TYPE_MESSAGE); $message = array(); $line = $this->getLine(); if (preg_match('/^Merge: /', $line)) { $this->nextLine(); } $line = $this->getLine(); if (!preg_match('/^Author: /', $line)) { $this->didFailParse("Expected 'Author:'."); } $line = $this->nextLine(); if (!preg_match('/^Date: /', $line)) { $this->didFailParse("Expected 'Date:'."); } while (($line = $this->nextLineTrimmed()) !== null) { if (strlen($line) && $line[0] != ' ') { break; } // Strip leading spaces from Git commit messages. Note that empty lines // are represented as just "\n"; don't touch those. $message[] = preg_replace('/^ /', '', $this->getLine()); } $message = rtrim(implode('', $message), "\r\n"); $change->setMetadata('message', $message); } /** * Parse an SVN property change hunk. These hunks are ambiguous so just sort * of try to get it mostly right. It's entirely possible to foil this parser * (or any other parser) with a carefully constructed property change. */ protected function parsePropertyHunk(ArcanistDiffChange $change) { $line = $this->getLineTrimmed(); if (!preg_match('/^_+$/', $line)) { $this->didFailParse("Expected '______________________'."); } $line = $this->nextLine(); while ($line !== null) { $done = preg_match('/^(Index|Property changes on):/', $line); if ($done) { break; } // NOTE: Before 1.5, SVN uses "Name". At 1.5 and later, SVN uses // "Modified", "Added" and "Deleted". $matches = null; $ok = preg_match( '/^(Name|Modified|Added|Deleted): (.*)$/', $line, $matches); if (!$ok) { $this->didFailParse( "Expected 'Name', 'Added', 'Deleted', or 'Modified'."); } $op = $matches[1]; $prop = $matches[2]; list($old, $new) = $this->parseSVNPropertyChange($op, $prop); if ($old !== null) { $change->setOldProperty($prop, $old); } if ($new !== null) { $change->setNewProperty($prop, $new); } $line = $this->getLine(); } } private function parseSVNPropertyChange($op, $prop) { $old = array(); $new = array(); $target = null; $line = $this->nextLine(); $prop_index = 2; while ($line !== null) { $done = preg_match( '/^(Modified|Added|Deleted|Index|Property changes on):/', $line); if ($done) { break; } $trimline = ltrim($line); if ($trimline && $trimline[0] == '#') { // in svn1.7, a line like ## -0,0 +1 ## is put between the Added: line // and the line with the property change. If we have such a line, we'll // just ignore it (: $line = $this->nextLine(); $prop_index = 1; $trimline = ltrim($line); } if ($trimline && $trimline[0] == '+') { if ($op == 'Deleted') { $this->didFailParse('Unexpected "+" section in property deletion.'); } $target = 'new'; $line = substr($trimline, $prop_index); } else if ($trimline && $trimline[0] == '-') { if ($op == 'Added') { $this->didFailParse('Unexpected "-" section in property addition.'); } $target = 'old'; $line = substr($trimline, $prop_index); } else if (!strncmp($trimline, 'Merged', 6)) { if ($op == 'Added') { $target = 'new'; } else { // These can appear on merges. No idea how to interpret this (unclear // what the old / new values are) and it's of dubious usefulness so // just throw it away until someone complains. $target = null; } $line = $trimline; } if ($target == 'new') { $new[] = $line; } else if ($target == 'old') { $old[] = $line; } $line = $this->nextLine(); } $old = rtrim(implode('', $old)); $new = rtrim(implode('', $new)); if (!strlen($old)) { $old = null; } if (!strlen($new)) { $new = null; } return array($old, $new); } protected function setIsGit($git) { if ($this->isGit !== null && $this->isGit != $git) { throw new Exception('Git status has changed!'); } $this->isGit = $git; return $this; } protected function getIsGit() { return $this->isGit; } public function setIsMercurial($is_mercurial) { $this->isMercurial = $is_mercurial; return $this; } public function getIsMercurial() { return $this->isMercurial; } protected function parseIndexHunk(ArcanistDiffChange $change) { $is_git = $this->getIsGit(); $is_mercurial = $this->getIsMercurial(); $is_svn = (!$is_git && !$is_mercurial); $move_source = null; $line = $this->getLine(); if ($is_git) { do { $patterns = array( '(?Pnew) file mode (?P\d+)', '(?Pdeleted) file mode (?P\d+)', // These occur when someone uses `chmod` on a file. 'old mode (?P\d+)', 'new mode (?P\d+)', // These occur when you `mv` a file and git figures it out. 'similarity index ', 'rename from (?P.*)', '(?Prename) to (?P.*)', 'copy from (?P.*)', '(?Pcopy) to (?P.*)' ); $ok = false; $match = null; foreach ($patterns as $pattern) { $ok = preg_match('@^'.$pattern.'@', $line, $match); if ($ok) { break; } } if (!$ok) { if ($line === null || preg_match('/^(diff --git|commit) /', $line)) { // In this case, there are ONLY file mode changes, or this is a // pure move. If it's a move, flag these changesets so we can build // synthetic changes later, enabling us to show file contents in // Differential -- git only gives us a block like this: // // diff --git a/README b/READYOU // similarity index 100% // rename from README // rename to READYOU // // ...i.e., there is no associated diff. // This allows us to distinguish between property changes only // and actual moves. For property changes only, we can't currently // build a synthetic diff correctly, so just skip it. // TODO: Build synthetic diffs for property changes, too. if ($change->getType() != ArcanistDiffChangeType::TYPE_CHANGE) { $change->setNeedsSyntheticGitHunks(true); if ($move_source) { $move_source->setNeedsSyntheticGitHunks(true); } } return; } break; } if (!empty($match['oldmode'])) { $change->setOldProperty('unix:filemode', $match['oldmode']); } if (!empty($match['newmode'])) { $change->setNewProperty('unix:filemode', $match['newmode']); } if (!empty($match['deleted'])) { $change->setType(ArcanistDiffChangeType::TYPE_DELETE); } if (!empty($match['new'])) { // If you replace a symlink with a normal file, git renders the change // as a "delete" of the symlink plus an "add" of the new file. We // prefer to represent this as a change. if ($change->getType() == ArcanistDiffChangeType::TYPE_DELETE) { $change->setType(ArcanistDiffChangeType::TYPE_CHANGE); } else { $change->setType(ArcanistDiffChangeType::TYPE_ADD); } } if (!empty($match['old'])) { $match['old'] = self::unescapeFilename($match['old']); $change->setOldPath($match['old']); } if (!empty($match['cur'])) { $match['cur'] = self::unescapeFilename($match['cur']); $change->setCurrentPath($match['cur']); } if (!empty($match['copy'])) { $change->setType(ArcanistDiffChangeType::TYPE_COPY_HERE); $old = $this->buildChange($change->getOldPath()); $type = $old->getType(); if ($type == ArcanistDiffChangeType::TYPE_MOVE_AWAY) { $old->setType(ArcanistDiffChangeType::TYPE_MULTICOPY); } else { $old->setType(ArcanistDiffChangeType::TYPE_COPY_AWAY); } $old->addAwayPath($change->getCurrentPath()); } if (!empty($match['move'])) { $change->setType(ArcanistDiffChangeType::TYPE_MOVE_HERE); $old = $this->buildChange($change->getOldPath()); $type = $old->getType(); if ($type == ArcanistDiffChangeType::TYPE_MULTICOPY) { // Great, no change. } else if ($type == ArcanistDiffChangeType::TYPE_MOVE_AWAY) { $old->setType(ArcanistDiffChangeType::TYPE_MULTICOPY); } else if ($type == ArcanistDiffChangeType::TYPE_COPY_AWAY) { $old->setType(ArcanistDiffChangeType::TYPE_MULTICOPY); } else { $old->setType(ArcanistDiffChangeType::TYPE_MOVE_AWAY); } // We'll reference this above. $move_source = $old; $old->addAwayPath($change->getCurrentPath()); } $line = $this->nextNonemptyLine(); } while (true); } $line = $this->getLine(); if ($is_svn) { $ok = preg_match('/^=+\s*$/', $line); if (!$ok) { $this->didFailParse("Expected '=======================' divider line."); } else { // Adding an empty file in SVN can produce an empty line here. $line = $this->nextNonemptyLine(); } } else if ($is_git) { $ok = preg_match('/^index .*$/', $line); if (!$ok) { // TODO: "hg diff -g" diffs ("mercurial git-style diffs") do not include // this line, so we can't parse them if we fail on it. Maybe introduce // a flag saying "parse this diff using relaxed git-style diff rules"? // $this->didFailParse("Expected 'index af23f...a98bc' header line."); } else { // NOTE: In the git case, where this patch is the last change in the // file, we may have a final terminal newline. Skip over it so that // we'll hit the '$line === null' block below. This is covered by the // 'git-empty-file.gitdiff' test case. $line = $this->nextNonemptyLine(); } } // If there are files with only whitespace changes and -b or -w are // supplied as command-line flags to `diff', svn and git both produce // changes without any body. if ($line === null || preg_match( '/^(Index:|Property changes on:|diff --git|commit) /', $line)) { return; } $is_binary_add = preg_match( '/^Cannot display: file marked as a binary type\.$/', rtrim($line)); if ($is_binary_add) { $this->nextLine(); // Cannot display: file marked as a binary type. $this->nextNonemptyLine(); // svn:mime-type = application/octet-stream $this->markBinary($change); return; } // We can get this in git, or in SVN when a file exists in the repository // WITHOUT a binary mime-type and is changed and given a binary mime-type. $is_binary_diff = preg_match( '/^(Binary files|Files) .* and .* differ$/', rtrim($line)); if ($is_binary_diff) { $this->nextNonemptyLine(); // Binary files x and y differ $this->markBinary($change); return; } // This occurs under "hg diff --git" when a binary file is removed. See // test case "hg-binary-delete.hgdiff". (I believe it never occurs under // git, which reports the "files X and /dev/null differ" string above. Git // can not apply these patches.) $is_hg_binary_delete = preg_match( '/^Binary file .* has changed$/', rtrim($line)); if ($is_hg_binary_delete) { $this->nextNonemptyLine(); $this->markBinary($change); return; } // With "git diff --binary" (not a normal mode, but one users may explicitly // invoke and then, e.g., copy-paste into the web console) or "hg diff // --git" (normal under hg workflows), we may encounter a literal binary // patch. $is_git_binary_patch = preg_match( '/^GIT binary patch$/', rtrim($line)); if ($is_git_binary_patch) { $this->nextLine(); $this->parseGitBinaryPatch(); $line = $this->getLine(); if (preg_match('/^literal/', $line)) { // We may have old/new binaries (change) or just a new binary (hg add). // If there are two blocks, parse both. $this->parseGitBinaryPatch(); } $this->markBinary($change); return; } if ($is_git) { // "git diff -b" ignores whitespace, but has an empty hunk target if (preg_match('@^diff --git .*$@', $line)) { $this->nextLine(); return null; } } if ($this->isRCS) { // Skip the RCS headers. $this->nextLine(); $this->nextLine(); $this->nextLine(); } $old_file = $this->parseHunkTarget(); $new_file = $this->parseHunkTarget(); if ($this->isRCS) { $change->setCurrentPath($new_file); } $change->setOldPath($old_file); $this->parseChangeset($change); } private function parseGitBinaryPatch() { // TODO: We could decode the patches, but it's a giant mess so don't bother // for now. We'll pick up the data from the working copy in the common // case ("arc diff"). $line = $this->getLine(); if (!preg_match('/^literal /', $line)) { $this->didFailParse("Expected 'literal NNNN' to start git binary patch."); } do { $line = $this->nextLineTrimmed(); if ($line === '' || $line === null) { // Some versions of Mercurial apparently omit the terminal newline, // although it's unclear if Git will ever do this. In either case, // rely on the base85 check for sanity. $this->nextNonemptyLine(); return; } else if (!preg_match('/^[a-zA-Z]/', $line)) { $this->didFailParse('Expected base85 line length character (a-zA-Z).'); } } while (true); } protected function parseHunkTarget() { $line = $this->getLine(); $matches = null; $remainder = '(?:\s*\(.*\))?'; if ($this->getIsMercurial()) { // Something like "Fri Aug 26 01:20:50 2005 -0700", don't bother trying // to parse it. $remainder = '\t.*'; } else if ($this->isRCS) { $remainder = '\s.*'; } $ok = preg_match( '@^[-+]{3} (?:[ab]/)?(?P.*?)'.$remainder.'$@', $line, $matches); if (!$ok) { $this->didFailParse( "Expected hunk target '+++ path/to/file.ext (revision N)'."); } $this->nextLine(); return $matches['path']; } protected function markBinary(ArcanistDiffChange $change) { $change->setFileType(ArcanistDiffChangeType::FILE_BINARY); return $this; } protected function parseChangeset(ArcanistDiffChange $change) { + // If a diff includes two sets of changes to the same file, let the + // second one win. In particular, this occurs when adding subdirectories + // in Subversion that contain files: the file text will be present in + // both the directory diff and the file diff. See T5555. Dropping the + // hunks lets whichever one shows up later win instead of showing changes + // twice. + $change->dropHunks(); + $all_changes = array(); do { $hunk = new ArcanistDiffHunk(); $line = $this->getLineTrimmed(); $real = array(); // In the case where only one line is changed, the length is omitted. // The final group is for git, which appends a guess at the function // context to the diff. $matches = null; $ok = preg_match( '/^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@(?: .*?)?$/U', $line, $matches); if (!$ok) { // It's possible we hit the style of an svn1.7 property change. // This is a 4-line Index block, followed by an empty line, followed // by a "Property changes on:" section similar to svn1.6. if ($line == '') { $line = $this->nextNonemptyLine(); $ok = preg_match('/^Property changes on:/', $line); if (!$ok) { $this->didFailParse('Confused by empty line'); } $line = $this->nextLine(); return $this->parsePropertyHunk($change); } $this->didFailParse("Expected hunk header '@@ -NN,NN +NN,NN @@'."); } $hunk->setOldOffset($matches[1]); $hunk->setNewOffset($matches[3]); // Cover for the cases where length wasn't present (implying one line). $old_len = idx($matches, 2); if (!strlen($old_len)) { $old_len = 1; } $new_len = idx($matches, 4); if (!strlen($new_len)) { $new_len = 1; } $hunk->setOldLength($old_len); $hunk->setNewLength($new_len); $add = 0; $del = 0; $hit_next_hunk = false; while ((($line = $this->nextLine()) !== null)) { if (strlen(rtrim($line, "\r\n"))) { $char = $line[0]; } else { // Normally, we do not encouter empty lines in diffs, because // unchanged lines have an initial space. However, in Git, with // the option `diff.suppress-blank-empty` set, unchanged blank lines // emit as completely empty. If we encounter a completely empty line, // treat it as a ' ' (i.e., unchanged empty line) line. $char = ' '; } switch ($char) { case '\\': if (!preg_match('@\\ No newline at end of file@', $line)) { $this->didFailParse( "Expected '\ No newline at end of file'."); } if ($new_len) { $real[] = $line; $hunk->setIsMissingOldNewline(true); } else { $real[] = $line; $hunk->setIsMissingNewNewline(true); } if (!$new_len) { break 2; } break; case '+': ++$add; --$new_len; $real[] = $line; break; case '-': if (!$old_len) { // In this case, we've hit "---" from a new file. So don't // advance the line cursor. $hit_next_hunk = true; break 2; } ++$del; --$old_len; $real[] = $line; break; case ' ': if (!$old_len && !$new_len) { break 2; } --$old_len; --$new_len; $real[] = $line; break; default: // We hit something, likely another hunk. $hit_next_hunk = true; break 2; } } if ($old_len || $new_len) { $this->didFailParse('Found the wrong number of hunk lines.'); } $corpus = implode('', $real); $is_binary = false; if ($this->detectBinaryFiles) { $is_binary = !phutil_is_utf8($corpus); $try_encoding = $this->tryEncoding; if ($is_binary && $try_encoding) { $is_binary = ArcanistDiffUtils::isHeuristicBinaryFile($corpus); if (!$is_binary) { $corpus = phutil_utf8_convert($corpus, 'UTF-8', $try_encoding); if (!phutil_is_utf8($corpus)) { throw new Exception( "Failed to convert a hunk from '{$try_encoding}' to UTF-8. ". "Check that the specified encoding is correct."); } } } } if ($is_binary) { // SVN happily treats binary files which aren't marked with the right // mime type as text files. Detect that junk here and mark the file // binary. We'll catch stuff with unicode too, but that's verboten // anyway. If there are too many false positives with this we might // need to make it threshold-triggered instead of triggering on any // unprintable byte. $change->setFileType(ArcanistDiffChangeType::FILE_BINARY); } else { $hunk->setCorpus($corpus); $hunk->setAddLines($add); $hunk->setDelLines($del); $change->addHunk($hunk); } if (!$hit_next_hunk) { $line = $this->nextNonemptyLine(); } } while (preg_match('/^@@ /', $line)); } protected function buildChange($path = null) { $change = null; if ($path !== null) { if (!empty($this->changes[$path])) { return $this->changes[$path]; } } if ($this->forcePath) { return $this->changes[$this->forcePath]; } $change = new ArcanistDiffChange(); if ($path !== null) { $change->setCurrentPath($path); $this->changes[$path] = $change; } else { $this->changes[] = $change; } return $change; } protected function didStartParse($text) { $this->rawDiff = $text; // Eat leading whitespace. This may happen if the first change in the diff // is an SVN property change. $text = ltrim($text); // Try to strip ANSI color codes from colorized diffs. ANSI color codes // might be present in two cases: // // - You piped a colorized diff into 'arc --raw' or similar (normally // we're able to disable colorization on diffs we control the generation // of). // - You're diffing a file which actually contains ANSI color codes. // // The former is vastly more likely, but we try to distinguish between the // two cases by testing for a color code at the beginning of a line. If // we find one, we know it's a colorized diff (since the beginning of the // line should be "+", "-" or " " if the code is in the diff text). // // While it's possible a diff might be colorized and fail this test, it's // unlikely, and it covers hg's color extension which seems to be the most // stubborn about colorizing text despite stdout not being a TTY. // // We might incorrectly strip color codes from a colorized diff of a text // file with color codes inside it, but this case is stupid and pathological // and you've dug your own grave. $ansi_color_pattern = '\x1B\[[\d;]*m'; if (preg_match('/^'.$ansi_color_pattern.'/m', $text)) { $text = preg_replace('/'.$ansi_color_pattern.'/', '', $text); } $this->text = phutil_split_lines($text); $this->line = 0; } protected function getLine() { if ($this->text === null) { throw new Exception('Not parsing!'); } if (isset($this->text[$this->line])) { return $this->text[$this->line]; } return null; } protected function getLineTrimmed() { $line = $this->getLine(); if ($line !== null) { $line = trim($line, "\r\n"); } return $line; } protected function nextLine() { $this->line++; return $this->getLine(); } protected function nextLineTrimmed() { $line = $this->nextLine(); if ($line !== null) { $line = trim($line, "\r\n"); } return $line; } protected function nextNonemptyLine() { while (($line = $this->nextLine()) !== null) { if (strlen(trim($line)) !== 0) { break; } } return $this->getLine(); } protected function nextLineThatLooksLikeDiffStart() { while (($line = $this->nextLine()) !== null) { if (preg_match('/^\s*diff\s+-(?:r|-git)/', $line)) { break; } } return $this->getLine(); } protected function saveLine() { $this->lineSaved = $this->line; } protected function restoreLine() { $this->line = $this->lineSaved; } protected function isFirstNonEmptyLine() { $len = count($this->text); for ($ii = 0; $ii < $len; $ii++) { $line = $this->text[$ii]; if (!strlen(trim($line))) { // This line is empty, skip it. continue; } if (preg_match('/^#/', $line)) { // This line is a comment, skip it. continue; } return ($ii == $this->line); } // Entire file is empty. return false; } protected function didFinishParse() { $this->text = null; } public function setWriteDiffOnFailure($write) { $this->writeDiffOnFailure = $write; return $this; } protected function didFailParse($message) { $context = 5; $min = max(0, $this->line - $context); $max = min($this->line + $context, count($this->text) - 1); $context = ''; for ($ii = $min; $ii <= $max; $ii++) { $context .= sprintf( '%8.8s %6.6s %s', ($ii == $this->line) ? '>>> ' : '', $ii + 1, $this->text[$ii]); } $out = array(); $out[] = "Diff Parse Exception: {$message}"; if ($this->writeDiffOnFailure) { $temp = new TempFile(); $temp->setPreserveFile(true); Filesystem::writeFile($temp, $this->rawDiff); $out[] = 'Raw input file was written to: '.(string)$temp; } $out[] = $context; $out = implode("\n\n", $out); throw new Exception($out); } /** * Unescape escaped filenames, e.g. from "git diff". */ private static function unescapeFilename($name) { if (preg_match('/^".+"$/', $name)) { return stripcslashes(substr($name, 1, -1)); } else { return $name; } } private function loadSyntheticData() { if (!$this->changes) { return; } $repository_api = $this->repositoryAPI; if (!$repository_api) { return; } $imagechanges = array(); $changes = $this->changes; foreach ($changes as $change) { $path = $change->getCurrentPath(); // Certain types of changes (moves and copies) don't contain change data // when expressed in raw "git diff" form. Augment any such diffs with // textual data. if ($change->getNeedsSyntheticGitHunks() && ($repository_api instanceof ArcanistGitAPI)) { $diff = $repository_api->getRawDiffText($path, $moves = false); // NOTE: We're reusing the parser and it doesn't reset change state // between parses because there's an oddball SVN workflow in Phabricator // which relies on being able to inject changes. // TODO: Fix this. $parser = clone $this; $parser->setChanges(array()); $raw_changes = $parser->parseDiff($diff); foreach ($raw_changes as $raw_change) { if ($raw_change->getCurrentPath() == $path) { $change->setFileType($raw_change->getFileType()); foreach ($raw_change->getHunks() as $hunk) { // Git thinks that this file has been added. But we know that it // has been moved or copied without a change. $hunk->setCorpus( preg_replace('/^\+/m', ' ', $hunk->getCorpus())); $change->addHunk($hunk); } break; } } $change->setNeedsSyntheticGitHunks(false); } if ($change->getFileType() != ArcanistDiffChangeType::FILE_BINARY && $change->getFileType() != ArcanistDiffChangeType::FILE_IMAGE) { continue; } $imagechanges[$path] = $change; } // Fetch the actual file contents in batches so repositories // that have slow random file accesses (i.e. mercurial) can // optimize the retrieval. $paths = array_keys($imagechanges); $filedata = $repository_api->getBulkOriginalFileData($paths); foreach ($filedata as $path => $data) { $imagechanges[$path]->setOriginalFileData($data); } $filedata = $repository_api->getBulkCurrentFileData($paths); foreach ($filedata as $path => $data) { $imagechanges[$path]->setCurrentFileData($data); } $this->changes = $changes; } /** * Strip prefixes off paths from `git diff`. By default git uses a/ and b/, * but you can set `diff.mnemonicprefix` to get a different set of prefixes, * or use `--no-prefix`, `--src-prefix` or `--dst-prefix` to set these to * other arbitrary values. * * We strip the default and mnemonic prefixes, and trust the user knows what * they're doing in the other cases. * * @param string Path to strip. * @return string Stripped path. */ public static function stripGitPathPrefix($path) { static $regex; if ($regex === null) { $prefixes = array( // These are the defaults. 'a/', 'b/', // These show up when you set "diff.mnemonicprefix". 'i/', 'c/', 'w/', 'o/', '1/', '2/', ); foreach ($prefixes as $key => $prefix) { $prefixes[$key] = preg_quote($prefix, '@'); } $regex = '@^('.implode('|', $prefixes).')@S'; } return preg_replace($regex, '', $path); } /** * Split the paths on a "diff --git" line into old and new paths. This * is difficult because they may be ambiguous if the files contain spaces. * * @param string Text from a diff line after "diff --git ". * @return pair Old and new paths. */ public static function splitGitDiffPaths($paths) { $matches = null; $paths = rtrim($paths, "\r\n"); $patterns = array( // Try quoted paths, used for unicode filenames or filenames with quotes. '@^(?P"(?:\\\\.|[^"\\\\]+)+") (?P"(?:\\\\.|[^"\\\\]+)+")$@', // Try paths without spaces. '@^(?P[^ ]+) (?P[^ ]+)$@', // Try paths with well-known prefixes. '@^(?P[abicwo12]/.*) (?P[abicwo12]/.*)$@', // Try the exact same string twice in a row separated by a space. // This can hit a false positive for moves from files like "old file old" // to "file", but such a case combined with custom diff prefixes is // incredibly obscure. '@^(?P.*) (?P\\1)$@', ); foreach ($patterns as $pattern) { if (preg_match($pattern, $paths, $matches)) { break; } } if (!$matches) { throw new Exception( "Input diff contains ambiguous line 'diff --git {$paths}'. This line ". "is ambiguous because there are spaces in the file names, so the ". "parser can not determine where the file names begin and end. To ". "resolve this ambiguity, use standard prefixes ('a/' and 'b/') when ". "generating diffs."); } $old = $matches['old']; $old = self::unescapeFilename($old); $old = self::stripGitPathPrefix($old); $new = $matches['new']; $new = self::unescapeFilename($new); $new = self::stripGitPathPrefix($new); return array($old, $new); } /** * Strip the header and footer off a `git-format-patch` diff. * * Returns a parseable normal diff and a textual commit message. */ private function stripGitFormatPatch($diff) { // We can parse this by splitting it into two pieces over and over again // along different section dividers: // // 1. Mail headers. // 2. ("\n\n") // 3. Mail body. // 4. ("---") // 5. Diff stat section. // 6. ("\n\n") // 7. Actual diff body. // 8. ("--") // 9. Patch footer. list($head, $tail) = preg_split('/^---$/m', $diff, 2); list($mail_headers, $mail_body) = explode("\n\n", $head, 2); list($body, $foot) = preg_split('/^-- ?$/m', $tail, 2); list($stat, $diff) = explode("\n\n", $body, 2); // Rebuild the commit message by putting the subject line back on top of it, // if we can find one. $matches = null; $pattern = '/^Subject: (?:\[PATCH\] )?(.*)$/mi'; if (preg_match($pattern, $mail_headers, $matches)) { $mail_body = $matches[1]."\n\n".$mail_body; $mail_body = rtrim($mail_body); } return array($mail_body, $diff); } } diff --git a/src/parser/__tests__/ArcanistDiffParserTestCase.php b/src/parser/__tests__/ArcanistDiffParserTestCase.php index 965468da..8e547a6e 100644 --- a/src/parser/__tests__/ArcanistDiffParserTestCase.php +++ b/src/parser/__tests__/ArcanistDiffParserTestCase.php @@ -1,674 +1,681 @@ parseDiff($root.$file); } } private function parseDiff($diff_file) { $contents = Filesystem::readFile($diff_file); $file = basename($diff_file); $parser = new ArcanistDiffParser(); $changes = $parser->parseDiff($contents); switch ($file) { case 'colorized.hggitdiff': $this->assertEqual(1, count($changes)); break; case 'basic-missing-both-newlines-plus.udiff': case 'basic-missing-both-newlines.udiff': case 'basic-missing-new-newline-plus.udiff': case 'basic-missing-new-newline.udiff': case 'basic-missing-old-newline-plus.udiff': case 'basic-missing-old-newline.udiff': $expect_old = strpos($file, '-old-') || strpos($file, '-both-'); $expect_new = strpos($file, '-new-') || strpos($file, '-both-'); $expect_two = strpos($file, '-plus'); $this->assertEqual(count($changes), $expect_two ? 2 : 1); $change = reset($changes); $this->assertTrue($change !== null); $hunks = $change->getHunks(); $this->assertEqual(1, count($hunks)); $hunk = reset($hunks); $this->assertEqual((bool)$expect_old, $hunk->getIsMissingOldNewline()); $this->assertEqual((bool)$expect_new, $hunk->getIsMissingNewNewline()); break; case 'basic-binary.udiff': $this->assertEqual(1, count($changes)); $change = reset($changes); $this->assertEqual( ArcanistDiffChangeType::FILE_BINARY, $change->getFileType()); break; case 'basic-multi-hunk.udiff': $this->assertEqual(1, count($changes)); $change = reset($changes); $hunks = $change->getHunks(); $this->assertEqual(4, count($hunks)); $this->assertEqual('right', $change->getCurrentPath()); $this->assertEqual('left', $change->getOldPath()); break; case 'basic-multi-hunk-content.svndiff': $this->assertEqual(1, count($changes)); $change = reset($changes); $hunks = $change->getHunks(); $this->assertEqual(2, count($hunks)); $there_is_a_literal_trailing_space_here = ' '; $corpus_0 = <<assertEqual( $corpus_0, $hunks[0]->getCorpus()); $this->assertEqual( $corpus_1, $hunks[1]->getCorpus()); break; case 'svn-ignore-whitespace-only.svndiff': $this->assertEqual(2, count($changes)); $hunks = reset($changes)->getHunks(); $this->assertEqual(0, count($hunks)); break; case 'svn-property-add.svndiff': $this->assertEqual(1, count($changes)); $change = reset($changes); $hunks = reset($changes)->getHunks(); $this->assertEqual(1, count($hunks)); $this->assertEqual( array( 'duck' => 'quack', ), $change->getNewProperties()); break; case 'svn-property-modify.svndiff': $this->assertEqual(2, count($changes)); $change = array_shift($changes); $this->assertEqual(0, count($change->getHunks())); $this->assertEqual( array( 'svn:ignore' => '*.phpz', ), $change->getOldProperties()); $this->assertEqual( array( 'svn:ignore' => '*.php', ), $change->getNewProperties()); $change = array_shift($changes); $this->assertEqual(0, count($change->getHunks())); $this->assertEqual( array( 'svn:special' => '*', ), $change->getOldProperties()); $this->assertEqual( array( 'svn:special' => 'moo', ), $change->getNewProperties()); break; case 'svn-property-delete.svndiff': $this->assertEqual(1, count($changes)); $change = reset($changes); $this->assertEqual(0, count($change->getHunks())); $this->assertEqual( $change->getOldProperties(), array( 'svn:special' => '*', )); $this->assertEqual( array( ), $change->getNewProperties()); break; case 'svn-property-merged.svndiff': $this->assertEqual(1, count($changes)); $change = reset($changes); $this->assertEqual(count($change->getHunks()), 0); $this->assertEqual( $change->getOldProperties(), array()); $this->assertEqual( $change->getNewProperties(), array()); break; case 'svn-property-merge.svndiff': $this->assertEqual(1, count($changes)); $change = reset($changes); $this->assertEqual(count($change->getHunks()), 0); $this->assertEqual( $change->getOldProperties(), array( )); $this->assertEqual( $change->getNewProperties(), array( 'svn:mergeinfo' => <<assertEqual(1, count($changes)); $change = reset($changes); $this->assertEqual(count($change->getHunks()), 0); $this->assertEqual( $change->getOldProperties(), array( )); $this->assertEqual( $change->getNewProperties(), array( 'svn:executable' => '*', )); break; case 'svn-binary-add.svndiff': $this->assertEqual(1, count($changes)); $change = reset($changes); $this->assertEqual( ArcanistDiffChangeType::FILE_BINARY, $change->getFileType()); $this->assertEqual(0, count($change->getHunks())); $this->assertEqual( array( 'svn:mime-type' => 'application/octet-stream', ), $change->getNewProperties()); break; case 'svn-binary-diff.svndiff': case 'svn-binary-diff-freebsd.svndiff': $this->assertEqual(1, count($changes)); $change = reset($changes); $this->assertEqual( ArcanistDiffChangeType::FILE_BINARY, $change->getFileType()); $this->assertEqual(count($change->getHunks()), 0); break; case 'git-delete-file.gitdiff': $this->assertEqual(1, count($changes)); $change = reset($changes); $this->assertEqual( ArcanistDiffChangeType::TYPE_DELETE, $change->getType()); $this->assertEqual( 'scripts/intern/test/testfile2', $change->getCurrentPath()); $this->assertEqual(1, count($change->getHunks())); break; case 'git-binary-change.gitdiff': $this->assertEqual(1, count($changes)); $change = reset($changes); $this->assertEqual( ArcanistDiffChangeType::FILE_BINARY, $change->getFileType()); $this->assertEqual(0, count($change->getHunks())); break; case 'git-filemode-change.gitdiff': $this->assertEqual(1, count($changes)); $change = reset($changes); $this->assertEqual(1, count($change->getHunks())); $this->assertEqual( array( 'unix:filemode' => '100644', ), $change->getOldProperties()); $this->assertEqual( array( 'unix:filemode' => '100755', ), $change->getNewProperties()); break; case 'git-filemode-change-only.gitdiff': $this->assertEqual(count($changes), 2); $change = reset($changes); $this->assertEqual(count($change->getHunks()), 0); $this->assertEqual( array( 'unix:filemode' => '100644', ), $change->getOldProperties()); $this->assertEqual( array( 'unix:filemode' => '100755', ), $change->getNewProperties()); break; case 'svn-empty-file.svndiff': $this->assertEqual(2, count($changes)); $change = array_shift($changes); $this->assertEqual(0, count($change->getHunks())); break; case 'git-ignore-whitespace-only.gitdiff': $this->assertEqual(count($changes), 2); $change = array_shift($changes); $this->assertEqual(count($change->getHunks()), 0); $this->assertEqual( $change->getOldPath(), 'scripts/intern/test/testfile2'); $this->assertEqual( $change->getCurrentPath(), 'scripts/intern/test/testfile2'); $change = array_shift($changes); $this->assertEqual(count($change->getHunks()), 1); $this->assertEqual( $change->getOldPath(), 'scripts/intern/test/testfile3'); $this->assertEqual( $change->getCurrentPath(), 'scripts/intern/test/testfile3'); break; case 'git-move.gitdiff': case 'git-move-edit.gitdiff': case 'git-move-plus.gitdiff': $extra_changeset = (bool)strpos($file, '-plus'); $has_hunk = (bool)strpos($file, '-edit'); $this->assertEqual($extra_changeset ? 3 : 2, count($changes)); $change = array_shift($changes); $this->assertEqual($has_hunk ? 1 : 0, count($change->getHunks())); $this->assertEqual( $change->getType(), ArcanistDiffChangeType::TYPE_MOVE_HERE); $target = $change; $change = array_shift($changes); $this->assertEqual(0, count($change->getHunks())); $this->assertEqual( ArcanistDiffChangeType::TYPE_MOVE_AWAY, $change->getType()); $this->assertEqual( $change->getCurrentPath(), $target->getOldPath()); $this->assertTrue( in_array($target->getCurrentPath(), $change->getAwayPaths())); break; case 'git-merge-header.gitdiff': $this->assertEqual(1, count($changes)); $change = reset($changes); $this->assertEqual( ArcanistDiffChangeType::TYPE_MESSAGE, $change->getType()); $this->assertEqual( '501f6d519703458471dbea6284ec5f49d1408598', $change->getCommitHash()); break; case 'git-new-file.gitdiff': $this->assertEqual(1, count($changes)); $change = reset($changes); $this->assertEqual( ArcanistDiffChangeType::TYPE_ADD, $change->getType()); break; case 'git-copy.gitdiff': $this->assertEqual(2, count($changes)); $change = array_shift($changes); $this->assertEqual(0, count($change->getHunks())); $this->assertEqual( ArcanistDiffChangeType::TYPE_COPY_HERE, $change->getType()); $this->assertEqual( 'flib/intern/widgets/ui/UIWidgetRSSBox.php', $change->getCurrentPath()); $change = array_shift($changes); $this->assertEqual(0, count($change->getHunks())); $this->assertEqual( ArcanistDiffChangeType::TYPE_COPY_AWAY, $change->getType()); $this->assertEqual( 'lib/display/intern/ui/widget/UIWidgetRSSBox.php', $change->getCurrentPath()); break; case 'git-copy-plus.gitdiff': $this->assertEqual(2, count($changes)); $change = array_shift($changes); $this->assertEqual(3, count($change->getHunks())); $this->assertEqual( ArcanistDiffChangeType::TYPE_COPY_HERE, $change->getType()); $this->assertEqual( 'flib/intern/widgets/ui/UIWidgetGraphConnect.php', $change->getCurrentPath()); $change = array_shift($changes); $this->assertEqual(0, count($change->getHunks())); $this->assertEqual( ArcanistDiffChangeType::TYPE_COPY_AWAY, $change->getType()); $this->assertEqual( 'lib/display/intern/ui/widget/UIWidgetLunchtime.php', $change->getCurrentPath()); break; case 'svn-property-multiline.svndiff': $this->assertEqual(1, count($changes)); $change = array_shift($changes); $this->assertEqual(0, count($change->getHunks())); $this->assertEqual( array( 'svn:ignore' => 'tags', ), $change->getOldProperties()); $this->assertEqual( array( 'svn:ignore' => "tags\nasdf\nlol\nwhat", ), $change->getNewProperties()); break; case 'git-empty-files.gitdiff': $this->assertEqual(2, count($changes)); while ($change = array_shift($changes)) { $this->assertEqual(0, count($change->getHunks())); } break; case 'git-mnemonicprefix.gitdiff': // Check parsing of diffs created with `diff.mnemonicprefix` // configuration option set to `true`. $this->assertEqual(1, count($changes)); $this->assertEqual(1, count(reset($changes)->getHunks())); break; case 'git-commit.gitdiff': case 'git-commit-logdecorate.gitdiff': $this->assertEqual(1, count($changes)); $change = reset($changes); $this->assertEqual( ArcanistDiffChangeType::TYPE_MESSAGE, $change->getType()); $this->assertEqual( '76e2f1339c298c748aa0b52030799ed202a6537b', $change->getCommitHash()); $this->assertEqual( <<. I tested most of these calls, but there were some that I didn't know how to reach, so if you are one of the owners of this code, please test your feature in my sandbox: www.ngao.devrs013.facebook.com @brosenthal, I removed some logic that was setting a disabled state on a UIActionButton, which is actually a no-op. Reviewed By: brosenthal Other Commenters: sparker, egiovanola Test Plan: www.ngao.devrs013.facebook.com Explicitly tested: * ads creation flow (add keyword) * ads manager (conversion tracking) * help center (create a discussion) * new user wizard (next step button) Revert: OK DiffCamp Revision: 94064 git-svn-id: svn+ssh://tubbs/svnroot/tfb/trunk/www@223593 2c7ba8d8 EOTEXT , $change->getMetadata('message')); break; case 'git-binary.gitdiff': $this->assertEqual(1, count($changes)); $change = reset($changes); $this->assertEqual( ArcanistDiffChangeType::TYPE_CHANGE, $change->getType()); $this->assertEqual( ArcanistDiffChangeType::FILE_BINARY, $change->getFileType()); break; case 'git-odd-filename.gitdiff': $this->assertEqual(2, count($changes)); $change = reset($changes); $this->assertEqual( 'old/'."\342\210\206".'.jpg', $change->getOldPath()); $this->assertEqual( 'new/'."\342\210\206".'.jpg', $change->getCurrentPath()); break; case 'hg-binary-change.hgdiff': case 'hg-solo-binary-change.hgdiff': $this->assertEqual(1, count($changes)); $change = reset($changes); $this->assertEqual( ArcanistDiffChangeType::TYPE_ADD, $change->getType()); $this->assertEqual( ArcanistDiffChangeType::FILE_BINARY, $change->getFileType()); break; case 'hg-binary-delete.hgdiff': $this->assertEqual(1, count($changes)); $change = reset($changes); $this->assertEqual( ArcanistDiffChangeType::TYPE_DELETE, $change->getType()); $this->assertEqual( ArcanistDiffChangeType::FILE_BINARY, $change->getFileType()); break; case 'git-replace-symlink.gitdiff': $this->assertEqual(1, count($changes)); $change = array_shift($changes); $this->assertEqual( ArcanistDiffChangeType::TYPE_CHANGE, $change->getType()); break; case 'svn-1.7-property-added.svndiff': $this->assertEqual(1, count($changes)); $change = head($changes); $new_properties = $change->getNewProperties(); $this->assertEqual(2, count($new_properties)); $this->assertEqual('*', idx($new_properties, 'svn:executable')); $this->assertEqual('text/html', idx($new_properties, 'svn:mime-type')); break; case 'hg-diff-range.hgdiff': $this->assertEqual(1, count($changes)); $change = array_shift($changes); $this->assertEqual( 'Test.java', $change->getOldPath()); $this->assertEqual( 'Test.java', $change->getCurrentPath()); break; case 'hg-patch.hgdiff': $this->assertEqual(1, count($changes)); break; case 'hg-patch-git.hgdiff': $this->assertEqual(1, count($changes)); break; case 'custom-prefixes.gitdiff': $this->assertEqual(1, count($changes)); $change = head($changes); $this->assertEqual( 'dst/file', $change->getCurrentPath()); break; case 'more-newlines.svndiff': $this->assertEqual(1, count($changes)); break; case 'suppress-blank-empty.gitdiff': $this->assertEqual(1, count($changes)); break; case 'svn-property-windows.svndiff': $this->assertEqual(1, count($changes)); break; case 'rcs-addline.rcsdiff': $this->assertEqual(1, count($changes)); $change = array_shift($changes); $this->assertEqual( ArcanistDiffChangeType::TYPE_CHANGE, $change->getType()); break; case 'rcs-deleteline.rcsdiff': $this->assertEqual(1, count($changes)); $change = array_shift($changes); $this->assertEqual( ArcanistDiffChangeType::TYPE_CHANGE, $change->getType()); break; case 'comment.svndiff': $this->assertEqual(1, count($changes)); $change = array_shift($changes); $this->assertEqual( ArcanistDiffChangeType::TYPE_CHANGE, $change->getType()); break; case 'svnlook-basics.svndiff': case 'svnlook-add.svndiff': case 'svnlook-delete.svndiff': case 'svnlook-copied.svndiff': $this->assertEqual(1, count($changes)); break; case 'git-format-patch.gitdiff': $this->assertEqual(2, count($changes)); $change = array_shift($changes); $this->assertEqual( ArcanistDiffChangeType::TYPE_MESSAGE, $change->getType()); $this->assertEqual('WIP', $change->getMetadata('message')); $change = array_shift($changes); $this->assertEqual( ArcanistDiffChangeType::TYPE_CHANGE, $change->getType()); break; + case 'svn-double-diff.svndiff': + $this->assertEqual(1, count($changes)); + + $change = array_shift($changes); + $hunks = $change->getHunks(); + $this->assertEqual(1, count($hunks)); + break; default: throw new Exception("No test block for diff file {$diff_file}."); break; } } public function testGitPrefixStripping() { static $tests = array( 'a/file.c' => 'file.c', 'b/file.c' => 'file.c', 'i/file.c' => 'file.c', 'c/file.c' => 'file.c', 'w/file.c' => 'file.c', 'o/file.c' => 'file.c', '1/file.c' => 'file.c', '2/file.c' => 'file.c', 'src/file.c' => 'src/file.c', 'file.c' => 'file.c', ); foreach ($tests as $input => $expect) { $this->assertEqual( $expect, ArcanistDiffParser::stripGitPathPrefix($input), "Strip git prefix from '{$input}'."); } } public function testGitPathSplitting() { static $tests = array( 'a/old.c b/new.c' => array('old.c', 'new.c'), "a/old.c b/new.c\n" => array('old.c', 'new.c'), "a/old.c b/new.c\r\n" => array('old.c', 'new.c'), 'old.c new.c' => array('old.c', 'new.c'), '1/old.c 2/new.c' => array('old.c', 'new.c'), '"a/\\"quotes1\\"" "b/\\"quotes2\\""' => array( '"quotes1"', '"quotes2"', ), '"a/\\"quotes and spaces1\\"" "b/\\"quotes and spaces2\\""' => array( '"quotes and spaces1"', '"quotes and spaces2"', ), '"a/\\342\\230\\2031" "b/\\342\\230\\2032"' => array( "\xE2\x98\x831", "\xE2\x98\x832", ), 'a/Core Data/old.c b/Core Data/new.c' => array( 'Core Data/old.c', 'Core Data/new.c', ), 'some file with spaces.c some file with spaces.c' => array( 'some file with spaces.c', 'some file with spaces.c', ), ); foreach ($tests as $input => $expect) { $result = ArcanistDiffParser::splitGitDiffPaths($input); $this->assertEqual( $expect, $result, "Split: {$input}"); } static $ambiguous = array( 'old file with spaces.c new file with spaces.c', ); foreach ($ambiguous as $input) { $caught = null; try { ArcanistDiffParser::splitGitDiffPaths($input); } catch (Exception $ex) { $caught = $ex; } $this->assertTrue( ($caught instanceof Exception), "Ambiguous: {$input}"); } } } diff --git a/src/parser/__tests__/diff/svn-double-diff.svndiff b/src/parser/__tests__/diff/svn-double-diff.svndiff new file mode 100644 index 00000000..7bf96277 --- /dev/null +++ b/src/parser/__tests__/diff/svn-double-diff.svndiff @@ -0,0 +1,16 @@ +Index: subdir/newfile +=================================================================== +--- subdir/newfile (revision 0) ++++ subdir/newfile (working copy) +@@ -0,0 +1,3 @@ ++apple ++banana ++cherry +Index: subdir/newfile +=================================================================== +--- subdir/newfile (revision 0) ++++ subdir/newfile (working copy) +@@ -0,0 +1,3 @@ ++apple ++banana ++cherry diff --git a/src/parser/diff/ArcanistDiffChange.php b/src/parser/diff/ArcanistDiffChange.php index 3b8c04fa..c5e8de7e 100644 --- a/src/parser/diff/ArcanistDiffChange.php +++ b/src/parser/diff/ArcanistDiffChange.php @@ -1,311 +1,316 @@ originalFileData = $original_file_data; return $this; } public function getOriginalFileData() { return $this->originalFileData; } public function setCurrentFileData($current_file_data) { $this->currentFileData = $current_file_data; return $this; } public function getCurrentFileData() { return $this->currentFileData; } public function toDictionary() { $hunks = array(); foreach ($this->hunks as $hunk) { $hunks[] = $hunk->toDictionary(); } return array( 'metadata' => $this->metadata, 'oldPath' => $this->oldPath, 'currentPath' => $this->currentPath, 'awayPaths' => $this->awayPaths, 'oldProperties' => $this->oldProperties, 'newProperties' => $this->newProperties, 'type' => $this->type, 'fileType' => $this->fileType, 'commitHash' => $this->commitHash, 'hunks' => $hunks, ); } public static function newFromDictionary(array $dict) { $hunks = array(); foreach ($dict['hunks'] as $hunk) { $hunks[] = ArcanistDiffHunk::newFromDictionary($hunk); } $obj = new ArcanistDiffChange(); $obj->metadata = $dict['metadata']; $obj->oldPath = $dict['oldPath']; $obj->currentPath = $dict['currentPath']; // TODO: The backend is shipping down some bogus data, e.g. diff 199453. // Should probably clean this up. $obj->awayPaths = nonempty($dict['awayPaths'], array()); $obj->oldProperties = nonempty($dict['oldProperties'], array()); $obj->newProperties = nonempty($dict['newProperties'], array()); $obj->type = $dict['type']; $obj->fileType = $dict['fileType']; $obj->commitHash = $dict['commitHash']; $obj->hunks = $hunks; return $obj; } public static function newFromConduit(array $dicts) { $changes = array(); foreach ($dicts as $dict) { $changes[] = self::newFromDictionary($dict); } return $changes; } public function getChangedLines($type) { $lines = array(); foreach ($this->hunks as $hunk) { $lines += $hunk->getChangedLines($type); } return $lines; } public function getAllMetadata() { return $this->metadata; } public function setMetadata($key, $value) { $this->metadata[$key] = $value; return $this; } public function getMetadata($key) { return idx($this->metadata, $key); } public function setCommitHash($hash) { $this->commitHash = $hash; return $this; } public function getCommitHash() { return $this->commitHash; } public function addAwayPath($path) { $this->awayPaths[] = $path; return $this; } public function getAwayPaths() { return $this->awayPaths; } public function setFileType($type) { $this->fileType = $type; return $this; } public function getFileType() { return $this->fileType; } public function setType($type) { $this->type = $type; return $this; } public function getType() { return $this->type; } public function setOldProperty($key, $value) { $this->oldProperties[$key] = $value; return $this; } public function setNewProperty($key, $value) { $this->newProperties[$key] = $value; return $this; } public function getOldProperties() { return $this->oldProperties; } public function getNewProperties() { return $this->newProperties; } public function setCurrentPath($path) { $this->currentPath = $this->filterPath($path); return $this; } public function getCurrentPath() { return $this->currentPath; } public function setOldPath($path) { $this->oldPath = $this->filterPath($path); return $this; } public function getOldPath() { return $this->oldPath; } public function addHunk(ArcanistDiffHunk $hunk) { $this->hunks[] = $hunk; return $this; } + public function dropHunks() { + $this->hunks = array(); + return $this; + } + public function getHunks() { return $this->hunks; } /** * @return array $old => array($new, ) */ public function buildLineMap() { $line_map = array(); $old = 1; $new = 1; foreach ($this->getHunks() as $hunk) { for ($n = $old; $n < $hunk->getOldOffset(); $n++) { $line_map[$n] = array($n + $new - $old); } $old = $hunk->getOldOffset(); $new = $hunk->getNewOffset(); $olds = array(); $news = array(); $lines = explode("\n", $hunk->getCorpus()); foreach ($lines as $line) { $type = substr($line, 0, 1); if ($type == '-' || $type == ' ') { $olds[] = $old; $old++; } if ($type == '+' || $type == ' ') { $news[] = $new; $new++; } if ($type == ' ' || $type == '') { $line_map += array_fill_keys($olds, $news); $olds = array(); $news = array(); } } } return $line_map; } public function convertToBinaryChange(ArcanistRepositoryAPI $api) { // Fill in the binary data from the working copy. $this->setOriginalFileData( $api->getOriginalFileData( $this->getOldPath())); $this->setCurrentFileData( $api->getCurrentFileData( $this->getCurrentPath())); $this->hunks = array(); $this->setFileType(ArcanistDiffChangeType::FILE_BINARY); return $this; } protected function filterPath($path) { if ($path == '/dev/null') { return null; } return $path; } public function renderTextSummary() { $type = $this->getType(); $file = $this->getFileType(); $char = ArcanistDiffChangeType::getSummaryCharacterForChangeType($type); $attr = ArcanistDiffChangeType::getShortNameForFileType($file); if ($attr) { $attr = '('.$attr.')'; } $summary = array(); $summary[] = sprintf( '%s %5.5s %s', $char, $attr, $this->getCurrentPath()); if (ArcanistDiffChangeType::isOldLocationChangeType($type)) { foreach ($this->getAwayPaths() as $path) { $summary[] = ' to: '.$path; } } if (ArcanistDiffChangeType::isNewLocationChangeType($type)) { $summary[] = ' from: '.$this->getOldPath(); } return implode("\n", $summary); } public function getSymlinkTarget() { if ($this->getFileType() != ArcanistDiffChangeType::FILE_SYMLINK) { throw new Exception('Not a symlink!'); } $hunks = $this->getHunks(); $hunk = reset($hunks); $corpus = $hunk->getCorpus(); $match = null; if (!preg_match('/^\+(?:link )?(.*)$/m', $corpus, $match)) { throw new Exception('Failed to extract link target!'); } return trim($match[1]); } public function setNeedsSyntheticGitHunks($needs_synthetic_git_hunks) { $this->needsSyntheticGitHunks = $needs_synthetic_git_hunks; return $this; } public function getNeedsSyntheticGitHunks() { return $this->needsSyntheticGitHunks; } } diff --git a/src/repository/api/ArcanistSubversionAPI.php b/src/repository/api/ArcanistSubversionAPI.php index a4114bd0..00366688 100644 --- a/src/repository/api/ArcanistSubversionAPI.php +++ b/src/repository/api/ArcanistSubversionAPI.php @@ -1,692 +1,695 @@ getPath()) as $parent) { $possible_svn_dir = Filesystem::resolvePath('.svn', $parent); if (Filesystem::pathExists($possible_svn_dir)) { $svn_dir = $possible_svn_dir; break; } } } return $svn_dir; } protected function buildLocalFuture(array $argv) { $argv[0] = 'svn '.$argv[0]; $future = newv('ExecFuture', $argv); $future->setCWD($this->getPath()); return $future; } protected function buildCommitRangeStatus() { // In SVN, the commit range is always "uncommitted changes", so these // statuses are equivalent. return $this->getUncommittedStatus(); } protected function buildUncommittedStatus() { return $this->getSVNStatus(); } public function getSVNBaseRevisions() { if ($this->svnBaseRevisions === null) { $this->getSVNStatus(); } return $this->svnBaseRevisions; } public function limitStatusToPaths(array $paths) { $this->statusPaths = $paths; return $this; } public function getSVNStatus($with_externals = false) { if ($this->svnStatus === null) { if ($this->statusPaths) { list($status) = $this->execxLocal( '--xml status %Ls', $this->statusPaths); } else { list($status) = $this->execxLocal('--xml status'); } $xml = new SimpleXMLElement($status); $externals = array(); $files = array(); foreach ($xml->target as $target) { $this->svnBaseRevisions = array(); foreach ($target->entry as $entry) { $path = (string)$entry['path']; // On Windows, we get paths with backslash directory separators here. // Normalize them to the format everything else expects and generates. if (phutil_is_windows()) { $path = str_replace(DIRECTORY_SEPARATOR, '/', $path); } $mask = 0; $props = (string)($entry->{'wc-status'}[0]['props']); $item = (string)($entry->{'wc-status'}[0]['item']); $base = (string)($entry->{'wc-status'}[0]['revision']); $this->svnBaseRevisions[$path] = $base; switch ($props) { case 'none': case 'normal': break; case 'modified': $mask |= self::FLAG_MODIFIED; break; default: throw new Exception("Unrecognized property status '{$props}'."); } $mask |= $this->parseSVNStatus($item); if ($item == 'external') { $externals[] = $path; } // This is new in or around Subversion 1.6. $tree_conflicts = ($entry->{'wc-status'}[0]['tree-conflicted']); if ((string)$tree_conflicts) { $mask |= self::FLAG_CONFLICT; } $files[$path] = $mask; } } foreach ($files as $path => $mask) { foreach ($externals as $external) { if (!strncmp($path.'/', $external.'/', strlen($external) + 1)) { $files[$path] |= self::FLAG_EXTERNALS; } } } $this->svnStatus = $files; } $status = $this->svnStatus; if (!$with_externals) { foreach ($status as $path => $mask) { if ($mask & ArcanistRepositoryAPI::FLAG_EXTERNALS) { unset($status[$path]); } } } return $status; } private function parseSVNStatus($item) { switch ($item) { case 'none': // We can get 'none' for property changes on a directory. case 'normal': return 0; case 'external': return self::FLAG_EXTERNALS; case 'unversioned': return self::FLAG_UNTRACKED; case 'obstructed': return self::FLAG_OBSTRUCTED; case 'missing': return self::FLAG_MISSING; case 'added': return self::FLAG_ADDED; case 'replaced': // This is the result of "svn rm"-ing a file, putting another one // in place of it, and then "svn add"-ing the new file. Just treat // this as equivalent to "modified". return self::FLAG_MODIFIED; case 'modified': return self::FLAG_MODIFIED; case 'deleted': return self::FLAG_DELETED; case 'conflicted': return self::FLAG_CONFLICT; case 'incomplete': return self::FLAG_INCOMPLETE; default: throw new Exception("Unrecognized item status '{$item}'."); } } public function addToCommit(array $paths) { $add = array_filter($paths, 'Filesystem::pathExists'); if ($add) { $this->execxLocal( 'add -- %Ls', $add); } if ($add != $paths) { $this->execxLocal( 'delete -- %Ls', array_diff($paths, $add)); } $this->svnStatus = null; } public function getSVNProperty($path, $property) { list($stdout) = execx( 'svn propget %s %s@', $property, $this->getPath($path)); return trim($stdout); } public function getSourceControlPath() { return idx($this->getSVNInfo('/'), 'URL'); } public function getSourceControlBaseRevision() { $info = $this->getSVNInfo('/'); return $info['URL'].'@'.$this->getSVNBaseRevisionNumber(); } public function getCanonicalRevisionName($string) { throw new ArcanistCapabilityNotSupportedException($this); } public function getSVNBaseRevisionNumber() { if ($this->svnBaseRevisionNumber) { return $this->svnBaseRevisionNumber; } $info = $this->getSVNInfo('/'); return $info['Revision']; } public function overrideSVNBaseRevisionNumber($effective_base_revision) { $this->svnBaseRevisionNumber = $effective_base_revision; return $this; } public function getBranchName() { $info = $this->getSVNInfo('/'); $repo_root = idx($info, 'Repository Root'); $repo_root_length = strlen($repo_root); $url = idx($info, 'URL'); if (substr($url, 0, $repo_root_length) == $repo_root) { return substr($url, $repo_root_length); } return 'svn'; } public function getRemoteURI() { return idx($this->getSVNInfo('/'), 'Repository Root'); } public function buildInfoFuture($path) { if ($path == '/') { // When the root of a working copy is referenced by a symlink and you // execute 'svn info' on that symlink, svn fails. This is a longstanding // bug in svn: // // See http://subversion.tigris.org/issues/show_bug.cgi?id=2305 // // To reproduce, do: // // $ ln -s working_copy working_link // $ svn info working_copy # ok // $ svn info working_link # fails // // Work around this by cd-ing into the directory before executing // 'svn info'. return $this->buildLocalFuture(array('info .')); } else { // Note: here and elsewhere we need to append "@" to the path because if // a file has a literal "@" in it, everything after that will be // interpreted as a revision. By appending "@" with no argument, SVN // parses it properly. return $this->buildLocalFuture(array('info %s@', $this->getPath($path))); } } public function buildDiffFuture($path) { + $root = phutil_get_library_root('arcanist'); + // The "--depth empty" flag prevents us from picking up changes in // children when we run 'diff' against a directory. Specifically, when a // user has added or modified some directory "example/", we want to return // ONLY changes to that directory when given it as a path. If we run // without "--depth empty", svn will give us changes to the directory // itself (such as property changes) and also give us changes to any // files within the directory (basically, implicit recursion). We don't - // want that, so prevent recursive diffing. - $root = phutil_get_library_root('arcanist'); + // want that, so prevent recursive diffing. This flag does not work if the + // directory is newly added (see T5555) so we need to filter the results + // out later as well. if (phutil_is_windows()) { // TODO: Provide a binary_safe_diff script for Windows. // TODO: Provide a diff command which can take lines of context somehow. return $this->buildLocalFuture( array( 'diff --depth empty %s', $path, )); } else { $diff_bin = $root.'/../scripts/repository/binary_safe_diff.sh'; $diff_cmd = Filesystem::resolvePath($diff_bin); return $this->buildLocalFuture( array( 'diff --depth empty --diff-cmd %s -x -U%d %s', $diff_cmd, $this->getDiffLinesOfContext(), $path, )); } } public function primeSVNInfoResult($path, $result) { $this->svnInfoRaw[$path] = $result; return $this; } public function primeSVNDiffResult($path, $result) { $this->svnDiffRaw[$path] = $result; return $this; } public function getSVNInfo($path) { if (empty($this->svnInfo[$path])) { if (empty($this->svnInfoRaw[$path])) { $this->svnInfoRaw[$path] = $this->buildInfoFuture($path)->resolve(); } list($err, $stdout) = $this->svnInfoRaw[$path]; if ($err) { throw new Exception( "Error #{$err} executing svn info against '{$path}'."); } // TODO: Hack for Windows. $stdout = str_replace("\r\n", "\n", $stdout); $patterns = array( '/^(URL): (\S+)$/m', '/^(Revision): (\d+)$/m', '/^(Last Changed Author): (\S+)$/m', '/^(Last Changed Rev): (\d+)$/m', '/^(Last Changed Date): (.+) \(.+\)$/m', '/^(Copied From URL): (\S+)$/m', '/^(Copied From Rev): (\d+)$/m', '/^(Repository Root): (\S+)$/m', '/^(Repository UUID): (\S+)$/m', '/^(Node Kind): (\S+)$/m', ); $result = array(); foreach ($patterns as $pattern) { $matches = null; if (preg_match($pattern, $stdout, $matches)) { $result[$matches[1]] = $matches[2]; } } if (isset($result['Last Changed Date'])) { $result['Last Changed Date'] = strtotime($result['Last Changed Date']); } if (empty($result)) { throw new Exception('Unable to parse SVN info.'); } $this->svnInfo[$path] = $result; } return $this->svnInfo[$path]; } public function getRawDiffText($path) { $status = $this->getSVNStatus(); if (!isset($status[$path])) { return null; } $status = $status[$path]; // Build meaningful diff text for "svn copy" operations. if ($status & ArcanistRepositoryAPI::FLAG_ADDED) { $info = $this->getSVNInfo($path); if (!empty($info['Copied From URL'])) { return $this->buildSyntheticAdditionDiff( $path, $info['Copied From URL'], $info['Copied From Rev']); } } // If we run "diff" on a binary file which doesn't have the "svn:mime-type" // of "application/octet-stream", `diff' will explode in a rain of // unhelpful hellfire as it tries to build a textual diff of the two // files. We just fix this inline since it's pretty unambiguous. // TODO: Move this to configuration? $matches = null; if (preg_match('/\.(gif|png|jpe?g|swf|pdf|ico)$/i', $path, $matches)) { // Check if the file is deleted first; SVN will complain if we try to // get properties of a deleted file. if ($status & ArcanistRepositoryAPI::FLAG_DELETED) { return <<getSVNProperty($path, 'svn:mime-type'); if ($mime != 'application/octet-stream') { execx( 'svn propset svn:mime-type application/octet-stream %s', self::escapeFileNameForSVN($this->getPath($path))); } } if (empty($this->svnDiffRaw[$path])) { $this->svnDiffRaw[$path] = $this->buildDiffFuture($path)->resolve(); } list($err, $stdout, $stderr) = $this->svnDiffRaw[$path]; // Note: GNU Diff returns 2 when SVN hands it binary files to diff and they // differ. This is not an error; it is documented behavior. But SVN isn't // happy about it. SVN will exit with code 1 and return the string below. if ($err != 0 && $stderr !== "svn: 'diff' returned 2\n") { throw new Exception( "svn diff returned unexpected error code: $err\n". "stdout: $stdout\n". "stderr: $stderr"); } if ($err == 0 && empty($stdout)) { // If there are no changes, 'diff' exits with no output, but that means // we can not distinguish between empty and unmodified files. Build a // synthetic "diff" without any changes in it. return $this->buildSyntheticUnchangedDiff($path); } return $stdout; } protected function buildSyntheticAdditionDiff($path, $source, $rev) { $type = $this->getSVNProperty($path, 'svn:mime-type'); if ($type == 'application/octet-stream') { return <<getPath($path))) { return null; } $data = Filesystem::readFile($this->getPath($path)); list($orig) = execx('svn cat %s@%s', $source, $rev); $src = new TempFile(); $dst = new TempFile(); Filesystem::writeFile($src, $orig); Filesystem::writeFile($dst, $data); list($err, $diff) = exec_manual( 'diff -L a/%s -L b/%s -U%d %s %s', str_replace($this->getSourceControlPath().'/', '', $source), $path, $this->getDiffLinesOfContext(), $src, $dst); if ($err == 1) { // 1 means there are differences. return <<buildSyntheticUnchangedDiff($path); } } protected function buildSyntheticUnchangedDiff($path) { $full_path = $this->getPath($path); if (is_dir($full_path)) { return null; } if (!file_exists($full_path)) { return null; } $data = Filesystem::readFile($full_path); $lines = explode("\n", $data); $len = count($lines); foreach ($lines as $key => $line) { $lines[$key] = ' '.$line; } $lines = implode("\n", $lines); return <<buildLocalFuture(array('list -R')); return new PhutilCallbackFilterIterator( new LinesOfALargeExecFuture($future), array($this, 'filterFiles')); } public function getChangedFiles($since_commit) { $url = ''; $match = null; if (preg_match('/(.*)@(.*)/', $since_commit, $match)) { list(, $url, $since_commit) = $match; } // TODO: Handle paths with newlines. list($stdout) = $this->execxLocal( '--xml diff --revision %s:HEAD --summarize %s', $since_commit, $url); $xml = new SimpleXMLElement($stdout); $return = array(); foreach ($xml->paths[0]->path as $path) { $return[(string)$path] = $this->parseSVNStatus($path['item']); } return $return; } public function filterFiles($path) { // NOTE: SVN uses '/' also on Windows. if ($path == '' || substr($path, -1) == '/') { return null; } return $path; } public function getBlame($path) { $blame = array(); list($stdout) = $this->execxLocal('blame %s', $path); $stdout = trim($stdout); if (!strlen($stdout)) { // Empty file. return $blame; } foreach (explode("\n", $stdout) as $line) { $m = array(); if (!preg_match('/^\s*(\d+)\s+(\S+)/', $line, $m)) { throw new Exception("Bad blame? `{$line}'"); } $revision = $m[1]; $author = $m[2]; $blame[] = array($author, $revision); } return $blame; } public function getOriginalFileData($path) { // SVN issues warnings for nonexistent paths, directories, etc., but still // returns no error code. However, for new paths in the working copy it // fails. Assume that failure means the original file does not exist. list($err, $stdout) = $this->execManualLocal('cat %s@', $path); if ($err) { return null; } return $stdout; } public function getCurrentFileData($path) { $full_path = $this->getPath($path); if (Filesystem::pathExists($full_path)) { return Filesystem::readFile($full_path); } return null; } public function getRepositoryUUID() { $info = $this->getSVNInfo('/'); return $info['Repository UUID']; } public function getLocalCommitInformation() { return null; } public function isHistoryDefaultImmutable() { return true; } public function supportsAmend() { return false; } public function supportsCommitRanges() { return false; } public function supportsLocalCommits() { return false; } public function hasLocalCommit($commit) { return false; } public function getWorkingCopyRevision() { return $this->getSourceControlBaseRevision(); } public function supportsLocalBranchMerge() { return false; } public function getFinalizedRevisionMessage() { // In other VCSes we give push instructions here, but it never makes sense // in SVN. return 'Done.'; } public function loadWorkingCopyDifferentialRevisions( ConduitClient $conduit, array $query) { // We don't have much to go on in SVN, look for revisions that came from // this directory and belong to the same project. $project = $this->getWorkingCopyIdentity()->getProjectID(); if (!$project) { return array(); } $results = $conduit->callMethodSynchronous( 'differential.query', $query + array( 'arcanistProjects' => array($project), )); foreach ($results as $key => $result) { if ($result['sourcePath'] != $this->getPath()) { unset($results[$key]); } } foreach ($results as $key => $result) { $results[$key]['why'] = 'Matching arcanist project name and working copy directory path.'; } return $results; } public function updateWorkingCopy() { $this->execxLocal('up'); } public static function escapeFileNamesForSVN(array $files) { foreach ($files as $k => $file) { $files[$k] = self::escapeFileNameForSVN($file); } return $files; } public static function escapeFileNameForSVN($file) { // SVN interprets "x@1" as meaning "file x at revision 1", which is not // intended for files named "sprite@2x.png" or similar. For files with an // "@" in their names, escape them by adding "@" at the end, which SVN // interprets as "at the working copy revision". There is a special case // where ".@" means "fail with an error" instead of ". at the working copy // revision", so avoid escaping "." into ".@". if (strpos($file, '@') !== false) { $file = $file.'@'; } return $file; } }