1<?php 2 3/** 4 * Sends changes from your working copy to Differential for code review. 5 * 6 * @task lintunit Lint and Unit Tests 7 * @task message Commit and Update Messages 8 * @task diffspec Diff Specification 9 * @task diffprop Diff Properties 10 */ 11final class ArcanistDiffWorkflow extends ArcanistWorkflow { 12 13 private $console; 14 private $hasWarnedExternals = false; 15 private $unresolvedLint; 16 private $testResults; 17 private $diffID; 18 private $revisionID; 19 private $diffPropertyFutures = array(); 20 private $commitMessageFromRevision; 21 private $hitAutotargets; 22 private $revisionTransactions; 23 private $revisionIsDraft; 24 25 const STAGING_PUSHED = 'pushed'; 26 const STAGING_USER_SKIP = 'user.skip'; 27 const STAGING_DIFF_RAW = 'diff.raw'; 28 const STAGING_REPOSITORY_UNKNOWN = 'repository.unknown'; 29 const STAGING_REPOSITORY_UNAVAILABLE = 'repository.unavailable'; 30 const STAGING_REPOSITORY_UNSUPPORTED = 'repository.unsupported'; 31 const STAGING_REPOSITORY_UNCONFIGURED = 'repository.unconfigured'; 32 const STAGING_CLIENT_UNSUPPORTED = 'client.unsupported'; 33 34 public function getWorkflowName() { 35 return 'diff'; 36 } 37 38 public function getCommandSynopses() { 39 return phutil_console_format(<<<EOTEXT 40 **diff** [__paths__] (svn) 41 **diff** [__commit__] (git, hg) 42EOTEXT 43 ); 44 } 45 46 public function getCommandHelp() { 47 return phutil_console_format(<<<EOTEXT 48 Supports: git, svn, hg 49 Generate a Differential diff or revision from local changes. 50 51 Under git and mercurial, you can specify a commit (like __HEAD^^^__ 52 or __master__) and Differential will generate a diff against the 53 merge base of that commit and your current working directory parent. 54 55 Under svn, you can choose to include only some of the modified files 56 in the working copy in the diff by specifying their paths. If you 57 omit paths, all changes are included in the diff. 58EOTEXT 59 ); 60 } 61 62 public function requiresWorkingCopy() { 63 return !$this->isRawDiffSource(); 64 } 65 66 public function requiresConduit() { 67 return true; 68 } 69 70 public function requiresAuthentication() { 71 return true; 72 } 73 74 public function requiresRepositoryAPI() { 75 if (!$this->isRawDiffSource()) { 76 return true; 77 } 78 79 return false; 80 } 81 82 public function getDiffID() { 83 return $this->diffID; 84 } 85 86 public function getArguments() { 87 $arguments = array( 88 'message' => array( 89 'short' => 'm', 90 'param' => 'message', 91 'help' => pht( 92 'When updating a revision, use the specified message instead of '. 93 'prompting.'), 94 ), 95 'message-file' => array( 96 'short' => 'F', 97 'param' => 'file', 98 'paramtype' => 'file', 99 'help' => pht( 100 'When creating a revision, read revision information '. 101 'from this file.'), 102 ), 103 'edit' => array( 104 'supports' => array( 105 'git', 106 'hg', 107 ), 108 'nosupport' => array( 109 'svn' => pht('Edit revisions via the web interface when using SVN.'), 110 ), 111 'help' => pht( 112 'When updating a revision under git, edit revision information '. 113 'before updating.'), 114 ), 115 'raw' => array( 116 'help' => pht( 117 'Read diff from stdin, not from the working copy. This disables '. 118 'many Arcanist/Phabricator features which depend on having access '. 119 'to the working copy.'), 120 'conflicts' => array( 121 'apply-patches' => pht('%s disables lint.', '--raw'), 122 'never-apply-patches' => pht('%s disables lint.', '--raw'), 123 124 'create' => pht( 125 '%s and %s both need stdin. Use %s.', 126 '--raw', 127 '--create', 128 '--raw-command'), 129 'edit' => pht( 130 '%s and %s both need stdin. Use %s.', 131 '--raw', 132 '--edit', 133 '--raw-command'), 134 'raw-command' => null, 135 ), 136 ), 137 'raw-command' => array( 138 'param' => 'command', 139 'help' => pht( 140 'Generate diff by executing a specified command, not from the '. 141 'working copy. This disables many Arcanist/Phabricator features '. 142 'which depend on having access to the working copy.'), 143 'conflicts' => array( 144 'apply-patches' => pht('%s disables lint.', '--raw-command'), 145 'never-apply-patches' => pht('%s disables lint.', '--raw-command'), 146 ), 147 ), 148 'create' => array( 149 'help' => pht('Always create a new revision.'), 150 'conflicts' => array( 151 'edit' => pht( 152 '%s can not be used with %s.', 153 '--create', 154 '--edit'), 155 'only' => pht( 156 '%s can not be used with %s.', 157 '--create', 158 '--only'), 159 'update' => pht( 160 '%s can not be used with %s.', 161 '--create', 162 '--update'), 163 ), 164 ), 165 'update' => array( 166 'param' => 'revision_id', 167 'help' => pht('Always update a specific revision.'), 168 ), 169 'draft' => array( 170 'help' => pht( 171 'Create a draft revision so you can look over your changes before '. 172 'involving anyone else. Other users will not be notified about the '. 173 'revision until you later use "Request Review" to publish it. You '. 174 'can still share the draft by giving someone the link.'), 175 'conflicts' => array( 176 'edit' => null, 177 'only' => null, 178 'update' => null, 179 ), 180 ), 181 'nounit' => array( 182 'help' => pht('Do not run unit tests.'), 183 ), 184 'nolint' => array( 185 'help' => pht('Do not run lint.'), 186 'conflicts' => array( 187 'apply-patches' => pht('%s suppresses lint.', '--nolint'), 188 'never-apply-patches' => pht('%s suppresses lint.', '--nolint'), 189 ), 190 ), 191 'only' => array( 192 'help' => pht( 193 'Instead of creating or updating a revision, only create a diff, '. 194 'which you may later attach to a revision.'), 195 'conflicts' => array( 196 'edit' => pht('%s does affect revisions.', '--only'), 197 'message' => pht('%s does not update any revision.', '--only'), 198 ), 199 ), 200 'allow-untracked' => array( 201 'help' => pht('Skip checks for untracked files in the working copy.'), 202 ), 203 'apply-patches' => array( 204 'help' => pht( 205 'Apply patches suggested by lint to the working copy without '. 206 'prompting.'), 207 'conflicts' => array( 208 'never-apply-patches' => true, 209 ), 210 'passthru' => array( 211 'lint' => true, 212 ), 213 ), 214 'never-apply-patches' => array( 215 'help' => pht('Never apply patches suggested by lint.'), 216 'conflicts' => array( 217 'apply-patches' => true, 218 ), 219 'passthru' => array( 220 'lint' => true, 221 ), 222 ), 223 'amend-all' => array( 224 'help' => pht( 225 'When linting git repositories, amend HEAD with all patches '. 226 'suggested by lint without prompting.'), 227 'passthru' => array( 228 'lint' => true, 229 ), 230 ), 231 'amend-autofixes' => array( 232 'help' => pht( 233 'When linting git repositories, amend HEAD with autofix '. 234 'patches suggested by lint without prompting.'), 235 'passthru' => array( 236 'lint' => true, 237 ), 238 ), 239 'add-all' => array( 240 'short' => 'a', 241 'help' => pht( 242 'Automatically add all unstaged and uncommitted '. 243 'files to the commit.'), 244 ), 245 'json' => array( 246 'help' => pht( 247 'Emit machine-readable JSON. EXPERIMENTAL! Probably does not work!'), 248 ), 249 'no-amend' => array( 250 'help' => pht( 251 'Never amend commits in the working copy with lint patches.'), 252 ), 253 'uncommitted' => array( 254 'help' => pht('Suppress warning about uncommitted changes.'), 255 'supports' => array( 256 'hg', 257 ), 258 ), 259 'verbatim' => array( 260 'help' => pht( 261 'When creating a revision, try to use the working copy commit '. 262 'message verbatim, without prompting to edit it. When updating a '. 263 'revision, update some fields from the local commit message.'), 264 'supports' => array( 265 'hg', 266 'git', 267 ), 268 'conflicts' => array( 269 'update' => true, 270 'only' => true, 271 'raw' => true, 272 'raw-command' => true, 273 'message-file' => true, 274 ), 275 ), 276 'reviewers' => array( 277 'param' => 'usernames', 278 'help' => pht('When creating a revision, add reviewers.'), 279 'conflicts' => array( 280 'only' => true, 281 'update' => true, 282 ), 283 ), 284 'cc' => array( 285 'param' => 'usernames', 286 'help' => pht('When creating a revision, add CCs.'), 287 'conflicts' => array( 288 'only' => true, 289 'update' => true, 290 ), 291 ), 292 'skip-binaries' => array( 293 'help' => pht('Do not upload binaries (like images).'), 294 ), 295 'skip-staging' => array( 296 'help' => pht('Do not copy changes to the staging area.'), 297 ), 298 'base' => array( 299 'param' => 'rules', 300 'help' => pht('Additional rules for determining base revision.'), 301 'nosupport' => array( 302 'svn' => pht('Subversion does not use base commits.'), 303 ), 304 'supports' => array('git', 'hg'), 305 ), 306 'coverage' => array( 307 'help' => pht('Always enable coverage information.'), 308 'conflicts' => array( 309 'no-coverage' => null, 310 ), 311 'passthru' => array( 312 'unit' => true, 313 ), 314 ), 315 'no-coverage' => array( 316 'help' => pht('Always disable coverage information.'), 317 'passthru' => array( 318 'unit' => true, 319 ), 320 ), 321 'browse' => array( 322 'help' => pht( 323 'After creating a diff or revision, open it in a web browser.'), 324 ), 325 '*' => 'paths', 326 'head' => array( 327 'param' => 'commit', 328 'help' => pht( 329 'Specify the end of the commit range. This disables many '. 330 'Arcanist/Phabricator features which depend on having access to '. 331 'the working copy.'), 332 'supports' => array('git'), 333 'nosupport' => array( 334 'svn' => pht('Subversion does not support commit ranges.'), 335 'hg' => pht('Mercurial does not support %s yet.', '--head'), 336 ), 337 ), 338 ); 339 340 return $arguments; 341 } 342 343 public function isRawDiffSource() { 344 return $this->getArgument('raw') || $this->getArgument('raw-command'); 345 } 346 347 public function run() { 348 $this->console = PhutilConsole::getConsole(); 349 350 $this->runRepositoryAPISetup(); 351 $this->runDiffSetupBasics(); 352 353 $commit_message = $this->buildCommitMessage(); 354 355 $this->dispatchEvent( 356 ArcanistEventType::TYPE_DIFF_DIDBUILDMESSAGE, 357 array( 358 'message' => $commit_message, 359 )); 360 361 if (!$this->shouldOnlyCreateDiff()) { 362 $revision = $this->buildRevisionFromCommitMessage($commit_message); 363 } 364 365 $data = $this->runLintUnit(); 366 367 $lint_result = $data['lintResult']; 368 $this->unresolvedLint = $data['unresolvedLint']; 369 $unit_result = $data['unitResult']; 370 $this->testResults = $data['testResults']; 371 372 $changes = $this->generateChanges(); 373 if (!$changes) { 374 throw new ArcanistUsageException( 375 pht('There are no changes to generate a diff from!')); 376 } 377 378 $diff_spec = array( 379 'changes' => mpull($changes, 'toDictionary'), 380 'lintStatus' => $this->getLintStatus($lint_result), 381 'unitStatus' => $this->getUnitStatus($unit_result), 382 ) + $this->buildDiffSpecification(); 383 384 $conduit = $this->getConduit(); 385 $diff_info = $conduit->callMethodSynchronous( 386 'differential.creatediff', 387 $diff_spec); 388 389 $this->diffID = $diff_info['diffid']; 390 391 $event = $this->dispatchEvent( 392 ArcanistEventType::TYPE_DIFF_WASCREATED, 393 array( 394 'diffID' => $diff_info['diffid'], 395 'lintResult' => $lint_result, 396 'unitResult' => $unit_result, 397 )); 398 399 $this->submitChangesToStagingArea($this->diffID); 400 401 $phid = idx($diff_info, 'phid'); 402 if ($phid) { 403 $this->hitAutotargets = $this->updateAutotargets( 404 $phid, 405 $unit_result); 406 } 407 408 $this->updateLintDiffProperty(); 409 $this->updateUnitDiffProperty(); 410 $this->updateLocalDiffProperty(); 411 $this->updateOntoDiffProperty(); 412 $this->resolveDiffPropertyUpdates(); 413 414 $output_json = $this->getArgument('json'); 415 416 if ($this->shouldOnlyCreateDiff()) { 417 if (!$output_json) { 418 echo phutil_console_format( 419 "%s\n **%s** __%s__\n\n", 420 pht('Created a new Differential diff:'), 421 pht('Diff URI:'), 422 $diff_info['uri']); 423 } else { 424 $human = ob_get_clean(); 425 echo json_encode(array( 426 'diffURI' => $diff_info['uri'], 427 'diffID' => $this->getDiffID(), 428 'human' => $human, 429 ))."\n"; 430 ob_start(); 431 } 432 433 if ($this->shouldOpenCreatedObjectsInBrowser()) { 434 $this->openURIsInBrowser(array($diff_info['uri'])); 435 } 436 } else { 437 $is_draft = $this->getArgument('draft'); 438 $revision['diffid'] = $this->getDiffID(); 439 440 if ($commit_message->getRevisionID()) { 441 if ($is_draft) { 442 // TODO: In at least some cases, we could raise this earlier in the 443 // workflow to save users some time before the workflow aborts. 444 if ($this->revisionIsDraft) { 445 $this->writeWarn( 446 pht('ALREADY A DRAFT'), 447 pht( 448 'You are updating a revision ("%s") with the "--draft" flag, '. 449 'but this revision is already a draft. You only need to '. 450 'provide the "--draft" flag when creating a revision. Draft '. 451 'revisions are not published until you explicitly request '. 452 'review from the web UI.', 453 $commit_message->getRevisionMonogram())); 454 } else { 455 throw new ArcanistUsageException( 456 pht( 457 'You are updating a revision ("%s") with the "--draft" flag, '. 458 'but this revision has already been published for review. '. 459 'You can not turn a revision back into a draft once it has '. 460 'been published.', 461 $commit_message->getRevisionMonogram())); 462 } 463 } 464 465 $result = $conduit->callMethodSynchronous( 466 'differential.updaterevision', 467 $revision); 468 469 foreach (array('edit-messages.json', 'update-messages.json') as $file) { 470 $messages = $this->readScratchJSONFile($file); 471 unset($messages[$revision['id']]); 472 $this->writeScratchJSONFile($file, $messages); 473 } 474 475 $result_uri = $result['uri']; 476 $result_id = $result['revisionid']; 477 478 echo pht('Updated an existing Differential revision:')."\n"; 479 } else { 480 // NOTE: We're either using "differential.revision.edit" (preferred) 481 // if we can, or falling back to "differential.createrevision" 482 // (the older way) if not. 483 484 $xactions = $this->revisionTransactions; 485 if ($xactions) { 486 $xactions[] = array( 487 'type' => 'update', 488 'value' => $diff_info['phid'], 489 ); 490 491 if ($is_draft) { 492 $xactions[] = array( 493 'type' => 'draft', 494 'value' => true, 495 ); 496 } 497 498 $result = $conduit->callMethodSynchronous( 499 'differential.revision.edit', 500 array( 501 'transactions' => $xactions, 502 )); 503 504 $result_id = idxv($result, array('object', 'id')); 505 if (!$result_id) { 506 throw new Exception( 507 pht( 508 'Expected a revision ID to be returned by '. 509 '"differential.revision.edit".')); 510 } 511 512 // TODO: This is hacky, but we don't currently receive a URI back 513 // from "differential.revision.edit". 514 $result_uri = id(new PhutilURI($this->getConduitURI())) 515 ->setPath('/D'.$result_id); 516 } else { 517 if ($is_draft) { 518 throw new ArcanistUsageException( 519 pht( 520 'You have specified "--draft", but the version of Phabricator '. 521 'on the server is too old to support draft revisions. Omit '. 522 'the flag or upgrade the server software.')); 523 } 524 525 $revision = $this->dispatchWillCreateRevisionEvent($revision); 526 527 $result = $conduit->callMethodSynchronous( 528 'differential.createrevision', 529 $revision); 530 531 $result_uri = $result['uri']; 532 $result_id = $result['revisionid']; 533 } 534 535 $revised_message = $conduit->callMethodSynchronous( 536 'differential.getcommitmessage', 537 array( 538 'revision_id' => $result_id, 539 )); 540 541 if ($this->shouldAmend()) { 542 $repository_api = $this->getRepositoryAPI(); 543 if ($repository_api->supportsAmend()) { 544 echo pht('Updating commit message...')."\n"; 545 $repository_api->amendCommit($revised_message); 546 } else { 547 echo pht( 548 'Commit message was not amended. Amending commit message is '. 549 'only supported in git and hg (version 2.2 or newer)'); 550 } 551 } 552 553 echo pht('Created a new Differential revision:')."\n"; 554 } 555 556 $uri = $result_uri; 557 echo phutil_console_format( 558 " **%s** __%s__\n\n", 559 pht('Revision URI:'), 560 $uri); 561 562 if ($this->shouldOpenCreatedObjectsInBrowser()) { 563 $this->openURIsInBrowser(array($uri)); 564 } 565 } 566 567 echo pht('Included changes:')."\n"; 568 foreach ($changes as $change) { 569 echo ' '.$change->renderTextSummary()."\n"; 570 } 571 572 if ($output_json) { 573 ob_get_clean(); 574 } 575 576 $this->removeScratchFile('create-message'); 577 578 return 0; 579 } 580 581 private function runRepositoryAPISetup() { 582 if (!$this->requiresRepositoryAPI()) { 583 return; 584 } 585 586 $repository_api = $this->getRepositoryAPI(); 587 588 $repository_api->setBaseCommitArgumentRules( 589 $this->getArgument('base', '')); 590 591 if ($repository_api->supportsCommitRanges()) { 592 $this->parseBaseCommitArgument($this->getArgument('paths')); 593 } 594 595 $head_commit = $this->getArgument('head'); 596 if ($head_commit !== null) { 597 $repository_api->setHeadCommit($head_commit); 598 } 599 600 } 601 602 private function runDiffSetupBasics() { 603 $output_json = $this->getArgument('json'); 604 if ($output_json) { 605 // TODO: We should move this to a higher-level and put an indirection 606 // layer between echoing stuff and stdout. 607 ob_start(); 608 } 609 610 if ($this->requiresWorkingCopy()) { 611 $repository_api = $this->getRepositoryAPI(); 612 if ($this->getArgument('add-all')) { 613 $this->setCommitMode(self::COMMIT_ENABLE); 614 } else if ($this->getArgument('uncommitted')) { 615 $this->setCommitMode(self::COMMIT_DISABLE); 616 } else { 617 $this->setCommitMode(self::COMMIT_ALLOW); 618 } 619 if ($repository_api instanceof ArcanistSubversionAPI) { 620 $repository_api->limitStatusToPaths($this->getArgument('paths')); 621 } 622 if (!$this->getArgument('head')) { 623 $this->requireCleanWorkingCopy(); 624 } 625 } 626 627 $this->dispatchEvent( 628 ArcanistEventType::TYPE_DIFF_DIDCOLLECTCHANGES, 629 array()); 630 } 631 632 private function buildRevisionFromCommitMessage( 633 ArcanistDifferentialCommitMessage $message) { 634 635 $conduit = $this->getConduit(); 636 637 $revision_id = $message->getRevisionID(); 638 $revision = array( 639 'fields' => $message->getFields(), 640 ); 641 $xactions = $message->getTransactions(); 642 643 if ($revision_id) { 644 645 // With '--verbatim', pass the (possibly modified) local fields. This 646 // allows the user to edit some fields (like "title" and "summary") 647 // locally without '--edit' and have changes automatically synchronized. 648 // Without '--verbatim', we do not update the revision to reflect local 649 // commit message changes. 650 if ($this->getArgument('verbatim')) { 651 $use_fields = $message->getFields(); 652 } else { 653 $use_fields = array(); 654 } 655 656 $should_edit = $this->getArgument('edit'); 657 $edit_messages = $this->readScratchJSONFile('edit-messages.json'); 658 $remote_corpus = idx($edit_messages, $revision_id); 659 660 if (!$should_edit || !$remote_corpus || $use_fields) { 661 if ($this->commitMessageFromRevision) { 662 $remote_corpus = $this->commitMessageFromRevision; 663 } else { 664 $remote_corpus = $conduit->callMethodSynchronous( 665 'differential.getcommitmessage', 666 array( 667 'revision_id' => $revision_id, 668 'edit' => 'edit', 669 'fields' => $use_fields, 670 )); 671 } 672 } 673 674 if ($should_edit) { 675 $edited = $this->newInteractiveEditor($remote_corpus) 676 ->setName('differential-edit-revision-info') 677 ->editInteractively(); 678 if ($edited != $remote_corpus) { 679 $remote_corpus = $edited; 680 $edit_messages[$revision_id] = $remote_corpus; 681 $this->writeScratchJSONFile('edit-messages.json', $edit_messages); 682 } 683 } 684 685 if ($this->commitMessageFromRevision == $remote_corpus) { 686 $new_message = $message; 687 } else { 688 $remote_corpus = ArcanistCommentRemover::removeComments( 689 $remote_corpus); 690 $new_message = ArcanistDifferentialCommitMessage::newFromRawCorpus( 691 $remote_corpus); 692 $new_message->pullDataFromConduit($conduit); 693 } 694 695 $revision['fields'] = $new_message->getFields(); 696 $xactions = $new_message->getTransactions(); 697 698 $revision['id'] = $revision_id; 699 $this->revisionID = $revision_id; 700 701 $revision['message'] = $this->getArgument('message'); 702 if (!strlen($revision['message'])) { 703 $update_messages = $this->readScratchJSONFile('update-messages.json'); 704 705 $update_messages[$revision_id] = $this->getUpdateMessage( 706 $revision['fields'], 707 idx($update_messages, $revision_id)); 708 709 $revision['message'] = ArcanistCommentRemover::removeComments( 710 $update_messages[$revision_id]); 711 if (!strlen(trim($revision['message']))) { 712 throw new ArcanistUserAbortException(); 713 } 714 715 $this->writeScratchJSONFile('update-messages.json', $update_messages); 716 } 717 } 718 719 $this->revisionTransactions = $xactions; 720 721 return $revision; 722 } 723 724 protected function shouldOnlyCreateDiff() { 725 if ($this->getArgument('create')) { 726 return false; 727 } 728 729 if ($this->getArgument('update')) { 730 return false; 731 } 732 733 if ($this->isRawDiffSource()) { 734 return true; 735 } 736 737 return $this->getArgument('only'); 738 } 739 740 private function generateAffectedPaths() { 741 if ($this->isRawDiffSource()) { 742 return array(); 743 } 744 745 $repository_api = $this->getRepositoryAPI(); 746 if ($repository_api instanceof ArcanistSubversionAPI) { 747 $file_list = new FileList($this->getArgument('paths', array())); 748 $paths = $repository_api->getSVNStatus($externals = true); 749 foreach ($paths as $path => $mask) { 750 if (!$file_list->contains($repository_api->getPath($path), true)) { 751 unset($paths[$path]); 752 } 753 } 754 755 $warn_externals = array(); 756 foreach ($paths as $path => $mask) { 757 $any_mod = ($mask & ArcanistRepositoryAPI::FLAG_ADDED) || 758 ($mask & ArcanistRepositoryAPI::FLAG_MODIFIED) || 759 ($mask & ArcanistRepositoryAPI::FLAG_DELETED); 760 if ($mask & ArcanistRepositoryAPI::FLAG_EXTERNALS) { 761 unset($paths[$path]); 762 if ($any_mod) { 763 $warn_externals[] = $path; 764 } 765 } 766 } 767 768 if ($warn_externals && !$this->hasWarnedExternals) { 769 echo phutil_console_format( 770 "%s\n\n%s\n\n", 771 pht( 772 "The working copy includes changes to '%s' paths. These ". 773 "changes will not be included in the diff because SVN can not ". 774 "commit 'svn:externals' changes alongside normal changes.", 775 'svn:externals'), 776 pht( 777 "Modified '%s' files:", 778 'svn:externals'), 779 phutil_console_wrap(implode("\n", $warn_externals), 8)); 780 $prompt = pht('Generate a diff (with just local changes) anyway?'); 781 if (!phutil_console_confirm($prompt)) { 782 throw new ArcanistUserAbortException(); 783 } else { 784 $this->hasWarnedExternals = true; 785 } 786 } 787 788 } else { 789 $paths = $repository_api->getWorkingCopyStatus(); 790 } 791 792 foreach ($paths as $path => $mask) { 793 if ($mask & ArcanistRepositoryAPI::FLAG_UNTRACKED) { 794 unset($paths[$path]); 795 } 796 } 797 798 return $paths; 799 } 800 801 802 protected function generateChanges() { 803 $parser = $this->newDiffParser(); 804 805 $is_raw = $this->isRawDiffSource(); 806 if ($is_raw) { 807 808 if ($this->getArgument('raw')) { 809 fwrite(STDERR, pht('Reading diff from stdin...')."\n"); 810 $raw_diff = file_get_contents('php://stdin'); 811 } else if ($this->getArgument('raw-command')) { 812 list($raw_diff) = execx('%C', $this->getArgument('raw-command')); 813 } else { 814 throw new Exception(pht('Unknown raw diff source.')); 815 } 816 817 $changes = $parser->parseDiff($raw_diff); 818 foreach ($changes as $key => $change) { 819 // Remove "message" changes, e.g. from "git show". 820 if ($change->getType() == ArcanistDiffChangeType::TYPE_MESSAGE) { 821 unset($changes[$key]); 822 } 823 } 824 return $changes; 825 } 826 827 $repository_api = $this->getRepositoryAPI(); 828 829 if ($repository_api instanceof ArcanistSubversionAPI) { 830 $paths = $this->generateAffectedPaths(); 831 $this->primeSubversionWorkingCopyData($paths); 832 833 // Check to make sure the user is diffing from a consistent base revision. 834 // This is mostly just an abuse sanity check because it's silly to do this 835 // and makes the code more difficult to effectively review, but it also 836 // affects patches and makes them nonportable. 837 $bases = $repository_api->getSVNBaseRevisions(); 838 839 // Remove all files with baserev "0"; these files are new. 840 foreach ($bases as $path => $baserev) { 841 if ($bases[$path] <= 0) { 842 unset($bases[$path]); 843 } 844 } 845 846 if ($bases) { 847 $rev = reset($bases); 848 849 $revlist = array(); 850 foreach ($bases as $path => $baserev) { 851 $revlist[] = ' '.pht('Revision %s, %s', $baserev, $path); 852 } 853 $revlist = implode("\n", $revlist); 854 855 foreach ($bases as $path => $baserev) { 856 if ($baserev !== $rev) { 857 throw new ArcanistUsageException( 858 pht( 859 "Base revisions of changed paths are mismatched. Update all ". 860 "paths to the same base revision before creating a diff: ". 861 "\n\n%s", 862 $revlist)); 863 } 864 } 865 866 // If you have a change which affects several files, all of which are 867 // at a consistent base revision, treat that revision as the effective 868 // base revision. The use case here is that you made a change to some 869 // file, which updates it to HEAD, but want to be able to change it 870 // again without updating the entire working copy. This is a little 871 // sketchy but it arises in Facebook Ops workflows with config files and 872 // doesn't have any real material tradeoffs (e.g., these patches are 873 // perfectly applyable). 874 $repository_api->overrideSVNBaseRevisionNumber($rev); 875 } 876 877 $changes = $parser->parseSubversionDiff( 878 $repository_api, 879 $paths); 880 } else if ($repository_api instanceof ArcanistGitAPI) { 881 $diff = $repository_api->getFullGitDiff( 882 $repository_api->getBaseCommit(), 883 $repository_api->getHeadCommit()); 884 if (!strlen($diff)) { 885 throw new ArcanistUsageException( 886 pht('No changes found. (Did you specify the wrong commit range?)')); 887 } 888 $changes = $parser->parseDiff($diff); 889 } else if ($repository_api instanceof ArcanistMercurialAPI) { 890 $diff = $repository_api->getFullMercurialDiff(); 891 if (!strlen($diff)) { 892 throw new ArcanistUsageException( 893 pht('No changes found. (Did you specify the wrong commit range?)')); 894 } 895 $changes = $parser->parseDiff($diff); 896 } else { 897 throw new Exception(pht('Repository API is not supported.')); 898 } 899 900 $limit = 1024 * 1024 * 4; 901 foreach ($changes as $change) { 902 $size = 0; 903 foreach ($change->getHunks() as $hunk) { 904 $size += strlen($hunk->getCorpus()); 905 } 906 if ($size > $limit) { 907 $byte_warning = pht( 908 "Diff for '%s' with context is %s bytes in length. ". 909 "Generally, source changes should not be this large.", 910 $change->getCurrentPath(), 911 new PhutilNumber($size)); 912 if ($repository_api instanceof ArcanistSubversionAPI) { 913 throw new ArcanistUsageException( 914 $byte_warning.' '. 915 pht( 916 "If the file is not a text file, mark it as binary with:". 917 "\n\n $ %s\n", 918 'svn propset svn:mime-type application/octet-stream <filename>')); 919 } else { 920 $confirm = $byte_warning.' '.pht( 921 "If the file is not a text file, you can mark it 'binary'. ". 922 "Mark this file as 'binary' and continue?"); 923 if (phutil_console_confirm($confirm)) { 924 $change->convertToBinaryChange($repository_api); 925 } else { 926 throw new ArcanistUsageException( 927 pht('Aborted generation of gigantic diff.')); 928 } 929 } 930 } 931 } 932 933 $utf8_problems = array(); 934 foreach ($changes as $change) { 935 foreach ($change->getHunks() as $hunk) { 936 $corpus = $hunk->getCorpus(); 937 if (!phutil_is_utf8($corpus)) { 938 939 // If this corpus is heuristically binary, don't try to convert it. 940 // mb_check_encoding() and mb_convert_encoding() are both very very 941 // liberal about what they're willing to process. 942 $is_binary = ArcanistDiffUtils::isHeuristicBinaryFile($corpus); 943 if (!$is_binary) { 944 945 try { 946 $try_encoding = $this->getRepositoryEncoding(); 947 } catch (ConduitClientException $e) { 948 if ($e->getErrorCode() == 'ERR-BAD-ARCANIST-PROJECT') { 949 echo phutil_console_wrap( 950 pht('Lookup of encoding in arcanist project failed: %s', 951 $e->getMessage())."\n"); 952 } else { 953 throw $e; 954 } 955 } 956 957 if ($try_encoding) { 958 $corpus = phutil_utf8_convert($corpus, 'UTF-8', $try_encoding); 959 $name = $change->getCurrentPath(); 960 if (phutil_is_utf8($corpus)) { 961 $this->writeStatusMessage( 962 pht( 963 "Converted a '%s' hunk from '%s' to UTF-8.\n", 964 $name, 965 $try_encoding)); 966 $hunk->setCorpus($corpus); 967 continue; 968 } 969 } 970 } 971 $utf8_problems[] = $change; 972 break; 973 } 974 } 975 } 976 977 // If there are non-binary files which aren't valid UTF-8, warn the user 978 // and treat them as binary changes. See D327 for discussion of why Arcanist 979 // has this behavior. 980 if ($utf8_problems) { 981 $utf8_warning = 982 sprintf( 983 "%s\n\n%s\n\n %s\n", 984 pht( 985 'This diff includes %s file(s) which are not valid UTF-8 (they '. 986 'contain invalid byte sequences). You can either stop this '. 987 'workflow and fix these files, or continue. If you continue, '. 988 'these files will be marked as binary.', 989 phutil_count($utf8_problems)), 990 pht( 991 "You can learn more about how Phabricator handles character ". 992 "encodings (and how to configure encoding settings and detect and ". 993 "correct encoding problems) by reading 'User Guide: UTF-8 and ". 994 "Character Encoding' in the Phabricator documentation."), 995 pht( 996 '%s AFFECTED FILE(S)', 997 phutil_count($utf8_problems))); 998 $confirm = pht( 999 'Do you want to mark these %s file(s) as binary and continue?', 1000 phutil_count($utf8_problems)); 1001 1002 echo phutil_console_format( 1003 "**%s**\n", 1004 pht('Invalid Content Encoding (Non-UTF8)')); 1005 echo phutil_console_wrap($utf8_warning); 1006 1007 $file_list = mpull($utf8_problems, 'getCurrentPath'); 1008 $file_list = ' '.implode("\n ", $file_list); 1009 echo $file_list; 1010 1011 if (!phutil_console_confirm($confirm, $default_no = false)) { 1012 throw new ArcanistUsageException(pht('Aborted workflow to fix UTF-8.')); 1013 } else { 1014 foreach ($utf8_problems as $change) { 1015 $change->convertToBinaryChange($repository_api); 1016 } 1017 } 1018 } 1019 1020 $this->uploadFilesForChanges($changes); 1021 1022 return $changes; 1023 } 1024 1025 private function getGitParentLogInfo() { 1026 $info = array( 1027 'parent' => null, 1028 'base_revision' => null, 1029 'base_path' => null, 1030 'uuid' => null, 1031 ); 1032 1033 $repository_api = $this->getRepositoryAPI(); 1034 1035 $parser = $this->newDiffParser(); 1036 $history_messages = $repository_api->getGitHistoryLog(); 1037 if (!$history_messages) { 1038 // This can occur on the initial commit. 1039 return $info; 1040 } 1041 $history_messages = $parser->parseDiff($history_messages); 1042 1043 foreach ($history_messages as $key => $change) { 1044 try { 1045 $message = ArcanistDifferentialCommitMessage::newFromRawCorpus( 1046 $change->getMetadata('message')); 1047 if ($message->getRevisionID() && $info['parent'] === null) { 1048 $info['parent'] = $message->getRevisionID(); 1049 } 1050 if ($message->getGitSVNBaseRevision() && 1051 $info['base_revision'] === null) { 1052 $info['base_revision'] = $message->getGitSVNBaseRevision(); 1053 $info['base_path'] = $message->getGitSVNBasePath(); 1054 } 1055 if ($message->getGitSVNUUID()) { 1056 $info['uuid'] = $message->getGitSVNUUID(); 1057 } 1058 if ($info['parent'] && $info['base_revision']) { 1059 break; 1060 } 1061 } catch (ArcanistDifferentialCommitMessageParserException $ex) { 1062 // Ignore. 1063 } catch (ArcanistUsageException $ex) { 1064 // Ignore an invalid Differential Revision field in the parent commit 1065 } 1066 } 1067 1068 return $info; 1069 } 1070 1071 protected function primeSubversionWorkingCopyData($paths) { 1072 $repository_api = $this->getRepositoryAPI(); 1073 1074 $futures = array(); 1075 $targets = array(); 1076 foreach ($paths as $path => $mask) { 1077 $futures[] = $repository_api->buildDiffFuture($path); 1078 $targets[] = array('command' => 'diff', 'path' => $path); 1079 $futures[] = $repository_api->buildInfoFuture($path); 1080 $targets[] = array('command' => 'info', 'path' => $path); 1081 } 1082 1083 $futures = id(new FutureIterator($futures)) 1084 ->limit(8); 1085 foreach ($futures as $key => $future) { 1086 $target = $targets[$key]; 1087 if ($target['command'] == 'diff') { 1088 $repository_api->primeSVNDiffResult( 1089 $target['path'], 1090 $future->resolve()); 1091 } else { 1092 $repository_api->primeSVNInfoResult( 1093 $target['path'], 1094 $future->resolve()); 1095 } 1096 } 1097 } 1098 1099 private function shouldAmend() { 1100 if ($this->isRawDiffSource()) { 1101 return false; 1102 } 1103 1104 if ($this->getArgument('no-amend')) { 1105 return false; 1106 } 1107 1108 if ($this->getArgument('head') !== null) { 1109 return false; 1110 } 1111 1112 // Run this last: with --raw or --raw-command, we won't have a repository 1113 // API. 1114 if ($this->isHistoryImmutable()) { 1115 return false; 1116 } 1117 1118 return true; 1119 } 1120 1121 1122/* -( Lint and Unit Tests )------------------------------------------------ */ 1123 1124 1125 /** 1126 * @task lintunit 1127 */ 1128 private function runLintUnit() { 1129 $lint_result = $this->runLint(); 1130 $unit_result = $this->runUnit(); 1131 return array( 1132 'lintResult' => $lint_result, 1133 'unresolvedLint' => $this->unresolvedLint, 1134 'unitResult' => $unit_result, 1135 'testResults' => $this->testResults, 1136 ); 1137 } 1138 1139 1140 /** 1141 * @task lintunit 1142 */ 1143 private function runLint() { 1144 if ($this->getArgument('nolint') || 1145 $this->isRawDiffSource() || 1146 $this->getArgument('head')) { 1147 return ArcanistLintWorkflow::RESULT_SKIP; 1148 } 1149 1150 $repository_api = $this->getRepositoryAPI(); 1151 1152 $this->console->writeOut("%s\n", pht('Linting...')); 1153 try { 1154 $argv = $this->getPassthruArgumentsAsArgv('lint'); 1155 if ($repository_api->supportsCommitRanges()) { 1156 $argv[] = '--rev'; 1157 $argv[] = $repository_api->getBaseCommit(); 1158 } 1159 1160 $lint_workflow = $this->buildChildWorkflow('lint', $argv); 1161 1162 if ($this->shouldAmend()) { 1163 // TODO: We should offer to create a checkpoint commit. 1164 $lint_workflow->setShouldAmendChanges(true); 1165 } 1166 1167 $lint_result = $lint_workflow->run(); 1168 1169 switch ($lint_result) { 1170 case ArcanistLintWorkflow::RESULT_OKAY: 1171 $this->console->writeOut( 1172 "<bg:green>** %s **</bg> %s\n", 1173 pht('LINT OKAY'), 1174 pht('No lint problems.')); 1175 break; 1176 case ArcanistLintWorkflow::RESULT_WARNINGS: 1177 $this->console->writeOut( 1178 "<bg:yellow>** %s **</bg> %s\n", 1179 pht('LINT MESSAGES'), 1180 pht('Lint issued unresolved warnings.')); 1181 break; 1182 case ArcanistLintWorkflow::RESULT_ERRORS: 1183 $this->console->writeOut( 1184 "<bg:red>** %s **</bg> %s\n", 1185 pht('LINT ERRORS'), 1186 pht('Lint raised errors!')); 1187 break; 1188 } 1189 1190 $this->unresolvedLint = array(); 1191 foreach ($lint_workflow->getUnresolvedMessages() as $message) { 1192 $this->unresolvedLint[] = $message->toDictionary(); 1193 } 1194 1195 return $lint_result; 1196 } catch (ArcanistNoEngineException $ex) { 1197 $this->console->writeOut( 1198 "%s\n", 1199 pht('No lint engine configured for this project.')); 1200 } catch (ArcanistNoEffectException $ex) { 1201 $this->console->writeOut("%s\n", $ex->getMessage()); 1202 } 1203 1204 return null; 1205 } 1206 1207 1208 /** 1209 * @task lintunit 1210 */ 1211 private function runUnit() { 1212 if ($this->getArgument('nounit') || 1213 $this->isRawDiffSource() || 1214 $this->getArgument('head')) { 1215 return ArcanistUnitWorkflow::RESULT_SKIP; 1216 } 1217 1218 $repository_api = $this->getRepositoryAPI(); 1219 1220 $this->console->writeOut("%s\n", pht('Running unit tests...')); 1221 try { 1222 $argv = $this->getPassthruArgumentsAsArgv('unit'); 1223 if ($repository_api->supportsCommitRanges()) { 1224 $argv[] = '--rev'; 1225 $argv[] = $repository_api->getBaseCommit(); 1226 } 1227 $unit_workflow = $this->buildChildWorkflow('unit', $argv); 1228 $unit_result = $unit_workflow->run(); 1229 1230 switch ($unit_result) { 1231 case ArcanistUnitWorkflow::RESULT_OKAY: 1232 $this->console->writeOut( 1233 "<bg:green>** %s **</bg> %s\n", 1234 pht('UNIT OKAY'), 1235 pht('No unit test failures.')); 1236 break; 1237 case ArcanistUnitWorkflow::RESULT_UNSOUND: 1238 $continue = phutil_console_confirm( 1239 pht( 1240 'Unit test results included failures, but all failing tests '. 1241 'are known to be unsound. Ignore unsound test failures?')); 1242 if (!$continue) { 1243 throw new ArcanistUserAbortException(); 1244 } 1245 1246 echo phutil_console_format( 1247 "<bg:yellow>** %s **</bg> %s\n", 1248 pht('UNIT UNSOUND'), 1249 pht( 1250 'Unit testing raised errors, but all '. 1251 'failing tests are unsound.')); 1252 break; 1253 case ArcanistUnitWorkflow::RESULT_FAIL: 1254 $this->console->writeOut( 1255 "<bg:red>** %s **</bg> %s\n", 1256 pht('UNIT ERRORS'), 1257 pht('Unit testing raised errors!')); 1258 break; 1259 } 1260 1261 $this->testResults = array(); 1262 foreach ($unit_workflow->getTestResults() as $test) { 1263 $this->testResults[] = $test->toDictionary(); 1264 } 1265 1266 return $unit_result; 1267 } catch (ArcanistNoEngineException $ex) { 1268 $this->console->writeOut( 1269 "%s\n", 1270 pht('No unit test engine is configured for this project.')); 1271 } catch (ArcanistNoEffectException $ex) { 1272 $this->console->writeOut("%s\n", $ex->getMessage()); 1273 } 1274 1275 return null; 1276 } 1277 1278 public function getTestResults() { 1279 return $this->testResults; 1280 } 1281 1282 1283/* -( Commit and Update Messages )----------------------------------------- */ 1284 1285 1286 /** 1287 * @task message 1288 */ 1289 private function buildCommitMessage() { 1290 if ($this->getArgument('only')) { 1291 return null; 1292 } 1293 1294 $is_create = $this->getArgument('create'); 1295 $is_update = $this->getArgument('update'); 1296 $is_raw = $this->isRawDiffSource(); 1297 $is_verbatim = $this->getArgument('verbatim'); 1298 1299 if ($is_verbatim) { 1300 return $this->getCommitMessageFromUser(); 1301 } 1302 1303 1304 if (!$is_raw && !$is_create && !$is_update) { 1305 $repository_api = $this->getRepositoryAPI(); 1306 $revisions = $repository_api->loadWorkingCopyDifferentialRevisions( 1307 $this->getConduit(), 1308 array( 1309 'authors' => array($this->getUserPHID()), 1310 'status' => 'status-open', 1311 )); 1312 if (!$revisions) { 1313 $is_create = true; 1314 } else if (count($revisions) == 1) { 1315 $revision = head($revisions); 1316 $is_update = $revision['id']; 1317 } else { 1318 throw new ArcanistUsageException( 1319 pht( 1320 "There are several revisions which match the working copy:\n\n%s\n". 1321 "Use '%s' to choose one, or '%s' to create a new revision.", 1322 $this->renderRevisionList($revisions), 1323 '--update', 1324 '--create')); 1325 } 1326 } 1327 1328 $message = null; 1329 if ($is_create) { 1330 $message_file = $this->getArgument('message-file'); 1331 if ($message_file) { 1332 return $this->getCommitMessageFromFile($message_file); 1333 } else { 1334 return $this->getCommitMessageFromUser(); 1335 } 1336 } else if ($is_update) { 1337 $revision_id = $this->normalizeRevisionID($is_update); 1338 if (!is_numeric($revision_id)) { 1339 throw new ArcanistUsageException( 1340 pht( 1341 'Parameter to %s must be a Differential Revision number.', 1342 '--update')); 1343 } 1344 return $this->getCommitMessageFromRevision($revision_id); 1345 } else { 1346 // This is --raw without enough info to create a revision, so force just 1347 // a diff. 1348 return null; 1349 } 1350 } 1351 1352 1353 /** 1354 * @task message 1355 */ 1356 private function getCommitMessageFromUser() { 1357 $conduit = $this->getConduit(); 1358 1359 $template = null; 1360 1361 if (!$this->getArgument('verbatim')) { 1362 $saved = $this->readScratchFile('create-message'); 1363 if ($saved) { 1364 $where = $this->getReadableScratchFilePath('create-message'); 1365 1366 $preview = explode("\n", $saved); 1367 $preview = array_shift($preview); 1368 $preview = trim($preview); 1369 $preview = id(new PhutilUTF8StringTruncator()) 1370 ->setMaximumGlyphs(64) 1371 ->truncateString($preview); 1372 1373 if ($preview) { 1374 $preview = pht('Message begins:')."\n\n {$preview}\n\n"; 1375 } else { 1376 $preview = null; 1377 } 1378 1379 echo pht( 1380 "You have a saved revision message in '%s'.\n%s". 1381 "You can use this message, or discard it.", 1382 $where, 1383 $preview); 1384 1385 $use = phutil_console_confirm( 1386 pht('Do you want to use this message?'), 1387 $default_no = false); 1388 if ($use) { 1389 $template = $saved; 1390 } else { 1391 $this->removeScratchFile('create-message'); 1392 } 1393 } 1394 } 1395 1396 $template_is_default = false; 1397 $notes = array(); 1398 $included = array(); 1399 1400 list($fields, $notes, $included_commits) = $this->getDefaultCreateFields(); 1401 if ($template) { 1402 $fields = array(); 1403 $notes = array(); 1404 } else { 1405 if (!$fields) { 1406 $template_is_default = true; 1407 } 1408 1409 if ($notes) { 1410 $commit = head($this->getRepositoryAPI()->getLocalCommitInformation()); 1411 $template = $commit['message']; 1412 } else { 1413 $template = $conduit->callMethodSynchronous( 1414 'differential.getcommitmessage', 1415 array( 1416 'revision_id' => null, 1417 'edit' => 'create', 1418 'fields' => $fields, 1419 )); 1420 } 1421 } 1422 1423 $old_message = $template; 1424 1425 $included = array(); 1426 if ($included_commits) { 1427 foreach ($included_commits as $commit) { 1428 $included[] = ' '.$commit; 1429 } 1430 1431 if (!$this->isRawDiffSource()) { 1432 $message = pht( 1433 'Included commits in branch %s:', 1434 $this->getRepositoryAPI()->getBranchName()); 1435 } else { 1436 $message = pht('Included commits:'); 1437 } 1438 $included = array_merge( 1439 array( 1440 '', 1441 $message, 1442 '', 1443 ), 1444 $included); 1445 } 1446 1447 $issues = array_merge( 1448 array( 1449 pht('NEW DIFFERENTIAL REVISION'), 1450 pht('Describe the changes in this new revision.'), 1451 ), 1452 $included, 1453 array( 1454 '', 1455 pht( 1456 'arc could not identify any existing revision in your working copy.'), 1457 pht('If you intended to update an existing revision, use:'), 1458 '', 1459 ' $ arc diff --update <revision>', 1460 )); 1461 if ($notes) { 1462 $issues = array_merge($issues, array(''), $notes); 1463 } 1464 1465 $done = false; 1466 $first = true; 1467 while (!$done) { 1468 $template = rtrim($template, "\r\n")."\n\n"; 1469 foreach ($issues as $issue) { 1470 $template .= rtrim('# '.$issue)."\n"; 1471 } 1472 $template .= "\n"; 1473 1474 if ($first && $this->getArgument('verbatim') && !$template_is_default) { 1475 $new_template = $template; 1476 } else { 1477 $new_template = $this->newInteractiveEditor($template) 1478 ->setName('new-commit') 1479 ->editInteractively(); 1480 } 1481 $first = false; 1482 1483 if ($template_is_default && ($new_template == $template)) { 1484 throw new ArcanistUsageException(pht('Template not edited.')); 1485 } 1486 1487 $template = ArcanistCommentRemover::removeComments($new_template); 1488 1489 // With --raw-command, we may not have a repository API. 1490 if ($this->hasRepositoryAPI()) { 1491 $repository_api = $this->getRepositoryAPI(); 1492 // special check for whether to amend here. optimizes a common git 1493 // workflow. we can't do this for mercurial because the mq extension 1494 // is popular and incompatible with hg commit --amend ; see T2011. 1495 $should_amend = (count($included_commits) == 1 && 1496 $repository_api instanceof ArcanistGitAPI && 1497 $this->shouldAmend()); 1498 } else { 1499 $should_amend = false; 1500 } 1501 1502 if ($should_amend) { 1503 $wrote = (rtrim($old_message) != rtrim($template)); 1504 if ($wrote) { 1505 $repository_api->amendCommit($template); 1506 $where = pht('commit message'); 1507 } 1508 } else { 1509 $wrote = $this->writeScratchFile('create-message', $template); 1510 $where = "'".$this->getReadableScratchFilePath('create-message')."'"; 1511 } 1512 1513 try { 1514 $message = ArcanistDifferentialCommitMessage::newFromRawCorpus( 1515 $template); 1516 $message->pullDataFromConduit($conduit); 1517 $this->validateCommitMessage($message); 1518 $done = true; 1519 } catch (ArcanistDifferentialCommitMessageParserException $ex) { 1520 echo pht('Commit message has errors:')."\n\n"; 1521 $issues = array(pht('Resolve these errors:')); 1522 foreach ($ex->getParserErrors() as $error) { 1523 echo phutil_console_wrap("- ".$error."\n", 6); 1524 $issues[] = ' - '.$error; 1525 } 1526 echo "\n"; 1527 echo pht('You must resolve these errors to continue.'); 1528 $again = phutil_console_confirm( 1529 pht('Do you want to edit the message?'), 1530 $default_no = false); 1531 if ($again) { 1532 // Keep going. 1533 } else { 1534 $saved = null; 1535 if ($wrote) { 1536 $saved = pht('A copy was saved to %s.', $where); 1537 } 1538 throw new ArcanistUsageException( 1539 pht('Message has unresolved errors.')." {$saved}"); 1540 } 1541 } catch (Exception $ex) { 1542 if ($wrote) { 1543 echo phutil_console_wrap(pht('(Message saved to %s.)', $where)."\n"); 1544 } 1545 throw $ex; 1546 } 1547 } 1548 1549 return $message; 1550 } 1551 1552 1553 /** 1554 * @task message 1555 */ 1556 private function getCommitMessageFromFile($file) { 1557 $conduit = $this->getConduit(); 1558 1559 $data = Filesystem::readFile($file); 1560 $message = ArcanistDifferentialCommitMessage::newFromRawCorpus($data); 1561 $message->pullDataFromConduit($conduit); 1562 1563 $this->validateCommitMessage($message); 1564 1565 return $message; 1566 } 1567 1568 1569 /** 1570 * @task message 1571 */ 1572 private function getCommitMessageFromRevision($revision_id) { 1573 $id = $revision_id; 1574 1575 $revision = $this->getConduit()->callMethodSynchronous( 1576 'differential.query', 1577 array( 1578 'ids' => array($id), 1579 )); 1580 $revision = head($revision); 1581 1582 if (!$revision) { 1583 throw new ArcanistUsageException( 1584 pht( 1585 "Revision '%s' does not exist!", 1586 $revision_id)); 1587 } 1588 1589 $this->checkRevisionOwnership($revision); 1590 1591 // TODO: Save this status to improve a prompt later. See PHI458. This is 1592 // extra awful until we move to "differential.revision.search" because 1593 // the "differential.query" method doesn't return a real draft status for 1594 // compatibility. 1595 $this->revisionIsDraft = (idx($revision, 'statusName') === 'Draft'); 1596 1597 $message = $this->getConduit()->callMethodSynchronous( 1598 'differential.getcommitmessage', 1599 array( 1600 'revision_id' => $id, 1601 'edit' => false, 1602 )); 1603 $this->commitMessageFromRevision = $message; 1604 1605 $obj = ArcanistDifferentialCommitMessage::newFromRawCorpus($message); 1606 $obj->pullDataFromConduit($this->getConduit()); 1607 1608 return $obj; 1609 } 1610 1611 1612 /** 1613 * @task message 1614 */ 1615 private function validateCommitMessage( 1616 ArcanistDifferentialCommitMessage $message) { 1617 $futures = array(); 1618 1619 $revision_id = $message->getRevisionID(); 1620 if ($revision_id) { 1621 $futures['revision'] = $this->getConduit()->callMethod( 1622 'differential.query', 1623 array( 1624 'ids' => array($revision_id), 1625 )); 1626 } 1627 1628 $reviewers = $message->getFieldValue('reviewerPHIDs'); 1629 if ($reviewers) { 1630 $futures['reviewers'] = $this->getConduit()->callMethod( 1631 'user.query', 1632 array( 1633 'phids' => $reviewers, 1634 )); 1635 } 1636 1637 foreach (new FutureIterator($futures) as $key => $future) { 1638 $result = $future->resolve(); 1639 switch ($key) { 1640 case 'revision': 1641 if (empty($result)) { 1642 throw new ArcanistUsageException( 1643 pht( 1644 'There is no revision %s.', 1645 "D{$revision_id}")); 1646 } 1647 $this->checkRevisionOwnership(head($result)); 1648 break; 1649 case 'reviewers': 1650 $away = array(); 1651 foreach ($result as $user) { 1652 if (idx($user, 'currentStatus') != 'away') { 1653 continue; 1654 } 1655 1656 $username = $user['userName']; 1657 $real_name = $user['realName']; 1658 1659 if (strlen($real_name)) { 1660 $name = pht('%s (%s)', $username, $real_name); 1661 } else { 1662 $name = pht('%s', $username); 1663 } 1664 1665 $away[] = array( 1666 'name' => $name, 1667 'until' => $user['currentStatusUntil'], 1668 ); 1669 } 1670 1671 if ($away) { 1672 if (count($away) == count($reviewers)) { 1673 $earliest_return = min(ipull($away, 'until')); 1674 1675 $message = pht( 1676 'All reviewers are away until %s:', 1677 date('l, M j Y', $earliest_return)); 1678 } else { 1679 $message = pht('Some reviewers are currently away:'); 1680 } 1681 1682 echo tsprintf( 1683 "%s\n\n", 1684 $message); 1685 1686 $list = id(new PhutilConsoleList()); 1687 foreach ($away as $spec) { 1688 $list->addItem( 1689 pht( 1690 '%s (until %s)', 1691 $spec['name'], 1692 date('l, M j Y', $spec['until']))); 1693 } 1694 1695 echo tsprintf( 1696 '%B', 1697 $list->drawConsoleString()); 1698 1699 $confirm = pht('Continue even though reviewers are unavailable?'); 1700 if (!phutil_console_confirm($confirm)) { 1701 throw new ArcanistUsageException( 1702 pht('Specify available reviewers and retry.')); 1703 } 1704 } 1705 break; 1706 } 1707 } 1708 1709 } 1710 1711 1712 /** 1713 * @task message 1714 */ 1715 private function getUpdateMessage(array $fields, $template = '') { 1716 if ($this->getArgument('raw')) { 1717 throw new ArcanistUsageException( 1718 pht( 1719 "When using '%s' to update a revision, specify an update message ". 1720 "with '%s'. (Normally, we'd launch an editor to ask you for a ". 1721 "message, but can not do that because stdin is the diff source.)", 1722 '--raw', 1723 '--message')); 1724 } 1725 1726 // When updating a revision using git without specifying '--message', try 1727 // to prefill with the message in HEAD if it isn't a template message. The 1728 // idea is that if you do: 1729 // 1730 // $ git commit -a -m 'fix some junk' 1731 // $ arc diff 1732 // 1733 // ...you shouldn't have to retype the update message. Similar things apply 1734 // to Mercurial. 1735 1736 if ($template == '') { 1737 $comments = $this->getDefaultUpdateMessage(); 1738 1739 $template = sprintf( 1740 "%s\n\n# %s\n#\n# %s\n# %s\n#\n# %s\n# $ %s\n\n", 1741 rtrim($comments), 1742 pht( 1743 'Updating %s: %s', 1744 "D{$fields['revisionID']}", 1745 $fields['title']), 1746 pht( 1747 'Enter a brief description of the changes included in this update.'), 1748 pht('The first line is used as subject, next lines as comment.'), 1749 pht('If you intended to create a new revision, use:'), 1750 'arc diff --create'); 1751 } 1752 1753 $comments = $this->newInteractiveEditor($template) 1754 ->setName('differential-update-comments') 1755 ->editInteractively(); 1756 1757 return $comments; 1758 } 1759 1760 private function getDefaultCreateFields() { 1761 $result = array(array(), array(), array()); 1762 1763 if ($this->isRawDiffSource()) { 1764 return $result; 1765 } 1766 1767 $repository_api = $this->getRepositoryAPI(); 1768 $local = $repository_api->getLocalCommitInformation(); 1769 if ($local) { 1770 $result = $this->parseCommitMessagesIntoFields($local); 1771 if ($this->getArgument('create')) { 1772 unset($result[0]['revisionID']); 1773 } 1774 } 1775 1776 $result[0] = $this->dispatchWillBuildEvent($result[0]); 1777 1778 return $result; 1779 } 1780 1781 /** 1782 * Convert a list of commits from `getLocalCommitInformation()` into 1783 * a format usable by arc to create a new diff. Specifically, we emit: 1784 * 1785 * - A dictionary of commit message fields. 1786 * - A list of errors encountered while parsing the messages. 1787 * - A human-readable list of the commits themselves. 1788 * 1789 * For example, if the user runs "arc diff HEAD^^^" and selects a diff range 1790 * which includes several diffs, we attempt to merge them somewhat 1791 * intelligently into a single message, because we can only send one 1792 * "Summary:", "Reviewers:", etc., field to Differential. We also return 1793 * errors (e.g., if the user typed a reviewer name incorrectly) and a 1794 * summary of the commits themselves. 1795 * 1796 * @param dict Local commit information. 1797 * @return list Complex output, see summary. 1798 * @task message 1799 */ 1800 private function parseCommitMessagesIntoFields(array $local) { 1801 $conduit = $this->getConduit(); 1802 $local = ipull($local, null, 'commit'); 1803 1804 // If the user provided "--reviewers" or "--ccs", add a faux message to 1805 // the list with the implied fields. 1806 1807 $faux_message = array(); 1808 if ($this->getArgument('reviewers')) { 1809 $faux_message[] = pht('Reviewers: %s', $this->getArgument('reviewers')); 1810 } 1811 if ($this->getArgument('cc')) { 1812 $faux_message[] = pht('CC: %s', $this->getArgument('cc')); 1813 } 1814 1815 // NOTE: For now, this isn't a real field, so it just ends up as the first 1816 // part of the summary. 1817 $depends_ref = $this->getDependsOnRevisionRef(); 1818 if ($depends_ref) { 1819 $faux_message[] = pht( 1820 'Depends on %s. ', 1821 $depends_ref->getMonogram()); 1822 } 1823 1824 // See T12069. After T10312, the first line of a message is always parsed 1825 // as a title. Add a placeholder so "Reviewers" and "CC" are never the 1826 // first line. 1827 $placeholder_title = pht('<placeholder>'); 1828 1829 if ($faux_message) { 1830 array_unshift($faux_message, $placeholder_title); 1831 $faux_message = implode("\n\n", $faux_message); 1832 $local = array( 1833 '(Flags) ' => array( 1834 'message' => $faux_message, 1835 'summary' => pht('Command-Line Flags'), 1836 ), 1837 ) + $local; 1838 } 1839 1840 // Build a human-readable list of the commits, so we can show the user which 1841 // commits are included in the diff. 1842 $included = array(); 1843 foreach ($local as $hash => $info) { 1844 $included[] = substr($hash, 0, 12).' '.$info['summary']; 1845 } 1846 1847 // Parse all of the messages into fields. 1848 $messages = array(); 1849 foreach ($local as $hash => $info) { 1850 $text = $info['message']; 1851 $obj = ArcanistDifferentialCommitMessage::newFromRawCorpus($text); 1852 $messages[$hash] = $obj; 1853 } 1854 1855 $notes = array(); 1856 $fields = array(); 1857 foreach ($messages as $hash => $message) { 1858 try { 1859 $message->pullDataFromConduit($conduit, $partial = true); 1860 $fields[$hash] = $message->getFields(); 1861 } catch (ArcanistDifferentialCommitMessageParserException $ex) { 1862 if ($this->getArgument('verbatim')) { 1863 // In verbatim mode, just bail when we hit an error. The user can 1864 // rerun without --verbatim if they want to fix it manually. Most 1865 // users will probably `git commit --amend` instead. 1866 throw $ex; 1867 } 1868 $fields[$hash] = $message->getFields(); 1869 1870 $frev = substr($hash, 0, 12); 1871 $notes[] = pht( 1872 'NOTE: commit %s could not be completely parsed:', 1873 $frev); 1874 foreach ($ex->getParserErrors() as $error) { 1875 $notes[] = " - {$error}"; 1876 } 1877 } 1878 } 1879 1880 // Merge commit message fields. We do this somewhat-intelligently so that 1881 // multiple "Reviewers" or "CC" fields will merge into the concatenation 1882 // of all values. 1883 1884 // We have special parsing rules for 'title' because we can't merge 1885 // multiple titles, and one-line commit messages like "fix stuff" will 1886 // parse as titles. Instead, pick the first title we encounter. When we 1887 // encounter subsequent titles, treat them as part of the summary. Then 1888 // we merge all the summaries together below. 1889 1890 $result = array(); 1891 1892 // Process fields in oldest-first order, so earlier commits get to set the 1893 // title of record and reviewers/ccs are listed in chronological order. 1894 $fields = array_reverse($fields); 1895 1896 foreach ($fields as $hash => $dict) { 1897 $title = idx($dict, 'title'); 1898 if (!strlen($title)) { 1899 continue; 1900 } 1901 1902 if ($title === $placeholder_title) { 1903 continue; 1904 } 1905 1906 if (!isset($result['title'])) { 1907 // We don't have a title yet, so use this one. 1908 $result['title'] = $title; 1909 } else { 1910 // We already have a title, so merge this new title into the summary. 1911 $summary = idx($dict, 'summary'); 1912 if ($summary) { 1913 $summary = $title."\n\n".$summary; 1914 } else { 1915 $summary = $title; 1916 } 1917 $fields[$hash]['summary'] = $summary; 1918 } 1919 } 1920 1921 // Now, merge all the other fields in a general sort of way. 1922 1923 foreach ($fields as $hash => $dict) { 1924 foreach ($dict as $key => $value) { 1925 if ($key == 'title') { 1926 // This has been handled above, and either assigned directly or 1927 // merged into the summary. 1928 continue; 1929 } 1930 1931 if (is_array($value)) { 1932 // For array values, merge the arrays, appending the new values. 1933 // Examples are "Reviewers" and "Cc", where this produces a list of 1934 // all users specified as reviewers. 1935 $cur = idx($result, $key, array()); 1936 $new = array_merge($cur, $value); 1937 $result[$key] = $new; 1938 continue; 1939 } else { 1940 if (!strlen(trim($value))) { 1941 // Ignore empty fields. 1942 continue; 1943 } 1944 1945 // For string values, append the new field to the old field with 1946 // a blank line separating them. Examples are "Test Plan" and 1947 // "Summary". 1948 $cur = idx($result, $key, ''); 1949 if (strlen($cur)) { 1950 $new = $cur."\n\n".$value; 1951 } else { 1952 $new = $value; 1953 } 1954 $result[$key] = $new; 1955 } 1956 } 1957 } 1958 1959 return array($result, $notes, $included); 1960 } 1961 1962 private function getDefaultUpdateMessage() { 1963 if ($this->isRawDiffSource()) { 1964 return null; 1965 } 1966 1967 $repository_api = $this->getRepositoryAPI(); 1968 if ($repository_api instanceof ArcanistGitAPI) { 1969 return $this->getGitUpdateMessage(); 1970 } 1971 1972 if ($repository_api instanceof ArcanistMercurialAPI) { 1973 return $this->getMercurialUpdateMessage(); 1974 } 1975 1976 return null; 1977 } 1978 1979 /** 1980 * Retrieve the git messages between HEAD and the last update. 1981 * 1982 * @task message 1983 */ 1984 private function getGitUpdateMessage() { 1985 $repository_api = $this->getRepositoryAPI(); 1986 1987 $parser = $this->newDiffParser(); 1988 $commit_messages = $repository_api->getGitCommitLog(); 1989 $commit_messages = $parser->parseDiff($commit_messages); 1990 1991 if (count($commit_messages) == 1) { 1992 // If there's only one message, assume this is an amend-based workflow and 1993 // that using it to prefill doesn't make sense. 1994 return null; 1995 } 1996 1997 // We have more than one message, so figure out which ones are new. We 1998 // do this by pulling the current diff and comparing commit hashes in the 1999 // working copy with attached commit hashes. It's not super important that 2000 // we always get this 100% right, we're just trying to do something 2001 // reasonable. 2002 2003 $hashes = $this->loadActiveDiffLocalCommitHashes(); 2004 $hashes = array_fuse($hashes); 2005 2006 $usable = array(); 2007 foreach ($commit_messages as $message) { 2008 $text = $message->getMetadata('message'); 2009 2010 $parsed = ArcanistDifferentialCommitMessage::newFromRawCorpus($text); 2011 if ($parsed->getRevisionID()) { 2012 // If this is an amended commit message with a revision ID, it's 2013 // certainly not new. Stop marking commits as usable and break out. 2014 break; 2015 } 2016 2017 if (isset($hashes[$message->getCommitHash()])) { 2018 // If this commit is currently part of the diff, stop using commit 2019 // messages, since anything older than this isn't new. 2020 break; 2021 } 2022 2023 // Otherwise, this looks new, so it's a usable commit message. 2024 $usable[] = $text; 2025 } 2026 2027 if (!$usable) { 2028 // No new commit messages, so we don't have anywhere to start from. 2029 return null; 2030 } 2031 2032 return $this->formatUsableLogs($usable); 2033 } 2034 2035 /** 2036 * Retrieve the hg messages between tip and the last update. 2037 * 2038 * @task message 2039 */ 2040 private function getMercurialUpdateMessage() { 2041 $repository_api = $this->getRepositoryAPI(); 2042 2043 $messages = $repository_api->getCommitMessageLog(); 2044 2045 if (count($messages) == 1) { 2046 // If there's only one message, assume this is an amend-based workflow and 2047 // that using it to prefill doesn't make sense. 2048 return null; 2049 } 2050 2051 $hashes = $this->loadActiveDiffLocalCommitHashes(); 2052 $hashes = array_fuse($hashes); 2053 2054 $usable = array(); 2055 foreach ($messages as $rev => $message) { 2056 if (isset($hashes[$rev])) { 2057 // If this commit is currently part of the active diff on the revision, 2058 // stop using commit messages, since anything older than this isn't new. 2059 break; 2060 } 2061 2062 // Otherwise, this looks new, so it's a usable commit message. 2063 $usable[] = $message; 2064 } 2065 2066 if (!$usable) { 2067 // No new commit messages, so we don't have anywhere to start from. 2068 return null; 2069 } 2070 2071 return $this->formatUsableLogs($usable); 2072 } 2073 2074 2075 /** 2076 * Format log messages to prefill a diff update. 2077 * 2078 * @task message 2079 */ 2080 private function formatUsableLogs(array $usable) { 2081 // Flip messages so they'll read chronologically (oldest-first) in the 2082 // template, e.g.: 2083 // 2084 // - Added foobar. 2085 // - Fixed foobar bug. 2086 // - Documented foobar. 2087 2088 $usable = array_reverse($usable); 2089 $default = array(); 2090 foreach ($usable as $message) { 2091 // Pick the first line out of each message. 2092 $text = trim($message); 2093 $text = head(explode("\n", $text)); 2094 $default[] = ' - '.$text."\n"; 2095 } 2096 2097 return implode('', $default); 2098 } 2099 2100 private function loadActiveDiffLocalCommitHashes() { 2101 // The older "differential.querydiffs" method includes the full diff text, 2102 // which can be very slow for large diffs. If we can, try to use 2103 // "differential.diff.search" instead. 2104 2105 // We expect this to fail if the Phabricator version on the server is 2106 // older than April 2018 (D19386), which introduced the "commits" 2107 // attachment for "differential.revision.search". 2108 2109 // TODO: This can be optimized if we're able to learn the "revisionPHID" 2110 // before we get here. See PHI1104. 2111 2112 try { 2113 $revisions_raw = $this->getConduit()->callMethodSynchronous( 2114 'differential.revision.search', 2115 array( 2116 'constraints' => array( 2117 'ids' => array( 2118 $this->revisionID, 2119 ), 2120 ), 2121 )); 2122 2123 $revisions = $revisions_raw['data']; 2124 $revision = head($revisions); 2125 if ($revision) { 2126 $revision_phid = $revision['phid']; 2127 2128 $diffs_raw = $this->getConduit()->callMethodSynchronous( 2129 'differential.diff.search', 2130 array( 2131 'constraints' => array( 2132 'revisionPHIDs' => array( 2133 $revision_phid, 2134 ), 2135 ), 2136 'attachments' => array( 2137 'commits' => true, 2138 ), 2139 'limit' => 1, 2140 )); 2141 2142 $diffs = $diffs_raw['data']; 2143 $diff = head($diffs); 2144 2145 if ($diff) { 2146 $commits = idxv($diff, array('attachments', 'commits', 'commits')); 2147 if ($commits !== null) { 2148 $hashes = ipull($commits, 'identifier'); 2149 return array_values($hashes); 2150 } 2151 } 2152 } 2153 } catch (Exception $ex) { 2154 // If any of this fails, fall back to the older method below. 2155 } 2156 2157 $current_diff = $this->getConduit()->callMethodSynchronous( 2158 'differential.querydiffs', 2159 array( 2160 'revisionIDs' => array($this->revisionID), 2161 )); 2162 $current_diff = head($current_diff); 2163 2164 $properties = idx($current_diff, 'properties', array()); 2165 $local = idx($properties, 'local:commits', array()); 2166 $hashes = ipull($local, 'commit'); 2167 2168 return array_values($hashes); 2169 } 2170 2171 2172/* -( Diff Specification )------------------------------------------------- */ 2173 2174 2175 /** 2176 * @task diffspec 2177 */ 2178 private function getLintStatus($lint_result) { 2179 $map = array( 2180 ArcanistLintWorkflow::RESULT_OKAY => 'okay', 2181 ArcanistLintWorkflow::RESULT_ERRORS => 'fail', 2182 ArcanistLintWorkflow::RESULT_WARNINGS => 'warn', 2183 ArcanistLintWorkflow::RESULT_SKIP => 'skip', 2184 ); 2185 return idx($map, $lint_result, 'none'); 2186 } 2187 2188 2189 /** 2190 * @task diffspec 2191 */ 2192 private function getUnitStatus($unit_result) { 2193 $map = array( 2194 ArcanistUnitWorkflow::RESULT_OKAY => 'okay', 2195 ArcanistUnitWorkflow::RESULT_FAIL => 'fail', 2196 ArcanistUnitWorkflow::RESULT_UNSOUND => 'warn', 2197 ArcanistUnitWorkflow::RESULT_SKIP => 'skip', 2198 ); 2199 return idx($map, $unit_result, 'none'); 2200 } 2201 2202 2203 /** 2204 * @task diffspec 2205 */ 2206 private function buildDiffSpecification() { 2207 2208 $base_revision = null; 2209 $base_path = null; 2210 $vcs = null; 2211 $repo_uuid = null; 2212 $parent = null; 2213 $source_path = null; 2214 $branch = null; 2215 $bookmark = null; 2216 2217 if (!$this->isRawDiffSource()) { 2218 $repository_api = $this->getRepositoryAPI(); 2219 2220 $base_revision = $repository_api->getSourceControlBaseRevision(); 2221 $base_path = $repository_api->getSourceControlPath(); 2222 $vcs = $repository_api->getSourceControlSystemName(); 2223 $source_path = $repository_api->getPath(); 2224 $branch = $repository_api->getBranchName(); 2225 $repo_uuid = $repository_api->getRepositoryUUID(); 2226 2227 if ($repository_api instanceof ArcanistGitAPI) { 2228 $info = $this->getGitParentLogInfo(); 2229 if ($info['parent']) { 2230 $parent = $info['parent']; 2231 } 2232 if ($info['base_revision']) { 2233 $base_revision = $info['base_revision']; 2234 } 2235 if ($info['base_path']) { 2236 $base_path = $info['base_path']; 2237 } 2238 if ($info['uuid']) { 2239 $repo_uuid = $info['uuid']; 2240 } 2241 } else if ($repository_api instanceof ArcanistMercurialAPI) { 2242 2243 $bookmark = $repository_api->getActiveBookmark(); 2244 $svn_info = $repository_api->getSubversionInfo(); 2245 $repo_uuid = idx($svn_info, 'uuid'); 2246 $base_path = idx($svn_info, 'base_path', $base_path); 2247 $base_revision = idx($svn_info, 'base_revision', $base_revision); 2248 2249 // TODO: provide parent info 2250 2251 } 2252 } 2253 2254 $data = array( 2255 'sourceMachine' => php_uname('n'), 2256 'sourcePath' => $source_path, 2257 'branch' => $branch, 2258 'bookmark' => $bookmark, 2259 'sourceControlSystem' => $vcs, 2260 'sourceControlPath' => $base_path, 2261 'sourceControlBaseRevision' => $base_revision, 2262 'creationMethod' => 'arc', 2263 ); 2264 2265 if (!$this->isRawDiffSource()) { 2266 $repository_phid = $this->getRepositoryPHID(); 2267 if ($repository_phid) { 2268 $data['repositoryPHID'] = $repository_phid; 2269 } 2270 } 2271 2272 return $data; 2273 } 2274 2275 2276/* -( Diff Properties )---------------------------------------------------- */ 2277 2278 2279 /** 2280 * Update lint information for the diff. 2281 * 2282 * @return void 2283 * 2284 * @task diffprop 2285 */ 2286 private function updateLintDiffProperty() { 2287 if (!$this->hitAutotargets) { 2288 if ($this->unresolvedLint) { 2289 $this->updateDiffProperty( 2290 'arc:lint', 2291 json_encode($this->unresolvedLint)); 2292 } 2293 } 2294 } 2295 2296 2297 /** 2298 * Update unit test information for the diff. 2299 * 2300 * @return void 2301 * 2302 * @task diffprop 2303 */ 2304 private function updateUnitDiffProperty() { 2305 if (!$this->hitAutotargets) { 2306 if ($this->testResults) { 2307 $this->updateDiffProperty('arc:unit', json_encode($this->testResults)); 2308 } 2309 } 2310 } 2311 2312 2313 /** 2314 * Update local commit information for the diff. 2315 * 2316 * @task diffprop 2317 */ 2318 private function updateLocalDiffProperty() { 2319 if ($this->isRawDiffSource()) { 2320 return; 2321 } 2322 2323 $local_info = $this->getRepositoryAPI()->getLocalCommitInformation(); 2324 if (!$local_info) { 2325 return; 2326 } 2327 2328 $this->updateDiffProperty('local:commits', json_encode($local_info)); 2329 } 2330 2331 private function updateOntoDiffProperty() { 2332 $onto = $this->getDiffOntoTargets(); 2333 2334 if (!$onto) { 2335 return; 2336 } 2337 2338 $this->updateDiffProperty('arc:onto', json_encode($onto)); 2339 } 2340 2341 private function getDiffOntoTargets() { 2342 if ($this->isRawDiffSource()) { 2343 return null; 2344 } 2345 2346 $api = $this->getRepositoryAPI(); 2347 2348 if (!($api instanceof ArcanistGitAPI)) { 2349 return null; 2350 } 2351 2352 // If we track an upstream branch either directly or indirectly, use that. 2353 $branch = $api->getBranchName(); 2354 if (strlen($branch)) { 2355 $upstream_path = $api->getPathToUpstream($branch); 2356 $remote_branch = $upstream_path->getRemoteBranchName(); 2357 if (strlen($remote_branch)) { 2358 return array( 2359 array( 2360 'type' => 'branch', 2361 'name' => $remote_branch, 2362 'kind' => 'upstream', 2363 ), 2364 ); 2365 } 2366 } 2367 2368 // If "arc.land.onto.default" is configured, use that. 2369 $config_key = 'arc.land.onto.default'; 2370 $onto = $this->getConfigFromAnySource($config_key); 2371 if (strlen($onto)) { 2372 return array( 2373 array( 2374 'type' => 'branch', 2375 'name' => $onto, 2376 'kind' => 'arc.land.onto.default', 2377 ), 2378 ); 2379 } 2380 2381 return null; 2382 } 2383 2384 /** 2385 * Update an arbitrary diff property. 2386 * 2387 * @param string Diff property name. 2388 * @param string Diff property value. 2389 * @return void 2390 * 2391 * @task diffprop 2392 */ 2393 private function updateDiffProperty($name, $data) { 2394 $this->diffPropertyFutures[] = $this->getConduit()->callMethod( 2395 'differential.setdiffproperty', 2396 array( 2397 'diff_id' => $this->getDiffID(), 2398 'name' => $name, 2399 'data' => $data, 2400 )); 2401 } 2402 2403 /** 2404 * Wait for finishing all diff property updates. 2405 * 2406 * @return void 2407 * 2408 * @task diffprop 2409 */ 2410 private function resolveDiffPropertyUpdates() { 2411 id(new FutureIterator($this->diffPropertyFutures)) 2412 ->resolveAll(); 2413 $this->diffPropertyFutures = array(); 2414 } 2415 2416 private function dispatchWillCreateRevisionEvent(array $fields) { 2417 $event = $this->dispatchEvent( 2418 ArcanistEventType::TYPE_REVISION_WILLCREATEREVISION, 2419 array( 2420 'specification' => $fields, 2421 )); 2422 2423 return $event->getValue('specification'); 2424 } 2425 2426 private function dispatchWillBuildEvent(array $fields) { 2427 $event = $this->dispatchEvent( 2428 ArcanistEventType::TYPE_DIFF_WILLBUILDMESSAGE, 2429 array( 2430 'fields' => $fields, 2431 )); 2432 2433 return $event->getValue('fields'); 2434 } 2435 2436 private function checkRevisionOwnership(array $revision) { 2437 if ($revision['authorPHID'] == $this->getUserPHID()) { 2438 return; 2439 } 2440 2441 $id = $revision['id']; 2442 $title = $revision['title']; 2443 2444 $prompt = pht( 2445 "You don't own revision %s: \"%s\". Normally, you should ". 2446 "only update revisions you own. You can \"Commandeer\" this revision ". 2447 "from the web interface if you want to become the owner.\n\n". 2448 "Update this revision anyway?", 2449 "D{$id}", 2450 $title); 2451 2452 $ok = phutil_console_confirm($prompt, $default_no = true); 2453 if (!$ok) { 2454 throw new ArcanistUsageException( 2455 pht('Aborted update of revision: You are not the owner.')); 2456 } 2457 } 2458 2459 2460/* -( File Uploads )------------------------------------------------------- */ 2461 2462 2463 private function uploadFilesForChanges(array $changes) { 2464 assert_instances_of($changes, 'ArcanistDiffChange'); 2465 2466 // Collect all the files we need to upload. 2467 2468 $need_upload = array(); 2469 foreach ($changes as $key => $change) { 2470 if ($change->getFileType() != ArcanistDiffChangeType::FILE_BINARY) { 2471 continue; 2472 } 2473 2474 if ($this->getArgument('skip-binaries')) { 2475 continue; 2476 } 2477 2478 $name = basename($change->getCurrentPath()); 2479 2480 $need_upload[] = array( 2481 'type' => 'old', 2482 'name' => $name, 2483 'data' => $change->getOriginalFileData(), 2484 'change' => $change, 2485 ); 2486 2487 $need_upload[] = array( 2488 'type' => 'new', 2489 'name' => $name, 2490 'data' => $change->getCurrentFileData(), 2491 'change' => $change, 2492 ); 2493 } 2494 2495 if (!$need_upload) { 2496 return; 2497 } 2498 2499 // Determine mime types and file sizes. Update changes from "binary" to 2500 // "image" if the file is an image. Set image metadata. 2501 2502 $type_image = ArcanistDiffChangeType::FILE_IMAGE; 2503 foreach ($need_upload as $key => $spec) { 2504 $change = $need_upload[$key]['change']; 2505 2506 if ($spec['data'] === null) { 2507 // This covers the case where a file was added or removed; we don't 2508 // need to upload the other half of it (e.g., the old file data for 2509 // a file which was just added). This is distinct from an empty 2510 // file, which we do upload. 2511 unset($need_upload[$key]); 2512 continue; 2513 } 2514 2515 $type = $spec['type']; 2516 $size = strlen($spec['data']); 2517 2518 $change->setMetadata("{$type}:file:size", $size); 2519 2520 $mime = $this->getFileMimeType($spec['data']); 2521 if (preg_match('@^image/@', $mime)) { 2522 $change->setFileType($type_image); 2523 } 2524 2525 $change->setMetadata("{$type}:file:mime-type", $mime); 2526 } 2527 2528 $uploader = id(new ArcanistFileUploader()) 2529 ->setConduitEngine($this->getConduitEngine()); 2530 2531 foreach ($need_upload as $key => $spec) { 2532 $ref = id(new ArcanistFileDataRef()) 2533 ->setName($spec['name']) 2534 ->setData($spec['data']); 2535 2536 $uploader->addFile($ref, $key); 2537 } 2538 2539 $files = $uploader->uploadFiles(); 2540 2541 $errors = false; 2542 foreach ($files as $key => $file) { 2543 if ($file->getErrors()) { 2544 unset($files[$key]); 2545 $errors = true; 2546 echo pht( 2547 'Failed to upload binary "%s".', 2548 $file->getName()); 2549 } 2550 } 2551 2552 if ($errors) { 2553 $prompt = pht('Continue?'); 2554 $ok = phutil_console_confirm($prompt, $default_no = false); 2555 if (!$ok) { 2556 throw new ArcanistUsageException( 2557 pht( 2558 'Aborted due to file upload failure. You can use %s '. 2559 'to skip binary uploads.', 2560 '--skip-binaries')); 2561 } 2562 } 2563 2564 foreach ($files as $key => $file) { 2565 $spec = $need_upload[$key]; 2566 $phid = $file->getPHID(); 2567 2568 $change = $spec['change']; 2569 $type = $spec['type']; 2570 $change->setMetadata("{$type}:binary-phid", $phid); 2571 2572 echo pht('Uploaded binary data for "%s".', $file->getName())."\n"; 2573 } 2574 2575 echo pht('Upload complete.')."\n"; 2576 } 2577 2578 private function getFileMimeType($data) { 2579 $tmp = new TempFile(); 2580 Filesystem::writeFile($tmp, $data); 2581 return Filesystem::getMimeType($tmp); 2582 } 2583 2584 private function shouldOpenCreatedObjectsInBrowser() { 2585 return $this->getArgument('browse'); 2586 } 2587 2588 private function submitChangesToStagingArea($id) { 2589 $result = $this->pushChangesToStagingArea($id); 2590 2591 // We'll either get a failure constant on error, or a list of pushed 2592 // refs on success. 2593 $ok = is_array($result); 2594 2595 if ($ok) { 2596 $staging = array( 2597 'status' => self::STAGING_PUSHED, 2598 'refs' => $result, 2599 ); 2600 } else { 2601 $staging = array( 2602 'status' => $result, 2603 'refs' => array(), 2604 ); 2605 } 2606 2607 $this->updateDiffProperty( 2608 'arc.staging', 2609 phutil_json_encode($staging)); 2610 } 2611 2612 private function pushChangesToStagingArea($id) { 2613 if ($this->getArgument('skip-staging')) { 2614 $this->writeInfo( 2615 pht('SKIP STAGING'), 2616 pht('Flag --skip-staging was specified.')); 2617 return self::STAGING_USER_SKIP; 2618 } 2619 2620 if ($this->isRawDiffSource()) { 2621 $this->writeInfo( 2622 pht('SKIP STAGING'), 2623 pht('Raw changes can not be pushed to a staging area.')); 2624 return self::STAGING_DIFF_RAW; 2625 } 2626 2627 if (!$this->getRepositoryPHID()) { 2628 $this->writeInfo( 2629 pht('SKIP STAGING'), 2630 pht('Unable to determine repository for this change.')); 2631 return self::STAGING_REPOSITORY_UNKNOWN; 2632 } 2633 2634 $staging = $this->getRepositoryStagingConfiguration(); 2635 if ($staging === null) { 2636 $this->writeInfo( 2637 pht('SKIP STAGING'), 2638 pht('The server does not support staging areas.')); 2639 return self::STAGING_REPOSITORY_UNAVAILABLE; 2640 } 2641 2642 $supported = idx($staging, 'supported'); 2643 if (!$supported) { 2644 $this->writeInfo( 2645 pht('SKIP STAGING'), 2646 pht('Phabricator does not support staging areas for this repository.')); 2647 return self::STAGING_REPOSITORY_UNSUPPORTED; 2648 } 2649 2650 $staging_uri = idx($staging, 'uri'); 2651 if (!$staging_uri) { 2652 $this->writeInfo( 2653 pht('SKIP STAGING'), 2654 pht('No staging area is configured for this repository.')); 2655 return self::STAGING_REPOSITORY_UNCONFIGURED; 2656 } 2657 2658 $api = $this->getRepositoryAPI(); 2659 if (!($api instanceof ArcanistGitAPI)) { 2660 $this->writeInfo( 2661 pht('SKIP STAGING'), 2662 pht('This client version does not support staging this repository.')); 2663 return self::STAGING_CLIENT_UNSUPPORTED; 2664 } 2665 2666 $commit = $api->getHeadCommit(); 2667 $prefix = idx($staging, 'prefix', 'phabricator'); 2668 2669 $base_tag = "refs/tags/{$prefix}/base/{$id}"; 2670 $diff_tag = "refs/tags/{$prefix}/diff/{$id}"; 2671 2672 $this->writeOkay( 2673 pht('PUSH STAGING'), 2674 pht('Pushing changes to staging area...')); 2675 2676 $push_flags = array(); 2677 if (version_compare($api->getGitVersion(), '1.8.2', '>=')) { 2678 $push_flags[] = '--no-verify'; 2679 } 2680 2681 $refs = array(); 2682 2683 $remote = array( 2684 'uri' => $staging_uri, 2685 ); 2686 2687 $is_lfs = $api->isGitLFSWorkingCopy(); 2688 2689 // If the base commit is a real commit, we're going to push it. We don't 2690 // use this, but pushing it to a ref reduces the amount of redundant work 2691 // that Git does on later pushes by helping it figure out that the remote 2692 // already has most of the history. See T10509. 2693 2694 // In the future, we could avoid this push if the staging area is the same 2695 // as the main repository, or if the staging area is a virtual repository. 2696 // In these cases, the staging area should automatically have up-to-date 2697 // refs. 2698 $base_commit = $api->getSourceControlBaseRevision(); 2699 if ($base_commit !== ArcanistGitAPI::GIT_MAGIC_ROOT_COMMIT) { 2700 $refs[] = array( 2701 'ref' => $base_tag, 2702 'type' => 'base', 2703 'commit' => $base_commit, 2704 'remote' => $remote, 2705 ); 2706 } 2707 2708 // We're always going to push the change itself. 2709 $refs[] = array( 2710 'ref' => $diff_tag, 2711 'type' => 'diff', 2712 'commit' => $is_lfs ? $base_commit : $commit, 2713 'remote' => $remote, 2714 ); 2715 2716 $ref_list = array(); 2717 foreach ($refs as $ref) { 2718 $ref_list[] = $ref['commit'].':'.$ref['ref']; 2719 } 2720 2721 $err = phutil_passthru( 2722 'git push %Ls -- %s %Ls', 2723 $push_flags, 2724 $staging_uri, 2725 $ref_list); 2726 2727 if ($err) { 2728 $this->writeWarn( 2729 pht('STAGING FAILED'), 2730 pht('Unable to push changes to the staging area.')); 2731 2732 throw new ArcanistUsageException( 2733 pht( 2734 'Failed to push changes to staging area. Correct the issue, or '. 2735 'use --skip-staging to skip this step.')); 2736 } 2737 2738 if ($is_lfs) { 2739 $ref = '+'.$commit.':'.$diff_tag; 2740 $err = phutil_passthru( 2741 'git push -- %s %s', 2742 $staging_uri, 2743 $ref); 2744 2745 if ($err) { 2746 $this->writeWarn( 2747 pht('STAGING FAILED'), 2748 pht('Unable to push lfs changes to the staging area.')); 2749 2750 throw new ArcanistUsageException( 2751 pht( 2752 'Failed to push lfs changes to staging area. Correct the issue, '. 2753 'or use --skip-staging to skip this step.')); 2754 } 2755 } 2756 2757 return $refs; 2758 } 2759 2760 2761 /** 2762 * Try to upload lint and unit test results into modern Harbormaster build 2763 * targets. 2764 * 2765 * @return bool True if everything was uploaded to build targets. 2766 */ 2767 private function updateAutotargets($diff_phid, $unit_result) { 2768 $lint_key = 'arcanist.lint'; 2769 $unit_key = 'arcanist.unit'; 2770 2771 try { 2772 $result = $this->getConduit()->callMethodSynchronous( 2773 'harbormaster.queryautotargets', 2774 array( 2775 'objectPHID' => $diff_phid, 2776 'targetKeys' => array( 2777 $lint_key, 2778 $unit_key, 2779 ), 2780 )); 2781 $targets = idx($result, 'targetMap', array()); 2782 } catch (Exception $ex) { 2783 return false; 2784 } 2785 2786 $futures = array(); 2787 2788 $lint_target = idx($targets, $lint_key); 2789 if ($lint_target) { 2790 $lint = nonempty($this->unresolvedLint, array()); 2791 foreach ($lint as $key => $message) { 2792 $lint[$key] = $this->getModernLintDictionary($message); 2793 } 2794 2795 // Consider this target to have failed if there are any unresolved 2796 // errors or warnings. 2797 $type = 'pass'; 2798 foreach ($lint as $message) { 2799 switch (idx($message, 'severity')) { 2800 case ArcanistLintSeverity::SEVERITY_WARNING: 2801 case ArcanistLintSeverity::SEVERITY_ERROR: 2802 $type = 'fail'; 2803 break; 2804 } 2805 } 2806 2807 $futures[] = $this->getConduit()->callMethod( 2808 'harbormaster.sendmessage', 2809 array( 2810 'buildTargetPHID' => $lint_target, 2811 'lint' => array_values($lint), 2812 'type' => $type, 2813 )); 2814 } 2815 2816 $unit_target = idx($targets, $unit_key); 2817 if ($unit_target) { 2818 $unit = nonempty($this->testResults, array()); 2819 foreach ($unit as $key => $message) { 2820 $unit[$key] = $this->getModernUnitDictionary($message); 2821 } 2822 2823 $type = ArcanistUnitWorkflow::getHarbormasterTypeFromResult($unit_result); 2824 2825 $futures[] = $this->getConduit()->callMethod( 2826 'harbormaster.sendmessage', 2827 array( 2828 'buildTargetPHID' => $unit_target, 2829 'unit' => array_values($unit), 2830 'type' => $type, 2831 )); 2832 } 2833 2834 try { 2835 foreach (new FutureIterator($futures) as $future) { 2836 $future->resolve(); 2837 } 2838 return true; 2839 } catch (Exception $ex) { 2840 // TODO: Eventually, we should expect these to succeed if we get this 2841 // far, but just log errors for now. 2842 phlog($ex); 2843 return false; 2844 } 2845 } 2846 2847 private function getDependsOnRevisionRef() { 2848 // TODO: Restore this behavior after updating for toolsets. Loading the 2849 // required hardpoints currently depends on a "WorkingCopy" existing. 2850 return null; 2851 2852 $api = $this->getRepositoryAPI(); 2853 $base_ref = $api->getBaseCommitRef(); 2854 2855 $state_ref = id(new ArcanistWorkingCopyStateRef()) 2856 ->setCommitRef($base_ref); 2857 2858 $this->loadHardpoints( 2859 $state_ref, 2860 ArcanistWorkingCopyStateRef::HARDPOINT_REVISIONREFS); 2861 2862 $revision_refs = $state_ref->getRevisionRefs(); 2863 $viewer_phid = $this->getUserPHID(); 2864 2865 foreach ($revision_refs as $key => $revision_ref) { 2866 // Don't automatically depend on closed revisions. 2867 if ($revision_ref->isClosed()) { 2868 unset($revision_refs[$key]); 2869 continue; 2870 } 2871 2872 // Don't automatically depend on revisions authored by other users. 2873 if ($revision_ref->getAuthorPHID() != $viewer_phid) { 2874 unset($revision_refs[$key]); 2875 continue; 2876 } 2877 } 2878 2879 if (!$revision_refs) { 2880 return null; 2881 } 2882 2883 if (count($revision_refs) > 1) { 2884 return null; 2885 } 2886 2887 return head($revision_refs); 2888 } 2889 2890} 2891