diff --git a/src/__phutil_library_map__.php b/src/__phutil_library_map__.php --- a/src/__phutil_library_map__.php +++ b/src/__phutil_library_map__.php @@ -218,7 +218,7 @@ 'ArcanistGitAPI' => 'repository/api/ArcanistGitAPI.php', 'ArcanistGitCommitMessageHardpointQuery' => 'query/ArcanistGitCommitMessageHardpointQuery.php', 'ArcanistGitCommitSymbolCommitHardpointQuery' => 'ref/commit/ArcanistGitCommitSymbolCommitHardpointQuery.php', - 'ArcanistGitLandEngine' => 'land/ArcanistGitLandEngine.php', + 'ArcanistGitLandEngine' => 'land/engine/ArcanistGitLandEngine.php', 'ArcanistGitLocalState' => 'repository/state/ArcanistGitLocalState.php', 'ArcanistGitUpstreamPath' => 'repository/api/ArcanistGitUpstreamPath.php', 'ArcanistGitWorkingCopy' => 'workingcopy/ArcanistGitWorkingCopy.php', @@ -288,7 +288,11 @@ 'ArcanistKeywordCasingXHPASTLinterRuleTestCase' => 'lint/linter/xhpast/rules/__tests__/ArcanistKeywordCasingXHPASTLinterRuleTestCase.php', 'ArcanistLambdaFuncFunctionXHPASTLinterRule' => 'lint/linter/xhpast/rules/ArcanistLambdaFuncFunctionXHPASTLinterRule.php', 'ArcanistLambdaFuncFunctionXHPASTLinterRuleTestCase' => 'lint/linter/xhpast/rules/__tests__/ArcanistLambdaFuncFunctionXHPASTLinterRuleTestCase.php', - 'ArcanistLandEngine' => 'land/ArcanistLandEngine.php', + 'ArcanistLandCommit' => 'land/ArcanistLandCommit.php', + 'ArcanistLandCommitSet' => 'land/ArcanistLandCommitSet.php', + 'ArcanistLandEngine' => 'land/engine/ArcanistLandEngine.php', + 'ArcanistLandSymbol' => 'land/ArcanistLandSymbol.php', + 'ArcanistLandTarget' => 'land/ArcanistLandTarget.php', 'ArcanistLandWorkflow' => 'workflow/ArcanistLandWorkflow.php', 'ArcanistLanguageConstructParenthesesXHPASTLinterRule' => 'lint/linter/xhpast/rules/ArcanistLanguageConstructParenthesesXHPASTLinterRule.php', 'ArcanistLanguageConstructParenthesesXHPASTLinterRuleTestCase' => 'lint/linter/xhpast/rules/__tests__/ArcanistLanguageConstructParenthesesXHPASTLinterRuleTestCase.php', @@ -320,9 +324,13 @@ 'ArcanistLowercaseFunctionsXHPASTLinterRule' => 'lint/linter/xhpast/rules/ArcanistLowercaseFunctionsXHPASTLinterRule.php', 'ArcanistLowercaseFunctionsXHPASTLinterRuleTestCase' => 'lint/linter/xhpast/rules/__tests__/ArcanistLowercaseFunctionsXHPASTLinterRuleTestCase.php', 'ArcanistMercurialAPI' => 'repository/api/ArcanistMercurialAPI.php', + 'ArcanistMercurialCommitMessageHardpointQuery' => 'query/ArcanistMercurialCommitMessageHardpointQuery.php', + 'ArcanistMercurialLandEngine' => 'land/engine/ArcanistMercurialLandEngine.php', + 'ArcanistMercurialLocalState' => 'repository/state/ArcanistMercurialLocalState.php', 'ArcanistMercurialParser' => 'repository/parser/ArcanistMercurialParser.php', 'ArcanistMercurialParserTestCase' => 'repository/parser/__tests__/ArcanistMercurialParserTestCase.php', 'ArcanistMercurialWorkingCopy' => 'workingcopy/ArcanistMercurialWorkingCopy.php', + 'ArcanistMercurialWorkingCopyRevisionHardpointQuery' => 'query/ArcanistMercurialWorkingCopyRevisionHardpointQuery.php', 'ArcanistMergeConflictLinter' => 'lint/linter/ArcanistMergeConflictLinter.php', 'ArcanistMergeConflictLinterTestCase' => 'lint/linter/__tests__/ArcanistMergeConflictLinterTestCase.php', 'ArcanistMessageRevisionHardpointQuery' => 'query/ArcanistMessageRevisionHardpointQuery.php', @@ -524,6 +532,7 @@ 'ArcanistWorkflowArgument' => 'toolset/ArcanistWorkflowArgument.php', 'ArcanistWorkflowGitHardpointQuery' => 'query/ArcanistWorkflowGitHardpointQuery.php', 'ArcanistWorkflowInformation' => 'toolset/ArcanistWorkflowInformation.php', + 'ArcanistWorkflowMercurialHardpointQuery' => 'query/ArcanistWorkflowMercurialHardpointQuery.php', 'ArcanistWorkingCopy' => 'workingcopy/ArcanistWorkingCopy.php', 'ArcanistWorkingCopyCommitHardpointQuery' => 'query/ArcanistWorkingCopyCommitHardpointQuery.php', 'ArcanistWorkingCopyConfigurationSource' => 'config/source/ArcanistWorkingCopyConfigurationSource.php', @@ -1298,8 +1307,12 @@ 'ArcanistKeywordCasingXHPASTLinterRuleTestCase' => 'ArcanistXHPASTLinterRuleTestCase', 'ArcanistLambdaFuncFunctionXHPASTLinterRule' => 'ArcanistXHPASTLinterRule', 'ArcanistLambdaFuncFunctionXHPASTLinterRuleTestCase' => 'ArcanistXHPASTLinterRuleTestCase', + 'ArcanistLandCommit' => 'Phobject', + 'ArcanistLandCommitSet' => 'Phobject', 'ArcanistLandEngine' => 'Phobject', - 'ArcanistLandWorkflow' => 'ArcanistWorkflow', + 'ArcanistLandSymbol' => 'Phobject', + 'ArcanistLandTarget' => 'Phobject', + 'ArcanistLandWorkflow' => 'ArcanistArcWorkflow', 'ArcanistLanguageConstructParenthesesXHPASTLinterRule' => 'ArcanistXHPASTLinterRule', 'ArcanistLanguageConstructParenthesesXHPASTLinterRuleTestCase' => 'ArcanistXHPASTLinterRuleTestCase', 'ArcanistLesscLinter' => 'ArcanistExternalLinter', @@ -1330,9 +1343,13 @@ 'ArcanistLowercaseFunctionsXHPASTLinterRule' => 'ArcanistXHPASTLinterRule', 'ArcanistLowercaseFunctionsXHPASTLinterRuleTestCase' => 'ArcanistXHPASTLinterRuleTestCase', 'ArcanistMercurialAPI' => 'ArcanistRepositoryAPI', + 'ArcanistMercurialCommitMessageHardpointQuery' => 'ArcanistWorkflowMercurialHardpointQuery', + 'ArcanistMercurialLandEngine' => 'ArcanistLandEngine', + 'ArcanistMercurialLocalState' => 'ArcanistRepositoryLocalState', 'ArcanistMercurialParser' => 'Phobject', 'ArcanistMercurialParserTestCase' => 'PhutilTestCase', 'ArcanistMercurialWorkingCopy' => 'ArcanistWorkingCopy', + 'ArcanistMercurialWorkingCopyRevisionHardpointQuery' => 'ArcanistWorkflowMercurialHardpointQuery', 'ArcanistMergeConflictLinter' => 'ArcanistLinter', 'ArcanistMergeConflictLinterTestCase' => 'ArcanistLinterTestCase', 'ArcanistMessageRevisionHardpointQuery' => 'ArcanistRuntimeHardpointQuery', @@ -1544,6 +1561,7 @@ 'ArcanistWorkflowArgument' => 'Phobject', 'ArcanistWorkflowGitHardpointQuery' => 'ArcanistRuntimeHardpointQuery', 'ArcanistWorkflowInformation' => 'Phobject', + 'ArcanistWorkflowMercurialHardpointQuery' => 'ArcanistRuntimeHardpointQuery', 'ArcanistWorkingCopy' => 'Phobject', 'ArcanistWorkingCopyCommitHardpointQuery' => 'ArcanistRuntimeHardpointQuery', 'ArcanistWorkingCopyConfigurationSource' => 'ArcanistFilesystemConfigurationSource', diff --git a/src/config/arc/ArcanistArcConfigurationEngineExtension.php b/src/config/arc/ArcanistArcConfigurationEngineExtension.php --- a/src/config/arc/ArcanistArcConfigurationEngineExtension.php +++ b/src/config/arc/ArcanistArcConfigurationEngineExtension.php @@ -136,19 +136,15 @@ array( 'origin', )), - id(new ArcanistBoolConfigOption()) - ->setKey('history.immutable') + id(new ArcanistStringConfigOption()) + ->setKey('arc.land.strategy') ->setSummary( pht( - 'Configure use of history mutation operations like amends '. - 'and rebases.')) + 'Configure a default merge strategy for "arc land".')) ->setHelp( pht( - 'If this option is set to "true", Arcanist will treat the '. - 'repository history as immutable and will never issue '. - 'commands which rewrite repository history (like amends or '. - 'rebases). This option defaults to "true" in Mercurial, '. - '"false" in Git, and has no effect in Subversion.')), + 'Specifies the default behavior when "arc land" is run with '. + 'no "--strategy" flag.')), ); } diff --git a/src/land/ArcanistGitLandEngine.php b/src/land/ArcanistGitLandEngine.php deleted file mode 100644 --- a/src/land/ArcanistGitLandEngine.php +++ /dev/null @@ -1,913 +0,0 @@ -isGitPerforce = $is_git_perforce; - return $this; - } - - private function getIsGitPerforce() { - return $this->isGitPerforce; - } - - public function parseArguments() { - $api = $this->getRepositoryAPI(); - - $onto = $this->getEngineOnto(); - $this->setTargetOnto($onto); - - $remote = $this->getEngineRemote(); - - $is_pushable = $api->isPushableRemote($remote); - $is_perforce = $api->isPerforceRemote($remote); - - if (!$is_pushable && !$is_perforce) { - throw new PhutilArgumentUsageException( - pht( - 'No pushable remote "%s" exists. Use the "--remote" flag to choose '. - 'a valid, pushable remote to land changes onto.', - $remote)); - } - - if ($is_perforce) { - $this->setIsGitPerforce(true); - $this->writeWarn( - pht('P4 MODE'), - pht( - 'Operating in Git/Perforce mode after selecting a Perforce '. - 'remote.')); - - if (!$this->getShouldSquash()) { - throw new PhutilArgumentUsageException( - pht( - 'Perforce mode does not support the "merge" land strategy. '. - 'Use the "squash" land strategy when landing to a Perforce '. - 'remote (you can use "--squash" to select this strategy).')); - } - } - - $this->setTargetRemote($remote); - } - - public function execute() { - $this->verifySourceAndTargetExist(); - $this->fetchTarget(); - - $this->printLandingCommits(); - - if ($this->getShouldPreview()) { - $this->writeInfo( - pht('PREVIEW'), - pht('Completed preview of operation.')); - return; - } - - $this->saveLocalState(); - - try { - $this->identifyRevision(); - $this->updateWorkingCopy(); - - if ($this->getShouldHold()) { - $this->didHoldChanges(); - } else { - $this->pushChange(); - $this->reconcileLocalState(); - - $api = $this->getRepositoryAPI(); - $api->execxLocal('submodule update --init --recursive'); - - if ($this->getShouldKeep()) { - echo tsprintf( - "%s\n", - pht('Keeping local branch.')); - } else { - $this->destroyLocalBranch(); - } - - $this->writeOkay( - pht('DONE'), - pht('Landed changes.')); - } - - $this->restoreWhenDestroyed = false; - } catch (Exception $ex) { - $this->restoreLocalState(); - throw $ex; - } - } - - public function __destruct() { - if ($this->restoreWhenDestroyed) { - $this->writeWarn( - pht('INTERRUPTED!'), - pht('Restoring working copy to its original state.')); - - $this->restoreLocalState(); - } - } - - protected function getLandingCommits() { - $api = $this->getRepositoryAPI(); - - list($out) = $api->execxLocal( - 'log --oneline %s..%s --', - $this->getTargetFullRef(), - $this->sourceCommit); - - $out = trim($out); - - if (!strlen($out)) { - return array(); - } else { - return phutil_split_lines($out, false); - } - } - - private function identifyRevision() { - $api = $this->getRepositoryAPI(); - $api->execxLocal('checkout %s --', $this->getSourceRef()); - call_user_func($this->getBuildMessageCallback(), $this); - } - - private function verifySourceAndTargetExist() { - $api = $this->getRepositoryAPI(); - - list($err) = $api->execManualLocal( - 'rev-parse --verify %s', - $this->getTargetFullRef()); - - if ($err) { - $this->writeWarn( - pht('TARGET'), - pht( - 'No local ref exists for branch "%s" in remote "%s", attempting '. - 'fetch...', - $this->getTargetOnto(), - $this->getTargetRemote())); - - $api->execManualLocal( - 'fetch %s %s --', - $this->getTargetRemote(), - $this->getTargetOnto()); - - list($err) = $api->execManualLocal( - 'rev-parse --verify %s', - $this->getTargetFullRef()); - if ($err) { - throw new Exception( - pht( - 'Branch "%s" does not exist in remote "%s".', - $this->getTargetOnto(), - $this->getTargetRemote())); - } - - $this->writeInfo( - pht('FETCHED'), - pht( - 'Fetched branch "%s" from remote "%s".', - $this->getTargetOnto(), - $this->getTargetRemote())); - } - - list($err, $stdout) = $api->execManualLocal( - 'rev-parse --verify %s', - $this->getSourceRef()); - - if ($err) { - throw new Exception( - pht( - 'Branch "%s" does not exist in the local working copy.', - $this->getSourceRef())); - } - - $this->sourceCommit = trim($stdout); - } - - private function fetchTarget() { - $api = $this->getRepositoryAPI(); - - $ref = $this->getTargetFullRef(); - - // NOTE: Although this output isn't hugely useful, we need to passthru - // instead of using a subprocess here because `git fetch` may prompt the - // user to enter a password if they're fetching over HTTP with basic - // authentication. See T10314. - - if ($this->getIsGitPerforce()) { - $this->writeInfo( - pht('P4 SYNC'), - pht('Synchronizing "%s" from Perforce...', $ref)); - - $sync_ref = sprintf( - 'refs/remotes/%s/%s', - $this->getTargetRemote(), - $this->getTargetOnto()); - - $err = $api->execPassthru( - 'p4 sync --silent --branch %R --', - $sync_ref); - - if ($err) { - throw new ArcanistUsageException( - pht( - 'Perforce sync failed! Fix the error and run "arc land" again.')); - } - } else { - $this->writeInfo( - pht('FETCH'), - pht('Fetching "%s"...', $ref)); - - $err = $api->execPassthru( - 'fetch --quiet -- %s %s', - $this->getTargetRemote(), - $this->getTargetOnto()); - - if ($err) { - throw new ArcanistUsageException( - pht( - 'Fetch failed! Fix the error and run "arc land" again.')); - } - } - } - - private function updateWorkingCopy() { - $api = $this->getRepositoryAPI(); - $source = $this->sourceCommit; - - $api->execxLocal( - 'checkout %s --', - $this->getTargetFullRef()); - - list($original_author, $original_date) = $this->getAuthorAndDate($source); - - try { - if ($this->getShouldSquash()) { - // NOTE: We're explicitly specifying "--ff" to override the presence - // of "merge.ff" options in user configuration. - - $api->execxLocal( - 'merge --no-stat --no-commit --ff --squash -- %s', - $source); - } else { - $api->execxLocal( - 'merge --no-stat --no-commit --no-ff -- %s', - $source); - } - } catch (Exception $ex) { - $api->execManualLocal('merge --abort'); - $api->execManualLocal('reset --hard HEAD --'); - - throw new Exception( - pht( - 'Local "%s" does not merge cleanly into "%s". Merge or rebase '. - 'local changes so they can merge cleanly.', - $this->getSourceRef(), - $this->getTargetFullRef())); - } - - // TODO: This could probably be cleaner by asking the API a question - // about working copy status instead of running a raw diff command. See - // discussion in T11435. - list($changes) = $api->execxLocal('diff --no-ext-diff HEAD --'); - $changes = trim($changes); - if (!strlen($changes)) { - throw new Exception( - pht( - 'Merging local "%s" into "%s" produces an empty diff. '. - 'This usually means these changes have already landed.', - $this->getSourceRef(), - $this->getTargetFullRef())); - } - - $api->execxLocal( - 'commit --author %s --date %s -F %s --', - $original_author, - $original_date, - $this->getCommitMessageFile()); - - $this->getWorkflow()->didCommitMerge(); - - list($stdout) = $api->execxLocal( - 'rev-parse --verify %s', - 'HEAD'); - $this->mergedRef = trim($stdout); - } - - private function pushChange() { - $api = $this->getRepositoryAPI(); - - if ($this->getIsGitPerforce()) { - $this->writeInfo( - pht('SUBMITTING'), - pht('Submitting changes to "%s".', $this->getTargetFullRef())); - - $config_argv = array(); - - // Skip the "git p4 submit" interactive editor workflow. We expect - // the commit message that "arc land" has built to be satisfactory. - $config_argv[] = '-c'; - $config_argv[] = 'git-p4.skipSubmitEdit=true'; - - // Skip the "git p4 submit" confirmation prompt if the user does not edit - // the submit message. - $config_argv[] = '-c'; - $config_argv[] = 'git-p4.skipSubmitEditCheck=true'; - - $flags_argv = array(); - - // Disable implicit "git p4 rebase" as part of submit. We're allowing - // the implicit "git p4 sync" to go through since this puts us in a - // state which is generally similar to the state after "git push", with - // updated remotes. - - // We could do a manual "git p4 sync" with a more narrow "--branch" - // instead, but it's not clear that this is beneficial. - $flags_argv[] = '--disable-rebase'; - - // Detect moves and submit them to Perforce as move operations. - $flags_argv[] = '-M'; - - // If we run into a conflict, abort the operation. We expect users to - // fix conflicts and run "arc land" again. - $flags_argv[] = '--conflict=quit'; - - $err = $api->execPassthru( - '%LR p4 submit %LR --commit %R --', - $config_argv, - $flags_argv, - $this->mergedRef); - - if ($err) { - throw new ArcanistUsageException( - pht( - 'Submit failed! Fix the error and run "arc land" again.')); - } - } else { - $this->writeInfo( - pht('PUSHING'), - pht('Pushing changes to "%s".', $this->getTargetFullRef())); - - $err = $api->execPassthru( - 'push -- %s %s:%s', - $this->getTargetRemote(), - $this->mergedRef, - $this->getTargetOnto()); - - if ($err) { - throw new ArcanistUsageException( - pht( - 'Push failed! Fix the error and run "arc land" again.')); - } - } - } - - private function reconcileLocalState() { - $api = $this->getRepositoryAPI(); - - // Try to put the user into the best final state we can. This is very - // complicated because users are incredibly creative and their local - // branches may have the same names as branches in the remote but no - // relationship to them. - - if ($this->localRef != $this->getSourceRef()) { - // The user ran `arc land X` but was on a different branch, so just put - // them back wherever they were before. - $this->writeInfo( - pht('RESTORE'), - pht('Switching back to "%s".', $this->localRef)); - $this->restoreLocalState(); - return; - } - - // We're going to try to find a path to the upstream target branch. We - // try in two different ways: - // - // - follow the source branch directly along tracking branches until - // we reach the upstream; or - // - follow a local branch with the same name as the target branch until - // we reach the upstream. - - // First, get the path from whatever we landed to wherever it goes. - $local_branch = $this->getSourceRef(); - - $path = $api->getPathToUpstream($local_branch); - if ($path->getLength()) { - // We may want to discard the thing we landed from the path, if we're - // going to delete it. In this case, we don't want to update it or worry - // if it's dirty. - if ($this->getSourceRef() == $this->getTargetOnto()) { - // In this case, we've done something like land "master" onto itself, - // so we do want to update the actual branch. We're going to use the - // entire path. - } else { - // Otherwise, we're going to delete the branch at the end of the - // workflow, so throw it away the most-local branch that isn't long - // for this world. - $path->removeUpstream($local_branch); - - if (!$path->getLength()) { - // The local branch tracked upstream directly; however, it - // may not be the only one to do so. If there's a local - // branch of the same name that tracks the remote, try - // switching to that. - $local_branch = $this->getTargetOnto(); - list($err) = $api->execManualLocal( - 'rev-parse --verify %s', - $local_branch); - if (!$err) { - $path = $api->getPathToUpstream($local_branch); - } - if (!$path->isConnectedToRemote()) { - $this->writeInfo( - pht('UPDATE'), - pht( - 'Local branch "%s" directly tracks remote, staying on '. - 'detached HEAD.', - $local_branch)); - return; - } - } - - $local_branch = head($path->getLocalBranches()); - } - } else { - // The source branch has no upstream, so look for a local branch with - // the same name as the target branch. This corresponds to the common - // case where you have "master" and checkout local branches from it - // with "git checkout -b feature", then land onto "master". - - $local_branch = $this->getTargetOnto(); - - list($err) = $api->execManualLocal( - 'rev-parse --verify %s', - $local_branch); - if ($err) { - $this->writeInfo( - pht('UPDATE'), - pht( - 'Local branch "%s" does not exist, staying on detached HEAD.', - $local_branch)); - return; - } - - $path = $api->getPathToUpstream($local_branch); - } - - if ($path->getCycle()) { - $this->writeWarn( - pht('LOCAL CYCLE'), - pht( - 'Local branch "%s" tracks an upstream but following it leads to '. - 'a local cycle, staying on detached HEAD.', - $local_branch)); - return; - } - - $is_perforce = $this->getIsGitPerforce(); - - if ($is_perforce) { - // If we're in Perforce mode, we don't expect to have a meaningful - // path to the remote: the "p4" remote is not a real remote, and - // "git p4" commands do not configure branch upstreams to provide - // a path. - - // Just pretend the target branch is connected directly to the remote, - // since this is effectively the behavior of Perforce and appears to - // do the right thing. - $cascade_branches = array($local_branch); - } else { - if (!$path->isConnectedToRemote()) { - $this->writeInfo( - pht('UPDATE'), - pht( - 'Local branch "%s" is not connected to a remote, staying on '. - 'detached HEAD.', - $local_branch)); - return; - } - - $remote_remote = $path->getRemoteRemoteName(); - $remote_branch = $path->getRemoteBranchName(); - - $remote_actual = $remote_remote.'/'.$remote_branch; - $remote_expect = $this->getTargetFullRef(); - if ($remote_actual != $remote_expect) { - $this->writeInfo( - pht('UPDATE'), - pht( - 'Local branch "%s" is connected to a remote ("%s") other than '. - 'the target remote ("%s"), staying on detached HEAD.', - $local_branch, - $remote_actual, - $remote_expect)); - return; - } - - // If we get this far, we have a sequence of branches which ultimately - // connect to the remote. We're going to try to update them all in reverse - // order, from most-upstream to most-local. - - $cascade_branches = $path->getLocalBranches(); - $cascade_branches = array_reverse($cascade_branches); - } - - // First, check if any of them are ahead of the remote. - - $ahead_of_remote = array(); - foreach ($cascade_branches as $cascade_branch) { - list($stdout) = $api->execxLocal( - 'log %s..%s --', - $this->mergedRef, - $cascade_branch); - $stdout = trim($stdout); - - if (strlen($stdout)) { - $ahead_of_remote[$cascade_branch] = $cascade_branch; - } - } - - // We're going to handle the last branch (the thing we ultimately intend - // to check out) differently. It's OK if it's ahead of the remote, as long - // as we just landed it. - - $local_ahead = isset($ahead_of_remote[$local_branch]); - unset($ahead_of_remote[$local_branch]); - $land_self = ($this->getTargetOnto() === $this->getSourceRef()); - - // We aren't going to pull anything if anything upstream from us is ahead - // of the remote, or the local is ahead of the remote and we didn't land - // it onto itself. - $skip_pull = ($ahead_of_remote || ($local_ahead && !$land_self)); - - if ($skip_pull) { - $this->writeInfo( - pht('UPDATE'), - pht( - 'Local "%s" is ahead of remote "%s". Checking out "%s" but '. - 'not pulling changes.', - nonempty(head($ahead_of_remote), $local_branch), - $this->getTargetFullRef(), - $local_branch)); - - $this->writeInfo( - pht('CHECKOUT'), - pht( - 'Checking out "%s".', - $local_branch)); - - $api->execxLocal('checkout %s --', $local_branch); - - return; - } - - // If nothing upstream from our nearest branch is ahead of the remote, - // pull it all. - - $cascade_targets = array(); - if (!$ahead_of_remote) { - foreach ($cascade_branches as $cascade_branch) { - if ($local_ahead && ($local_branch == $cascade_branch)) { - continue; - } - $cascade_targets[] = $cascade_branch; - } - } - - if ($is_perforce) { - // In Perforce, we've already set the remote to the right state with an - // implicit "git p4 sync" during "git p4 submit", and "git pull" isn't a - // meaningful operation. We're going to skip this step and jump down to - // the "git reset --hard" below to get everything into the right state. - } else if ($cascade_targets) { - $this->writeInfo( - pht('UPDATE'), - pht( - 'Local "%s" tracks target remote "%s", checking out and '. - 'pulling changes.', - $local_branch, - $this->getTargetFullRef())); - - foreach ($cascade_targets as $cascade_branch) { - $this->writeInfo( - pht('PULL'), - pht( - 'Checking out and pulling "%s".', - $cascade_branch)); - - $api->execxLocal('checkout %s --', $cascade_branch); - $api->execxLocal( - 'pull %s %s --', - $this->getTargetRemote(), - $cascade_branch); - } - - if (!$local_ahead) { - return; - } - } - - // In this case, the user did something like land a branch onto itself, - // and the branch is tracking the correct remote. We're going to discard - // the local state and reset it to the state we just pushed. - - $this->writeInfo( - pht('RESET'), - pht( - 'Local "%s" landed into remote "%s", resetting local branch to '. - 'remote state.', - $this->getTargetOnto(), - $this->getTargetFullRef())); - - $api->execxLocal('checkout %s --', $local_branch); - $api->execxLocal('reset --hard %s --', $this->getTargetFullRef()); - - return; - } - - private function destroyLocalBranch() { - $api = $this->getRepositoryAPI(); - $source_ref = $this->getSourceRef(); - - if ($source_ref == $this->getTargetOnto()) { - // If we landed a branch into a branch with the same name, so don't - // destroy it. This prevents us from cleaning up "master" if you're - // landing master into itself. - return; - } - - // TODO: Maybe this should also recover the proper upstream? - - // See T10321. If we were not landing a branch, don't try to clean it up. - // This happens most often when landing from a detached HEAD. - $is_branch = $this->isBranch($source_ref); - if (!$is_branch) { - echo tsprintf( - "%s\n", - pht( - '(Source "%s" is not a branch, leaving working copy as-is.)', - $source_ref)); - return; - } - - $recovery_command = csprintf( - 'git checkout -b %R %R', - $source_ref, - $this->sourceCommit); - - echo tsprintf( - "%s\n", - pht('Cleaning up branch "%s"...', $source_ref)); - - echo tsprintf( - "%s\n", - pht('(Use `%s` if you want it back.)', $recovery_command)); - - $api->execxLocal('branch -D -- %s', $source_ref); - } - - /** - * Save the local working copy state so we can restore it later. - */ - private function saveLocalState() { - $api = $this->getRepositoryAPI(); - - $this->localCommit = $api->getWorkingCopyRevision(); - - list($ref) = $api->execxLocal('rev-parse --abbrev-ref HEAD'); - $ref = trim($ref); - if ($ref === 'HEAD') { - $ref = $this->localCommit; - } - - $this->localRef = $ref; - - $this->restoreWhenDestroyed = true; - } - - /** - * Restore the working copy to the state it was in before we started - * performing writes. - */ - private function restoreLocalState() { - $api = $this->getRepositoryAPI(); - - $api->execxLocal('checkout %s --', $this->localRef); - $api->execxLocal('reset --hard %s --', $this->localCommit); - $api->execxLocal('submodule update --init --recursive'); - - $this->restoreWhenDestroyed = false; - } - - private function getTargetFullRef() { - return $this->getTargetRemote().'/'.$this->getTargetOnto(); - } - - private function getAuthorAndDate($commit) { - $api = $this->getRepositoryAPI(); - - // TODO: This is working around Windows escaping problems, see T8298. - - list($info) = $api->execxLocal( - 'log -n1 --format=%C %s --', - '%aD%n%an%n%ae', - $commit); - - $info = trim($info); - list($date, $author, $email) = explode("\n", $info, 3); - - return array( - "$author <{$email}>", - $date, - ); - } - - private function didHoldChanges() { - if ($this->getIsGitPerforce()) { - $this->writeInfo( - pht('HOLD'), - pht( - 'Holding change locally, it has not been submitted.')); - - $push_command = csprintf( - '$ git p4 submit -M --commit %R --', - $this->mergedRef); - } else { - $this->writeInfo( - pht('HOLD'), - pht( - 'Holding change locally, it has not been pushed.')); - - $push_command = csprintf( - '$ git push -- %R %R:%R', - $this->getTargetRemote(), - $this->mergedRef, - $this->getTargetOnto()); - } - - $restore_command = csprintf( - '$ git checkout %R --', - $this->localRef); - - echo tsprintf( - "\n%s\n\n". - "%s\n\n". - " **%s**\n\n". - "%s\n\n". - " **%s**\n\n". - "%s\n", - pht( - 'This local working copy now contains the merged changes in a '. - 'detached state.'), - pht('You can push the changes manually with this command:'), - $push_command, - pht( - 'You can go back to how things were before you ran "arc land" with '. - 'this command:'), - $restore_command, - pht( - 'Local branches have not been changed, and are still in exactly the '. - 'same state as before.')); - } - - private function isBranch($ref) { - $api = $this->getRepositoryAPI(); - - list($err) = $api->execManualLocal( - 'show-ref --verify --quiet -- %R', - 'refs/heads/'.$ref); - - return !$err; - } - - private function getEngineOnto() { - $source_ref = $this->getSourceRef(); - - $onto = $this->getOntoArgument(); - if ($onto !== null) { - $this->writeInfo( - pht('TARGET'), - pht( - 'Landing onto "%s", selected with the "--onto" flag.', - $onto)); - return $onto; - } - - $api = $this->getRepositoryAPI(); - $path = $api->getPathToUpstream($source_ref); - - if ($path->getLength()) { - $cycle = $path->getCycle(); - if ($cycle) { - $this->writeWarn( - pht('LOCAL CYCLE'), - pht( - 'Local branch tracks an upstream, but following it leads to a '. - 'local cycle; ignoring branch upstream.')); - - echo tsprintf( - "\n %s\n\n", - implode(' -> ', $cycle)); - - } else { - if ($path->isConnectedToRemote()) { - $onto = $path->getRemoteBranchName(); - $this->writeInfo( - pht('TARGET'), - pht( - 'Landing onto "%s", selected by following tracking branches '. - 'upstream to the closest remote.', - $onto)); - return $onto; - } else { - $this->writeInfo( - pht('NO PATH TO UPSTREAM'), - pht( - 'Local branch tracks an upstream, but there is no path '. - 'to a remote; ignoring branch upstream.')); - } - } - } - - $workflow = $this->getWorkflow(); - - $config_key = 'arc.land.onto.default'; - $onto = $workflow->getConfigFromAnySource($config_key); - if ($onto !== null) { - $this->writeInfo( - pht('TARGET'), - pht( - 'Landing onto "%s", selected by "%s" configuration.', - $onto, - $config_key)); - return $onto; - } - - $onto = 'master'; - $this->writeInfo( - pht('TARGET'), - pht( - 'Landing onto "%s", the default target under git.', - $onto)); - - return $onto; - } - - private function getEngineRemote() { - $source_ref = $this->getSourceRef(); - - $remote = $this->getRemoteArgument(); - if ($remote !== null) { - $this->writeInfo( - pht('REMOTE'), - pht( - 'Using remote "%s", selected with the "--remote" flag.', - $remote)); - return $remote; - } - - $api = $this->getRepositoryAPI(); - $path = $api->getPathToUpstream($source_ref); - - $remote = $path->getRemoteRemoteName(); - if ($remote !== null) { - $this->writeInfo( - pht('REMOTE'), - pht( - 'Using remote "%s", selected by following tracking branches '. - 'upstream to the closest remote.', - $remote)); - return $remote; - } - - $remote = 'p4'; - if ($api->isPerforceRemote($remote)) { - $this->writeInfo( - pht('REMOTE'), - pht( - 'Using Perforce remote "%s". The existence of this remote implies '. - 'this working copy was synchronized from a Perforce repository.', - $remote)); - return $remote; - } - - $remote = 'origin'; - $this->writeInfo( - pht('REMOTE'), - pht( - 'Using remote "%s", the default remote under Git.', - $remote)); - - return $remote; - } - -} diff --git a/src/land/ArcanistLandCommit.php b/src/land/ArcanistLandCommit.php new file mode 100644 --- /dev/null +++ b/src/land/ArcanistLandCommit.php @@ -0,0 +1,149 @@ +hash = $hash; + return $this; + } + + public function getHash() { + return $this->hash; + } + + public function setSummary($summary) { + $this->summary = $summary; + return $this; + } + + public function getSummary() { + return $this->summary; + } + + public function getDisplaySummary() { + if ($this->displaySummary === null) { + $this->displaySummary = id(new PhutilUTF8StringTruncator()) + ->setMaximumGlyphs(64) + ->truncateString($this->getSummary()); + } + return $this->displaySummary; + } + + public function setParents(array $parents) { + $this->parents = $parents; + return $this; + } + + public function getParents() { + return $this->parents; + } + + public function addSymbol(ArcanistLandSymbol $symbol) { + $this->symbols[] = $symbol; + return $this; + } + + public function getSymbols() { + return $this->symbols; + } + + public function setExplicitRevisionref(ArcanistRevisionRef $ref) { + $this->explicitRevisionRef = $ref; + return $this; + } + + public function getExplicitRevisionref() { + return $this->explicitRevisionRef; + } + + public function setParentCommits(array $parent_commits) { + $this->parentCommits = $parent_commits; + return $this; + } + + public function getParentCommits() { + return $this->parentCommits; + } + + public function setIsHeadCommit($is_head_commit) { + $this->isHeadCommit = $is_head_commit; + return $this; + } + + public function getIsHeadCommit() { + return $this->isHeadCommit; + } + + public function setIsImplicitCommit($is_implicit_commit) { + $this->isImplicitCommit = $is_implicit_commit; + return $this; + } + + public function getIsImplicitCommit() { + return $this->isImplicitCommit; + } + + public function getAncestorRevisionPHIDs() { + $phids = array(); + + foreach ($this->getParentCommits() as $parent_commit) { + $phids += $parent_commit->getAncestorRevisionPHIDs(); + } + + $revision_ref = $this->getRevisionRef(); + if ($revision_ref) { + $phids[$revision_ref->getPHID()] = $revision_ref->getPHID(); + } + + return $phids; + } + + public function getRevisionRef() { + if ($this->revisionRef === false) { + $this->revisionRef = $this->newRevisionRef(); + } + + return $this->revisionRef; + } + + private function newRevisionRef() { + $revision_ref = $this->getExplicitRevisionRef(); + if ($revision_ref) { + return $revision_ref; + } + + $parent_refs = array(); + foreach ($this->getParentCommits() as $parent_commit) { + $parent_ref = $parent_commit->getRevisionRef(); + if ($parent_ref) { + $parent_refs[$parent_ref->getPHID()] = $parent_ref; + } + } + + if (count($parent_refs) > 1) { + throw new Exception( + pht( + 'Too many distinct parent refs!')); + } + + if ($parent_refs) { + return head($parent_refs); + } + + return null; + } + + +} diff --git a/src/land/ArcanistLandCommitSet.php b/src/land/ArcanistLandCommitSet.php new file mode 100644 --- /dev/null +++ b/src/land/ArcanistLandCommitSet.php @@ -0,0 +1,52 @@ +revisionRef = $revision_ref; + return $this; + } + + public function getRevisionRef() { + return $this->revisionRef; + } + + public function setCommits(array $commits) { + assert_instances_of($commits, 'ArcanistLandCommit'); + $this->commits = $commits; + + $revision_phid = $this->getRevisionRef()->getPHID(); + foreach ($commits as $commit) { + $revision_ref = $commit->getExplicitRevisionRef(); + + if ($revision_ref) { + if ($revision_ref->getPHID() === $revision_phid) { + continue; + } + } + + $commit->setIsImplicitCommit(true); + } + + return $this; + } + + public function getCommits() { + return $this->commits; + } + + public function hasImplicitCommits() { + foreach ($this->commits as $commit) { + if ($commit->getIsImplicitCommit()) { + return true; + } + } + + return false; + } + +} diff --git a/src/land/ArcanistLandEngine.php b/src/land/ArcanistLandEngine.php deleted file mode 100644 --- a/src/land/ArcanistLandEngine.php +++ /dev/null @@ -1,182 +0,0 @@ -workflow = $workflow; - return $this; - } - - final public function getWorkflow() { - return $this->workflow; - } - - final public function setRepositoryAPI( - ArcanistRepositoryAPI $repository_api) { - $this->repositoryAPI = $repository_api; - return $this; - } - - final public function getRepositoryAPI() { - return $this->repositoryAPI; - } - - final public function setShouldHold($should_hold) { - $this->shouldHold = $should_hold; - return $this; - } - - final public function getShouldHold() { - return $this->shouldHold; - } - - final public function setShouldKeep($should_keep) { - $this->shouldKeep = $should_keep; - return $this; - } - - final public function getShouldKeep() { - return $this->shouldKeep; - } - - final public function setShouldSquash($should_squash) { - $this->shouldSquash = $should_squash; - return $this; - } - - final public function getShouldSquash() { - return $this->shouldSquash; - } - - final public function setShouldPreview($should_preview) { - $this->shouldPreview = $should_preview; - return $this; - } - - final public function getShouldPreview() { - return $this->shouldPreview; - } - - final public function setTargetRemote($target_remote) { - $this->targetRemote = $target_remote; - return $this; - } - - final public function getTargetRemote() { - return $this->targetRemote; - } - - final public function setTargetOnto($target_onto) { - $this->targetOnto = $target_onto; - return $this; - } - - final public function getTargetOnto() { - return $this->targetOnto; - } - - final public function setSourceRef($source_ref) { - $this->sourceRef = $source_ref; - return $this; - } - - final public function getSourceRef() { - return $this->sourceRef; - } - - final public function setBuildMessageCallback($build_message_callback) { - $this->buildMessageCallback = $build_message_callback; - return $this; - } - - final public function getBuildMessageCallback() { - return $this->buildMessageCallback; - } - - final public function setCommitMessageFile($commit_message_file) { - $this->commitMessageFile = $commit_message_file; - return $this; - } - - final public function getCommitMessageFile() { - return $this->commitMessageFile; - } - - final public function setRemoteArgument($remote_argument) { - $this->remoteArgument = $remote_argument; - return $this; - } - - final public function getRemoteArgument() { - return $this->remoteArgument; - } - - final public function setOntoArgument($onto_argument) { - $this->ontoArgument = $onto_argument; - return $this; - } - - final public function getOntoArgument() { - return $this->ontoArgument; - } - - abstract public function parseArguments(); - abstract public function execute(); - - abstract protected function getLandingCommits(); - - protected function printLandingCommits() { - $logs = $this->getLandingCommits(); - - if (!$logs) { - throw new ArcanistUsageException( - pht( - 'There are no commits on "%s" which are not already present on '. - 'the target.', - $this->getSourceRef())); - } - - $list = id(new PhutilConsoleList()) - ->setWrap(false) - ->addItems($logs); - - id(new PhutilConsoleBlock()) - ->addParagraph( - pht( - 'These %s commit(s) will be landed:', - new PhutilNumber(count($logs)))) - ->addList($list) - ->draw(); - } - - protected function writeWarn($title, $message) { - return $this->getWorkflow()->writeWarn($title, $message); - } - - protected function writeInfo($title, $message) { - return $this->getWorkflow()->writeInfo($title, $message); - } - - protected function writeOkay($title, $message) { - return $this->getWorkflow()->writeOkay($title, $message); - } - - -} diff --git a/src/land/ArcanistLandSymbol.php b/src/land/ArcanistLandSymbol.php new file mode 100644 --- /dev/null +++ b/src/land/ArcanistLandSymbol.php @@ -0,0 +1,27 @@ +symbol = $symbol; + return $this; + } + + public function getSymbol() { + return $this->symbol; + } + + public function setCommit($commit) { + $this->commit = $commit; + return $this; + } + + public function getCommit() { + return $this->commit; + } + +} diff --git a/src/land/ArcanistLandTarget.php b/src/land/ArcanistLandTarget.php new file mode 100644 --- /dev/null +++ b/src/land/ArcanistLandTarget.php @@ -0,0 +1,41 @@ +remote = $remote; + return $this; + } + + public function getRemote() { + return $this->remote; + } + + public function setRef($ref) { + $this->ref = $ref; + return $this; + } + + public function getRef() { + return $this->ref; + } + + public function getLandTargetKey() { + return sprintf('%s/%s', $this->getRemote(), $this->getRef()); + } + + public function setLandTargetCommit($commit) { + $this->commit = $commit; + return $this; + } + + public function getLandTargetCommit() { + return $this->commit; + } + +} diff --git a/src/land/engine/ArcanistGitLandEngine.php b/src/land/engine/ArcanistGitLandEngine.php new file mode 100644 --- /dev/null +++ b/src/land/engine/ArcanistGitLandEngine.php @@ -0,0 +1,1362 @@ +isGitPerforce = $is_git_perforce; + return $this; + } + + private function getIsGitPerforce() { + return $this->isGitPerforce; + } + + protected function pruneBranches(array $sets) { + $old_commits = array(); + foreach ($sets as $set) { + $hash = last($set->getCommits())->getHash(); + $old_commits[] = $hash; + } + + $branch_map = $this->getBranchesForCommits( + $old_commits, + $is_contains = false); + + $api = $this->getRepositoryAPI(); + foreach ($branch_map as $branch_name => $branch_hash) { + $recovery_command = csprintf( + 'git checkout -b %s %s', + $branch_name, + $this->getDisplayHash($branch_hash)); + + echo tsprintf( + "%s\n", + pht('Cleaning up branch "%s"...', $branch_name)); + + echo tsprintf( + "%s\n", + pht('(Use `%s` if you want it back.)', $recovery_command)); + + $api->execxLocal('branch -D -- %s', $branch_name); + $this->deletedBranches[$branch_name] = true; + } + } + + private function getBranchesForCommits(array $hashes, $is_contains) { + $api = $this->getRepositoryAPI(); + + $format = '%(refname) %(objectname)'; + + $result = array(); + foreach ($hashes as $hash) { + if ($is_contains) { + $command = csprintf( + 'for-each-ref --contains %s --format %s --', + $hash, + $format); + } else { + $command = csprintf( + 'for-each-ref --points-at %s --format %s --', + $hash, + $format); + } + + list($foreach_lines) = $api->execxLocal('%C', $command); + $foreach_lines = phutil_split_lines($foreach_lines, false); + + foreach ($foreach_lines as $line) { + if (!strlen($line)) { + continue; + } + + $expect_parts = 2; + $parts = explode(' ', $line, $expect_parts); + if (count($parts) !== $expect_parts) { + throw new Exception( + pht( + 'Failed to explode line "%s".', + $line)); + } + + $ref_name = $parts[0]; + $ref_hash = $parts[1]; + + $matches = null; + $ok = preg_match('(^refs/heads/(.*)\z)', $ref_name, $matches); + if ($ok === false) { + throw new Exception( + pht( + 'Failed to match against branch pattern "%s".', + $line)); + } + + if (!$ok) { + continue; + } + + $result[$matches[1]] = $ref_hash; + } + } + + return $result; + } + + protected function cascadeState(ArcanistLandCommitSet $set, $into_commit) { + $api = $this->getRepositoryAPI(); + $log = $this->getLogEngine(); + + // This has no effect when we're executing a merge strategy. + if (!$this->isSquashStrategy()) { + return; + } + + $old_commit = last($set->getCommits())->getHash(); + $new_commit = $into_commit; + + $branch_map = $this->getBranchesForCommits( + array($old_commit), + $is_contains = true); + + $log = $this->getLogEngine(); + foreach ($branch_map as $branch_name => $branch_head) { + // If this branch just points at the old state, don't bother rebasing + // it. We'll update or delete it later. + if ($branch_head === $old_commit) { + continue; + } + + $log->writeStatus( + pht('CASCADE'), + pht( + 'Rebasing "%s" onto landed state...', + $branch_name)); + + try { + $api->execxLocal( + 'rebase --onto %s -- %s %s', + $new_commit, + $old_commit, + $branch_name); + } catch (CommandException $ex) { + // TODO: If we have a stashed state or are not running in incremental + // mode: abort the rebase, restore the local state, and pop the stash. + // Otherwise, drop the user out here. + throw $ex; + } + } + } + + private function fetchTarget(ArcanistLandTarget $target) { + $api = $this->getRepositoryAPI(); + $log = $this->getLogEngine(); + + // NOTE: Although this output isn't hugely useful, we need to passthru + // instead of using a subprocess here because `git fetch` may prompt the + // user to enter a password if they're fetching over HTTP with basic + // authentication. See T10314. + + if ($this->getIsGitPerforce()) { + $log->writeStatus( + pht('P4 SYNC'), + pht( + 'Synchronizing "%s" from Perforce...', + $target->getRef())); + + $err = $api->execPassthru( + 'p4 sync --silent --branch %s --', + $target->getRemote().'/'.$target->getRef()); + + if ($err) { + throw new ArcanistUsageException( + pht( + 'Perforce sync failed! Fix the error and run "arc land" again.')); + } + + return $this->getLandTargetLocalCommit($target); + } + + $exists = $this->getLandTargetLocalExists($target); + if (!$exists) { + $log->writeWarning( + pht('TARGET'), + pht( + 'No local copy of ref "%s" in remote "%s" exists, attempting '. + 'fetch...', + $target->getRef(), + $target->getRemote())); + + $this->fetchLandTarget($target, $ignore_failure = true); + + $exists = $this->getLandTargetLocalExists($target); + if (!$exists) { + return null; + } + + $log->writeStatus( + pht('FETCHED'), + pht( + 'Fetched ref "%s" from remote "%s".', + $target->getRef(), + $target->getRemote())); + + return $this->getLandTargetLocalCommit($target); + } + + $log->writeStatus( + pht('FETCH'), + pht( + 'Fetching "%s" from remote "%s"...', + $target->getRef(), + $target->getRemote())); + + $this->fetchLandTarget($target, $ignore_failure = false); + + return $this->getLandTargetLocalCommit($target); + } + + private function updateWorkingCopy($into_commit) { + $api = $this->getRepositoryAPI(); + if ($into_commit === null) { + throw new Exception('TODO: Author a new empty state.'); + } else { + $api->execxLocal('checkout %s --', $into_commit); + } + } + + protected function executeMerge(ArcanistLandCommitSet $set, $into_commit) { + $api = $this->getRepositoryAPI(); + + $this->updateWorkingCopy($into_commit); + + $commits = $set->getCommits(); + $source_commit = last($commits)->getHash(); + + // NOTE: See T11435 for some history. See PHI1727 for a case where a user + // modified their working copy while running "arc land". This attempts to + // resist incorrectly detecting simultaneous working copy modifications + // as changes. + + list($changes) = $api->execxLocal( + 'diff --no-ext-diff HEAD..%s --', + $source_commit); + $changes = trim($changes); + if (!strlen($changes)) { + + // TODO: We could make a more significant effort to identify the + // human-readable symbol which led us to try to land this ref. + + throw new PhutilArgumentUsageException( + pht( + 'Merging local "%s" into "%s" produces an empty diff. '. + 'This usually means these changes have already landed.', + $this->getDisplayHash($source_commit), + $this->getDisplayHash($into_commit))); + } + + list($original_author, $original_date) = $this->getAuthorAndDate( + $source_commit); + + try { + if ($this->isSquashStrategy()) { + // NOTE: We're explicitly specifying "--ff" to override the presence + // of "merge.ff" options in user configuration. + + $api->execxLocal( + 'merge --no-stat --no-commit --ff --squash -- %s', + $source_commit); + } else { + $api->execxLocal( + 'merge --no-stat --no-commit --no-ff -- %s', + $source_commit); + } + } catch (CommandException $ex) { + $api->execManualLocal('merge --abort'); + $api->execManualLocal('reset --hard HEAD --'); + + throw new PhutilArgumentUsageException( + pht( + 'Local "%s" does not merge cleanly into "%s". Merge or rebase '. + 'local changes so they can merge cleanly.', + $source_commit, + $into_commit)); + } + + $revision_ref = $set->getRevisionRef(); + $commit_message = $revision_ref->getCommitMessage(); + + $future = $api->execFutureLocal( + 'commit --author %s --date %s -F - --', + $original_author, + $original_date); + $future->write($commit_message); + $future->resolvex(); + + list($stdout) = $api->execxLocal('rev-parse --verify %s', 'HEAD'); + $new_cursor = trim($stdout); + + if ($into_commit === null) { + if ($this->isSquashStrategy()) { + throw new Exception( + pht('TODO: Rewrite HEAD to have no parents.')); + } else { + throw new Exception( + pht('TODO: Rewrite HEAD to have only source as a parent.')); + } + } + + return $new_cursor; + } + + protected function pushChange($into_commit) { + $api = $this->getRepositoryAPI(); + $log = $this->getLogEngine(); + + if ($this->getIsGitPerforce()) { + + // TODO: Specifying "--onto" more than once is almost certainly an error + // in Perforce. + + $log->writeStatus( + pht('SUBMITTING'), + pht( + 'Submitting changes to "%s".', + $this->getOntoRemote())); + + $config_argv = array(); + + // Skip the "git p4 submit" interactive editor workflow. We expect + // the commit message that "arc land" has built to be satisfactory. + $config_argv[] = '-c'; + $config_argv[] = 'git-p4.skipSubmitEdit=true'; + + // Skip the "git p4 submit" confirmation prompt if the user does not edit + // the submit message. + $config_argv[] = '-c'; + $config_argv[] = 'git-p4.skipSubmitEditCheck=true'; + + $flags_argv = array(); + + // Disable implicit "git p4 rebase" as part of submit. We're allowing + // the implicit "git p4 sync" to go through since this puts us in a + // state which is generally similar to the state after "git push", with + // updated remotes. + + // We could do a manual "git p4 sync" with a more narrow "--branch" + // instead, but it's not clear that this is beneficial. + $flags_argv[] = '--disable-rebase'; + + // Detect moves and submit them to Perforce as move operations. + $flags_argv[] = '-M'; + + // If we run into a conflict, abort the operation. We expect users to + // fix conflicts and run "arc land" again. + $flags_argv[] = '--conflict=quit'; + + $err = $api->execPassthru( + '%LR p4 submit %LR --commit %R --', + $config_argv, + $flags_argv, + $into_commit); + + if ($err) { + throw new ArcanistUsageException( + pht( + 'Submit failed! Fix the error and run "arc land" again.')); + } + + return; + } + + $log->writeStatus( + pht('PUSHING'), + pht('Pushing changes to "%s".', $this->getOntoRemote())); + + $refspecs = array(); + foreach ($this->getOntoRefs() as $onto_ref) { + $refspecs[] = sprintf( + '%s:%s', + $into_commit, + $onto_ref); + } + + $err = $api->execPassthru( + 'push -- %s %Ls', + $this->getOntoRemote(), + $refspecs); + + if ($err) { + throw new ArcanistUsageException( + pht( + 'Push failed! Fix the error and run "arc land" again.')); + } + + // TODO + // if ($this->isGitSvn) { + // $err = phutil_passthru('git svn dcommit'); + // $cmd = 'git svn dcommit'; + + } + + protected function reconcileLocalState( + $into_commit, + ArcanistRepositoryLocalState $state) { + + $api = $this->getRepositoryAPI(); + $log = $this->getWorkflow()->getLogEngine(); + + // Try to put the user into the best final state we can. This is very + // complicated because users are incredibly creative and their local + // branches may, for example, have the same names as branches in the + // remote but no relationship to them. + + // First, we're going to try to update these local branches: + // + // - the branch we started on originally; and + // - the local upstreams of the branch we started on originally; and + // - the local branch with the same name as the "into" ref; and + // - the local branch with the same name as the "onto" ref. + // + // These branches may not all exist and may not all be unique. + // + // To be updated, these branches must: + // + // - exist; + // - have not been deleted; and + // - be connected to the remote we pushed into. + + $update_branches = array(); + + $local_ref = $state->getLocalRef(); + if ($local_ref !== null) { + $update_branches[] = $local_ref; + } + + $local_path = $state->getLocalPath(); + if ($local_path) { + foreach ($local_path->getLocalBranches() as $local_branch) { + $update_branches[] = $local_branch; + } + } + + if (!$this->getIntoEmpty() && !$this->getIntoLocal()) { + $update_branches[] = $this->getIntoRef(); + } + + foreach ($this->getOntoRefs() as $onto_ref) { + $update_branches[] = $onto_ref; + } + + $update_branches = array_fuse($update_branches); + + // Remove any branches we know we deleted. + foreach ($update_branches as $key => $update_branch) { + if (isset($this->deletedBranches[$update_branch])) { + unset($update_branches[$key]); + } + } + + // Now, remove any branches which don't actually exist. + foreach ($update_branches as $key => $update_branch) { + list($err) = $api->execManualLocal( + 'rev-parse --verify %s', + $update_branch); + if ($err) { + unset($update_branches[$key]); + } + } + + $is_perforce = $this->getIsGitPerforce(); + if ($is_perforce) { + // If we're in Perforce mode, we don't expect to have a meaningful + // path to the remote: the "p4" remote is not a real remote, and + // "git p4" commands do not configure branch upstreams to provide + // a path. + + // Additionally, we've already set the remote to the right state with an + // implicit "git p4 sync" during "git p4 submit", and "git pull" isn't a + // meaningful operation. + + // We're going to skip everything here and just switch to the most + // desirable branch (if we can find one), then reset the state (if that + // operation is safe). + + if (!$update_branches) { + $log->writeStatus( + pht('DETACHED HEAD'), + pht( + 'Unable to find any local branches to update, staying on '. + 'detached head.')); + $state->discardLocalState(); + return; + } + + $dst_branch = head($update_branches); + if (!$this->isAncestorOf($dst_branch, $into_commit)) { + $log->writeStatus( + pht('CHECKOUT'), + pht( + 'Local branch "%s" has unpublished changes, checking it out '. + 'but leaving them in place.', + $dst_branch)); + $do_reset = false; + } else { + $log->writeStatus( + pht('UPDATE'), + pht( + 'Switching to local branch "%s".', + $dst_branch)); + $do_reset = true; + } + + $api->execxLocal('checkout %s --', $dst_branch); + + if ($do_reset) { + $api->execxLocal('reset --hard %s --', $into_commit); + } + + $state->discardLocalState(); + return; + } + + $onto_refs = array_fuse($this->getOntoRefs()); + + $pull_branches = array(); + foreach ($update_branches as $update_branch) { + $update_path = $api->getPathToUpstream($update_branch); + + // Remove any branches which contain upstream cycles. + if ($update_path->getCycle()) { + $log->writeWarning( + pht('LOCAL CYCLE'), + pht( + 'Local branch "%s" tracks an upstream but following it leads to '. + 'a local cycle, ignoring branch.', + $update_branch)); + continue; + } + + // Remove any branches not connected to a remote. + if (!$update_path->isConnectedToRemote()) { + continue; + } + + // Remove any branches connected to a remote other than the remote + // we actually pushed to. + $remote_name = $update_path->getRemoteRemoteName(); + if ($remote_name !== $this->getOntoRemote()) { + continue; + } + + // Remove any branches not connected to a branch we pushed to. + $remote_branch = $update_path->getRemoteBranchName(); + if (!isset($onto_refs[$remote_branch])) { + continue; + } + + // This is the most-desirable path between some local branch and + // an impacted upstream. Select it and continue. + $pull_branches = $update_path->getLocalBranches(); + break; + } + + // When we update these branches later, we want to start with the branch + // closest to the upstream and work our way down. + $pull_branches = array_reverse($pull_branches); + $pull_branches = array_fuse($pull_branches); + + // If we started on a branch and it still exists but is not impacted + // by the changes we made to the remote (i.e., we aren't actually going + // to pull or update it if we continue), just switch back to it now. It's + // okay if this branch is completely unrelated to the changes we just + // landed. + + if ($local_ref !== null) { + if (isset($update_branches[$local_ref])) { + if (!isset($pull_branches[$local_ref])) { + + $log->writeStatus( + pht('RETURN'), + pht( + 'Returning to original branch "%s" in original state.', + $local_ref)); + + $state->restoreLocalState(); + return; + } + } + } + + // Otherwise, if we don't have any path from the upstream to any local + // branch, we don't want to switch to some unrelated branch which happens + // to have the same name as a branch we interacted with. Just stay where + // we ended up. + + $dst_branch = null; + if ($pull_branches) { + $dst_branch = null; + foreach ($pull_branches as $pull_branch) { + if (!$this->isAncestorOf($pull_branch, $into_commit)) { + + $log->writeStatus( + pht('LOCAL CHANGES'), + pht( + 'Local branch "%s" has unpublished changes, ending updates.', + $pull_branch)); + + break; + } + + $log->writeStatus( + pht('UPDATE'), + pht( + 'Updating local branch "%s"...', + $pull_branch)); + + $api->execxLocal( + 'branch -f %s %s --', + $pull_branch, + $into_commit); + + $dst_branch = $pull_branch; + } + } + + if ($dst_branch) { + $log->writeStatus( + pht('CHECKOUT'), + pht( + 'Checking out "%s".', + $dst_branch)); + + $api->execxLocal('checkout %s --', $dst_branch); + } else { + $log->writeStatus( + pht('DETACHED HEAD'), + pht( + 'Unable to find any local branches to update, staying on '. + 'detached head.')); + } + + $state->discardLocalState(); + } + + private function isAncestorOf($branch, $commit) { + $api = $this->getRepositoryAPI(); + + list($stdout) = $api->execxLocal( + 'merge-base %s %s', + $branch, + $commit); + $merge_base = trim($stdout); + + list($stdout) = $api->execxLocal( + 'rev-parse --verify %s', + $branch); + $branch_hash = trim($stdout); + + return ($merge_base === $branch_hash); + } + + private function getAuthorAndDate($commit) { + $api = $this->getRepositoryAPI(); + + list($info) = $api->execxLocal( + 'log -n1 --format=%s %s --', + '%aD%n%an%n%ae', + $commit); + + $info = trim($info); + list($date, $author, $email) = explode("\n", $info, 3); + + return array( + "$author <{$email}>", + $date, + ); + } + + private function didHoldChanges() { + $log = $this->getLogEngine(); + + if ($this->getIsGitPerforce()) { + $this->writeInfo( + pht('HOLD'), + pht( + 'Holding change locally, it has not been submitted.')); + + $push_command = csprintf( + '$ git p4 submit -M --commit %R --', + $this->mergedRef); + } else { + $log->writeStatus( + pht('HOLD'), + pht( + 'Holding change locally, it has not been pushed.')); + + $push_command = csprintf( + '$ git push -- %R %R:%R', + $this->getTargetRemote(), + $this->mergedRef, + $this->getTargetOnto()); + } + + $restore_command = csprintf( + '$ git checkout %R --', + $this->localRef); + + echo tsprintf( + "\n%s\n\n". + "%s\n\n". + " **%s**\n\n". + "%s\n\n". + " **%s**\n\n". + "%s\n", + pht( + 'This local working copy now contains the merged changes in a '. + 'detached state.'), + pht('You can push the changes manually with this command:'), + $push_command, + pht( + 'You can go back to how things were before you ran "arc land" with '. + 'this command:'), + $restore_command, + pht( + 'Local branches have not been changed, and are still in exactly the '. + 'same state as before.')); + } + + protected function resolveSymbols(array $symbols) { + assert_instances_of($symbols, 'ArcanistLandSymbol'); + $api = $this->getRepositoryAPI(); + + foreach ($symbols as $symbol) { + $raw_symbol = $symbol->getSymbol(); + + list($err, $stdout) = $api->execManualLocal( + 'rev-parse --verify %s', + $raw_symbol); + + if ($err) { + throw new PhutilArgumentUsageException( + pht( + 'Branch "%s" does not exist in the local working copy.', + $raw_symbol)); + } + + $commit = trim($stdout); + $symbol->setCommit($commit); + } + } + + protected function confirmOntoRefs(array $onto_refs) { + foreach ($onto_refs as $onto_ref) { + if (!strlen($onto_ref)) { + throw new PhutilArgumentUsageException( + pht( + 'Selected "onto" ref "%s" is invalid: the empty string is not '. + 'a valid ref.', + $onto_ref)); + } + } + } + + protected function selectOntoRefs(array $symbols) { + assert_instances_of($symbols, 'ArcanistLandSymbol'); + $log = $this->getLogEngine(); + + $onto = $this->getOntoArguments(); + if ($onto) { + + $log->writeStatus( + pht('ONTO TARGET'), + pht( + 'Refs were selected with the "--onto" flag: %s.', + implode(', ', $onto))); + + return $onto; + } + + $onto = $this->getOntoFromConfiguration(); + if ($onto) { + $onto_key = $this->getOntoConfigurationKey(); + + $log->writeStatus( + pht('ONTO TARGET'), + pht( + 'Refs were selected by reading "%s" configuration: %s.', + $onto_key, + implode(', ', $onto))); + + return $onto; + } + + $api = $this->getRepositoryAPI(); + + $remote_onto = array(); + foreach ($symbols as $symbol) { + $raw_symbol = $symbol->getSymbol(); + $path = $api->getPathToUpstream($raw_symbol); + + if (!$path->getLength()) { + continue; + } + + $cycle = $path->getCycle(); + if ($cycle) { + $log->writeWarning( + pht('LOCAL CYCLE'), + pht( + 'Local branch "%s" tracks an upstream, but following it leads '. + 'to a local cycle; ignoring branch upstream.', + $raw_symbol)); + + $log->writeWarning( + pht('LOCAL CYCLE'), + implode(' -> ', $cycle)); + + continue; + } + + if (!$path->isConnectedToRemote()) { + $log->writeWarning( + pht('NO PATH TO REMOTE'), + pht( + 'Local branch "%s" tracks an upstream, but there is no path '. + 'to a remote; ignoring branch upstream.', + $raw_symbol)); + + continue; + } + + $onto = $path->getRemoteBranchName(); + + $remote_onto[$onto] = $onto; + } + + if (count($remote_onto) > 1) { + throw new PhutilArgumentUsageException( + pht( + 'The branches you are landing are connected to multiple different '. + 'remote branches via Git branch upstreams. Use "--onto" to select '. + 'the refs you want to push to.')); + } + + if ($remote_onto) { + $remote_onto = array_values($remote_onto); + + $log->writeStatus( + pht('ONTO TARGET'), + pht( + 'Landing onto target "%s", selected by following tracking branches '. + 'upstream to the closest remote branch.', + head($remote_onto))); + + return $remote_onto; + } + + $default_onto = 'master'; + + $log->writeStatus( + pht('ONTO TARGET'), + pht( + 'Landing onto target "%s", the default target under Git.', + $default_onto)); + + return array($default_onto); + } + + protected function selectOntoRemote(array $symbols) { + assert_instances_of($symbols, 'ArcanistLandSymbol'); + $remote = $this->newOntoRemote($symbols); + + $api = $this->getRepositoryAPI(); + $log = $this->getLogEngine(); + $is_pushable = $api->isPushableRemote($remote); + $is_perforce = $api->isPerforceRemote($remote); + + if (!$is_pushable && !$is_perforce) { + throw new PhutilArgumentUsageException( + pht( + 'No pushable remote "%s" exists. Use the "--onto-remote" flag to '. + 'choose a valid, pushable remote to land changes onto.', + $remote)); + } + + if ($is_perforce) { + $this->setIsGitPerforce(true); + + $log->writeWarning( + pht('P4 MODE'), + pht( + 'Operating in Git/Perforce mode after selecting a Perforce '. + 'remote.')); + + if (!$this->isSquashStrategy()) { + throw new PhutilArgumentUsageException( + pht( + 'Perforce mode does not support the "merge" land strategy. '. + 'Use the "squash" land strategy when landing to a Perforce '. + 'remote (you can use "--squash" to select this strategy).')); + } + } + + return $remote; + } + + private function newOntoRemote(array $onto_symbols) { + assert_instances_of($onto_symbols, 'ArcanistLandSymbol'); + $log = $this->getLogEngine(); + + $remote = $this->getOntoRemoteArgument(); + if ($remote !== null) { + + $log->writeStatus( + pht('ONTO REMOTE'), + pht( + 'Remote "%s" was selected with the "--onto-remote" flag.', + $remote)); + + return $remote; + } + + $remote = $this->getOntoRemoteFromConfiguration(); + if ($remote !== null) { + $remote_key = $this->getOntoRemoteConfigurationKey(); + + $log->writeStatus( + pht('ONTO REMOTE'), + pht( + 'Remote "%s" was selected by reading "%s" configuration.', + $remote, + $remote_key)); + + return $remote; + } + + $api = $this->getRepositoryAPI(); + + $upstream_remotes = array(); + foreach ($onto_symbols as $onto_symbol) { + $path = $api->getPathToUpstream($onto_symbol->getSymbol()); + + $remote = $path->getRemoteRemoteName(); + if ($remote !== null) { + $upstream_remotes[$remote][] = $onto_symbol; + } + } + + if (count($upstream_remotes) > 1) { + throw new PhutilArgumentUsageException( + pht( + 'The "onto" refs you have selected are connected to multiple '. + 'different remotes via Git branch upstreams. Use "--onto-remote" '. + 'to select a single remote.')); + } + + if ($upstream_remotes) { + $upstream_remote = head_key($upstream_remotes); + + $log->writeStatus( + pht('ONTO REMOTE'), + pht( + 'Remote "%s" was selected by following tracking branches '. + 'upstream to the closest remote.', + $remote)); + + return $upstream_remote; + } + + $perforce_remote = 'p4'; + if ($api->isPerforceRemote($remote)) { + + $log->writeStatus( + pht('ONTO REMOTE'), + pht( + 'Peforce remote "%s" was selected because the existence of '. + 'this remote implies this working copy was synchronized '. + 'from a Perforce repository.', + $remote)); + + return $remote; + } + + $default_remote = 'origin'; + + $log->writeStatus( + pht('ONTO REMOTE'), + pht( + 'Landing onto remote "%s", the default remote under Git.', + $default_remote)); + + return $default_remote; + } + + protected function selectIntoRemote() { + $api = $this->getRepositoryAPI(); + $log = $this->getLogEngine(); + + if ($this->getIntoEmptyArgument()) { + $this->setIntoEmpty(true); + + $log->writeStatus( + pht('INTO REMOTE'), + pht( + 'Will merge into empty state, selected with the "--into-empty" '. + 'flag.')); + + return; + } + + if ($this->getIntoLocalArgument()) { + $this->setIntoLocal(true); + + $log->writeStatus( + pht('INTO REMOTE'), + pht( + 'Will merge into local state, selected with the "--into-local" '. + 'flag.')); + + return; + } + + $into = $this->getIntoRemoteArgument(); + if ($into !== null) { + + // TODO: We could allow users to pass a URI argument instead, but + // this also requires some updates to the fetch logic elsewhere. + + if (!$api->isFetchableRemote($into)) { + throw new PhutilArgumentUsageException( + pht( + 'Remote "%s", specified with "--into", is not a valid fetchable '. + 'remote.', + $into)); + } + + $this->setIntoRemote($into); + + $log->writeStatus( + pht('INTO REMOTE'), + pht( + 'Will merge into remote "%s", selected with the "--into" flag.', + $into)); + + return; + } + + $onto = $this->getOntoRemote(); + $this->setIntoRemote($onto); + + $log->writeStatus( + pht('INTO REMOTE'), + pht( + 'Will merge into remote "%s" by default, because this is the remote '. + 'the change is landing onto.', + $onto)); + } + + protected function selectIntoRef() { + $log = $this->getLogEngine(); + + if ($this->getIntoEmptyArgument()) { + $log->writeStatus( + pht('INTO TARGET'), + pht( + 'Will merge into empty state, selected with the "--into-empty" '. + 'flag.')); + + return; + } + + $into = $this->getIntoArgument(); + if ($into !== null) { + $this->setIntoRef($into); + + $log->writeStatus( + pht('INTO TARGET'), + pht( + 'Will merge into target "%s", selected with the "--into" flag.', + $into)); + + return; + } + + $ontos = $this->getOntoRefs(); + $onto = head($ontos); + + $this->setIntoRef($onto); + if (count($ontos) > 1) { + $log->writeStatus( + pht('INTO TARGET'), + pht( + 'Will merge into target "%s" by default, because this is the first '. + '"onto" target.', + $onto)); + } else { + $log->writeStatus( + pht('INTO TARGET'), + pht( + 'Will merge into target "%s" by default, because this is the "onto" '. + 'target.', + $onto)); + } + } + + protected function selectIntoCommit() { + // Make sure that our "into" target is valid. + $log = $this->getLogEngine(); + + if ($this->getIntoEmpty()) { + // If we're running under "--into-empty", we don't have to do anything. + + $log->writeStatus( + pht('INTO COMMIT'), + pht('Preparing merge into the empty state.')); + + return null; + } + + if ($this->getIntoLocal()) { + // If we're running under "--into-local", just make sure that the + // target identifies some actual commit. + $api = $this->getRepositoryAPI(); + $local_ref = $this->getIntoRef(); + + list($err, $stdout) = $api->execManualLocal( + 'rev-parse --verify %s', + $local_ref); + + if ($err) { + throw new PhutilArgumentUsageException( + pht( + 'Local ref "%s" does not exist.', + $local_ref)); + } + + $into_commit = trim($stdout); + + $log->writeStatus( + pht('INTO COMMIT'), + pht( + 'Preparing merge into local target "%s", at commit "%s".', + $local_ref, + $this->getDisplayHash($into_commit))); + + return $into_commit; + } + + $target = id(new ArcanistLandTarget()) + ->setRemote($this->getIntoRemote()) + ->setRef($this->getIntoRef()); + + $commit = $this->fetchTarget($target); + if ($commit !== null) { + $log->writeStatus( + pht('INTO COMMIT'), + pht( + 'Preparing merge into "%s" from remote "%s", at commit "%s".', + $target->getRef(), + $target->getRemote(), + $this->getDisplayHash($commit))); + return $commit; + } + + // If we have no valid target and the user passed "--into" explicitly, + // treat this as an error. For example, "arc land --into Q --onto Q", + // where "Q" does not exist, is an error. + if ($this->getIntoArgument()) { + throw new PhutilArgumentUsageException( + pht( + 'Ref "%s" does not exist in remote "%s".', + $target->getRef(), + $target->getRemote())); + } + + // Otherwise, treat this as implying "--into-empty". For example, + // "arc land --onto Q", where "Q" does not exist, is equivalent to + // "arc land --into-empty --onto Q". + $this->setIntoEmpty(true); + + $log->writeStatus( + pht('INTO COMMIT'), + pht( + 'Preparing merge into the empty state to create target "%s" '. + 'in remote "%s".', + $target->getRef(), + $target->getRemote())); + + return null; + } + + private function getLandTargetLocalCommit(ArcanistLandTarget $target) { + $commit = $this->resolveLandTargetLocalCommit($target); + + if ($commit === null) { + throw new Exception( + pht( + 'No ref "%s" exists in remote "%s".', + $target->getRef(), + $target->getRemote())); + } + + return $commit; + } + + private function getLandTargetLocalExists(ArcanistLandTarget $target) { + $commit = $this->resolveLandTargetLocalCommit($target); + return ($commit !== null); + } + + private function resolveLandTargetLocalCommit(ArcanistLandTarget $target) { + $target_key = $target->getLandTargetKey(); + + if (!array_key_exists($target_key, $this->landTargetCommitMap)) { + $full_ref = sprintf( + 'refs/remotes/%s/%s', + $target->getRemote(), + $target->getRef()); + + $api = $this->getRepositoryAPI(); + + list($err, $stdout) = $api->execManualLocal( + 'rev-parse --verify %s', + $full_ref); + + if ($err) { + $result = null; + } else { + $result = trim($stdout); + } + + $this->landTargetCommitMap[$target_key] = $result; + } + + return $this->landTargetCommitMap[$target_key]; + } + + private function fetchLandTarget( + ArcanistLandTarget $target, + $ignore_failure = false) { + $api = $this->getRepositoryAPI(); + + // TODO: Format this fetch nicely as a workflow command. + + $err = $api->execPassthru( + 'fetch --no-tags --quiet -- %s %s', + $target->getRemote(), + $target->getRef()); + if ($err && !$ignore_failure) { + throw new ArcanistUsageException( + pht( + 'Fetch of "%s" from remote "%s" failed! Fix the error and '. + 'run "arc land" again.', + $target->getRef(), + $target->getRemote())); + } + + // TODO: If the remote is a bare URI, we could read ".git/FETCH_HEAD" + // here and write the commit into the map. For now, settle for clearing + // the cache. + + // We could also fetch into some named "refs/arc-land-temporary" named + // ref, then read that. + + if (!$err) { + $target_key = $target->getLandTargetKey(); + unset($this->landTargetCommitMap[$target_key]); + } + } + + protected function selectCommits($into_commit, array $symbols) { + assert_instances_of($symbols, 'ArcanistLandSymbol'); + $api = $this->getRepositoryAPI(); + + $commit_map = array(); + foreach ($symbols as $symbol) { + $symbol_commit = $symbol->getCommit(); + $format = '%H%x00%P%x00%s%x00'; + + if ($into_commit === null) { + list($commits) = $api->execxLocal( + 'log %s --format=%s', + $symbol_commit, + $format); + } else { + list($commits) = $api->execxLocal( + 'log %s --not %s --format=%s', + $symbol_commit, + $into_commit, + $format); + } + + $commits = phutil_split_lines($commits, false); + foreach ($commits as $line) { + if (!strlen($line)) { + continue; + } + + $parts = explode("\0", $line, 4); + if (count($parts) < 3) { + throw new Exception( + pht( + 'Unexpected output from "git log ...": %s', + $line)); + } + + $hash = $parts[0]; + if (!isset($commit_map[$hash])) { + $parents = $parts[1]; + $parents = trim($parents); + if (strlen($parents)) { + $parents = explode(' ', $parents); + } else { + $parents = array(); + } + + $summary = $parts[2]; + + $commit_map[$hash] = id(new ArcanistLandCommit()) + ->setHash($hash) + ->setParents($parents) + ->setSummary($summary); + } + + $commit = $commit_map[$hash]; + $commit->addSymbol($symbol); + } + } + + return $this->confirmCommits($into_commit, $symbols, $commit_map); + } + + protected function getDefaultSymbols() { + $api = $this->getRepositoryAPI(); + $log = $this->getLogEngine(); + + $branch = $api->getBranchName(); + if ($branch === null) { + $log->writeStatus( + pht('SOURCE'), + pht( + 'Landing the current branch, "%s".', + $branch)); + + return array($branch); + } + + $commit = $api->getCurrentCommitRef(); + + $log->writeStatus( + pht('SOURCE'), + pht( + 'Landing the current HEAD, "%s".', + $commit->getCommitHash())); + + return array($branch); + } + +} diff --git a/src/land/engine/ArcanistLandEngine.php b/src/land/engine/ArcanistLandEngine.php new file mode 100644 --- /dev/null +++ b/src/land/engine/ArcanistLandEngine.php @@ -0,0 +1,1430 @@ +viewer = $viewer; + return $this; + } + + final public function getViewer() { + return $this->viewer; + } + + final public function setOntoRemote($onto_remote) { + $this->ontoRemote = $onto_remote; + return $this; + } + + final public function getOntoRemote() { + return $this->ontoRemote; + } + + final public function setOntoRefs($onto_refs) { + $this->ontoRefs = $onto_refs; + return $this; + } + + final public function getOntoRefs() { + return $this->ontoRefs; + } + + final public function setIntoRemote($into_remote) { + $this->intoRemote = $into_remote; + return $this; + } + + final public function getIntoRemote() { + return $this->intoRemote; + } + + final public function setIntoRef($into_ref) { + $this->intoRef = $into_ref; + return $this; + } + + final public function getIntoRef() { + return $this->intoRef; + } + + final public function setIntoEmpty($into_empty) { + $this->intoEmpty = $into_empty; + return $this; + } + + final public function getIntoEmpty() { + return $this->intoEmpty; + } + + final public function setIntoLocal($into_local) { + $this->intoLocal = $into_local; + return $this; + } + + final public function getIntoLocal() { + return $this->intoLocal; + } + + final public function setWorkflow($workflow) { + $this->workflow = $workflow; + return $this; + } + + final public function getWorkflow() { + return $this->workflow; + } + + final public function setRepositoryAPI( + ArcanistRepositoryAPI $repository_api) { + $this->repositoryAPI = $repository_api; + return $this; + } + + final public function getRepositoryAPI() { + return $this->repositoryAPI; + } + + final public function setLogEngine(ArcanistLogEngine $log_engine) { + $this->logEngine = $log_engine; + return $this; + } + + final public function getLogEngine() { + return $this->logEngine; + } + + final public function setShouldHold($should_hold) { + $this->shouldHold = $should_hold; + return $this; + } + + final public function getShouldHold() { + return $this->shouldHold; + } + + final public function setShouldKeep($should_keep) { + $this->shouldKeep = $should_keep; + return $this; + } + + final public function getShouldKeep() { + return $this->shouldKeep; + } + + final public function setStrategy($strategy) { + $this->strategy = $strategy; + return $this; + } + + final public function getStrategy() { + return $this->strategy; + } + + final public function setRevisionSymbol($revision_symbol) { + $this->revisionSymbol = $revision_symbol; + return $this; + } + + final public function getRevisionSymbol() { + return $this->revisionSymbol; + } + + final public function setRevisionSymbolRef( + ArcanistRevisionSymbolRef $revision_ref) { + $this->revisionSymbolRef = $revision_ref; + return $this; + } + + final public function getRevisionSymbolRef() { + return $this->revisionSymbolRef; + } + + final public function setShouldPreview($should_preview) { + $this->shouldPreview = $should_preview; + return $this; + } + + final public function getShouldPreview() { + return $this->shouldPreview; + } + + final public function setSourceRefs(array $source_refs) { + $this->sourceRefs = $source_refs; + return $this; + } + + final public function getSourceRefs() { + return $this->sourceRefs; + } + + final public function setOntoRemoteArgument($remote_argument) { + $this->ontoRemoteArgument = $remote_argument; + return $this; + } + + final public function getOntoRemoteArgument() { + return $this->ontoRemoteArgument; + } + + final public function setOntoArguments(array $onto_arguments) { + $this->ontoArguments = $onto_arguments; + return $this; + } + + final public function getOntoArguments() { + return $this->ontoArguments; + } + + final public function setIsIncremental($is_incremental) { + $this->isIncremental = $is_incremental; + return $this; + } + + final public function getIsIncremental() { + return $this->isIncremental; + } + + final public function setIntoEmptyArgument($into_empty_argument) { + $this->intoEmptyArgument = $into_empty_argument; + return $this; + } + + final public function getIntoEmptyArgument() { + return $this->intoEmptyArgument; + } + + final public function setIntoLocalArgument($into_local_argument) { + $this->intoLocalArgument = $into_local_argument; + return $this; + } + + final public function getIntoLocalArgument() { + return $this->intoLocalArgument; + } + + final public function setIntoRemoteArgument($into_remote_argument) { + $this->intoRemoteArgument = $into_remote_argument; + return $this; + } + + final public function getIntoRemoteArgument() { + return $this->intoRemoteArgument; + } + + final public function setIntoArgument($into_argument) { + $this->intoArgument = $into_argument; + return $this; + } + + final public function getIntoArgument() { + return $this->intoArgument; + } + + final protected function getOntoFromConfiguration() { + $config_key = $this->getOntoConfigurationKey(); + return $this->getWorkflow()->getConfig($config_key); + } + + final protected function getOntoConfigurationKey() { + return 'arc.land.onto'; + } + + final protected function getOntoRemoteFromConfiguration() { + $config_key = $this->getOntoRemoteConfigurationKey(); + return $this->getWorkflow()->getConfig($config_key); + } + + final protected function getOntoRemoteConfigurationKey() { + return 'arc.land.onto-remote'; + } + + final protected function confirmRevisions(array $sets) { + assert_instances_of($sets, 'ArcanistLandCommitSet'); + + $revision_refs = mpull($sets, 'getRevisionRef'); + $viewer = $this->getViewer(); + $viewer_phid = $viewer->getPHID(); + + $unauthored = array(); + foreach ($revision_refs as $revision_ref) { + $author_phid = $revision_ref->getAuthorPHID(); + if ($author_phid !== $viewer_phid) { + $unauthored[] = $revision_ref; + } + } + + if ($unauthored) { + $this->getWorkflow()->loadHardpoints( + $unauthored, + array( + ArcanistRevisionRef::HARDPOINT_AUTHORREF, + )); + + echo tsprintf( + "\n%!\n%W\n\n", + pht('NOT REVISION AUTHOR'), + pht( + 'You are landing revisions which you ("%s") are not the author of:', + $viewer->getMonogram())); + + foreach ($unauthored as $revision_ref) { + $display_ref = $revision_ref->newDisplayRef(); + + $author_ref = $revision_ref->getAuthorRef(); + if ($author_ref) { + $display_ref->appendLine( + pht( + 'Author: %s', + $author_ref->getMonogram())); + } + + echo tsprintf('%s', $display_ref); + } + + echo tsprintf( + "\n%?\n", + pht( + 'Use "Commandeer" in the web interface to become the author of '. + 'a revision.')); + + $query = pht('Land revisions you are not the author of?'); + + $this->getWorkflow() + ->getPrompt('arc.land.unauthored') + ->setQuery($query) + ->execute(); + } + + $planned = array(); + $closed = array(); + $not_accepted = array(); + foreach ($revision_refs as $revision_ref) { + if ($revision_ref->isStatusChangesPlanned()) { + $planned[] = $revision_ref; + } else if ($revision_ref->isStatusClosed()) { + $closed[] = $revision_ref; + } else if (!$revision_ref->isStatusAccepted()) { + $not_accepted[] = $revision_ref; + } + } + + // See T10233. Previously, this prompt was bundled with the generic "not + // accepted" prompt, but users found it confusing and interpreted the + // prompt as a bug. + + if ($planned) { + $example_ref = head($planned); + + echo tsprintf( + "\n%!\n%W\n\n%W\n\n%W\n\n", + pht('%s REVISION(S) HAVE CHANGES PLANNED', phutil_count($planned)), + pht( + 'You are landing %s revision(s) which are currently in the state '. + '"%s", indicating that you expect to revise them before moving '. + 'forward.', + phutil_count($planned), + $example_ref->getStatusDisplayName()), + pht( + 'Normally, you should update these %s revision(s), submit them '. + 'for review, and wait for reviewers to accept them before '. + 'you continue. To resubmit a revision for review, either: '. + 'update the revision with revised changes; or use '. + '"Request Review" from the web interface.', + phutil_count($planned)), + pht( + 'These %s revision(s) have changes planned:', + phutil_count($planned))); + + foreach ($planned as $revision_ref) { + echo tsprintf('%s', $revision_ref->newDisplayRef()); + } + + $query = pht( + 'Land %s revision(s) with changes planned?', + phutil_count($planned)); + + $this->getWorkflow() + ->getPrompt('arc.land.changes-planned') + ->setQuery($query) + ->execute(); + } + + // See PHI1727. Previously, this prompt was bundled with the generic + // "not accepted" prompt, but at least one user found it confusing. + + if ($closed) { + $example_ref = head($closed); + + echo tsprintf( + "\n%!\n%W\n\n", + pht('%s REVISION(S) ARE ALREADY CLOSED', phutil_count($closed)), + pht( + 'You are landing %s revision(s) which are already in the state '. + '"%s", indicating that they have previously landed:', + phutil_count($closed), + $example_ref->getStatusDisplayName())); + + foreach ($closed as $revision_ref) { + echo tsprintf('%s', $revision_ref->newDisplayRef()); + } + + $query = pht( + 'Land %s revision(s) that are already closed?', + phutil_count($closed)); + + $this->getWorkflow() + ->getPrompt('arc.land.closed') + ->setQuery($query) + ->execute(); + } + + if ($not_accepted) { + $example_ref = head($not_accepted); + + echo tsprintf( + "\n%!\n%W\n\n", + pht('%s REVISION(S) ARE NOT ACCEPTED', phutil_count($not_accepted)), + pht( + 'You are landing %s revision(s) which are not in state "Accepted", '. + 'indicating that they have not been accepted by reviewers. '. + 'Normally, you should land changes only once they have been '. + 'accepted. These revisions are in the wrong state:', + phutil_count($not_accepted))); + + foreach ($not_accepted as $revision_ref) { + $display_ref = $revision_ref->newDisplayRef(); + $display_ref->appendLine( + pht( + 'Status: %s', + $revision_ref->getStatusDisplayName())); + echo tsprintf('%s', $display_ref); + } + + $query = pht( + 'Land %s revision(s) in the wrong state?', + phutil_count($not_accepted)); + + $this->getWorkflow() + ->getPrompt('arc.land.not-accepted') + ->setQuery($query) + ->execute(); + } + + $this->getWorkflow()->loadHardpoints( + $revision_refs, + array( + ArcanistRevisionRef::HARDPOINT_PARENTREVISIONREFS, + )); + + $open_parents = array(); + foreach ($revision_refs as $revision_phid => $revision_ref) { + $parent_refs = $revision_ref->getParentRevisionRefs(); + foreach ($parent_refs as $parent_ref) { + $parent_phid = $parent_ref->getPHID(); + + // If we're landing a parent revision in this operation, we don't need + // to complain that it hasn't been closed yet. + if (isset($revision_refs[$parent_phid])) { + continue; + } + + if ($parent_ref->isClosed()) { + continue; + } + + if (!isset($open_parents[$parent_phid])) { + $open_parents[$parent_phid] = array( + 'ref' => $parent_ref, + 'children' => array(), + ); + } + + $open_parents[$parent_phid]['children'][] = $revision_ref; + } + } + + if ($open_parents) { + echo tsprintf( + "\n%!\n%W\n\n", + pht('%s OPEN PARENT REVISION(S) ', phutil_count($open_parents)), + pht( + 'The changes you are landing depend on %s open parent revision(s). '. + 'Usually, you should land parent revisions before landing the '. + 'changes which depend on them. These parent revisions are open:', + phutil_count($open_parents))); + + foreach ($open_parents as $parent_phid => $spec) { + $parent_ref = $spec['ref']; + + $display_ref = $parent_ref->newDisplayRef(); + + $display_ref->appendLine( + pht( + 'Status: %s', + $parent_ref->getStatusDisplayName())); + + foreach ($spec['children'] as $child_ref) { + $display_ref->appendLine( + pht( + 'Parent of: %s %s', + $child_ref->getMonogram(), + $child_ref->getName())); + } + + echo tsprintf('%s', $display_ref); + } + + $query = pht( + 'Land changes that depend on %s open revision(s)?', + phutil_count($open_parents)); + + $this->getWorkflow() + ->getPrompt('arc.land.open-parents') + ->setQuery($query) + ->execute(); + } + + $this->confirmBuilds($revision_refs); + + // This is a reasonable place to bulk-load the commit messages, which + // we'll need soon. + + $this->getWorkflow()->loadHardpoints( + $revision_refs, + array( + ArcanistRevisionRef::HARDPOINT_COMMITMESSAGE, + )); + } + + private function confirmBuilds(array $revision_refs) { + assert_instances_of($revision_refs, 'ArcanistRevisionRef'); + + $this->getWorkflow()->loadHardpoints( + $revision_refs, + array( + ArcanistRevisionRef::HARDPOINT_BUILDABLEREF, + )); + + $buildable_refs = array(); + foreach ($revision_refs as $revision_ref) { + $ref = $revision_ref->getBuildableRef(); + if ($ref) { + $buildable_refs[] = $ref; + } + } + + $this->getWorkflow()->loadHardpoints( + $buildable_refs, + array( + ArcanistBuildableRef::HARDPOINT_BUILDREFS, + )); + + $build_refs = array(); + foreach ($buildable_refs as $buildable_ref) { + foreach ($buildable_ref->getBuildRefs() as $build_ref) { + $build_refs[] = $build_ref; + } + } + + $this->getWorkflow()->loadHardpoints( + $build_refs, + array( + ArcanistBuildRef::HARDPOINT_BUILDPLANREF, + )); + + $problem_builds = array(); + $has_failures = false; + $has_ongoing = false; + + $build_refs = msortv($build_refs, 'getStatusSortVector'); + foreach ($build_refs as $build_ref) { + $plan_ref = $build_ref->getBuildPlanRef(); + if (!$plan_ref) { + continue; + } + + $plan_behavior = $plan_ref->getBehavior('arc-land', 'always'); + $if_building = ($plan_behavior == 'building'); + $if_complete = ($plan_behavior == 'complete'); + $if_never = ($plan_behavior == 'never'); + + // If the build plan "Never" warns when landing, skip it. + if ($if_never) { + continue; + } + + // If the build plan warns when landing "If Complete" but the build is + // not complete, skip it. + if ($if_complete && !$build_ref->isComplete()) { + continue; + } + + // If the build plan warns when landing "If Building" but the build is + // complete, skip it. + if ($if_building && $build_ref->isComplete()) { + continue; + } + + // Ignore passing builds. + if ($build_ref->isPassed()) { + continue; + } + + if ($build_ref->isComplete()) { + $has_failures = true; + } else { + $has_ongoing = true; + } + + $problem_builds[] = $build_ref; + } + + if (!$problem_builds) { + return; + } + + $build_map = array(); + $failure_map = array(); + $buildable_map = mpull($buildable_refs, null, 'getPHID'); + $revision_map = mpull($revision_refs, null, 'getDiffPHID'); + foreach ($problem_builds as $build_ref) { + $buildable_phid = $build_ref->getBuildablePHID(); + $buildable_ref = $buildable_map[$buildable_phid]; + + $object_phid = $buildable_ref->getObjectPHID(); + $revision_ref = $revision_map[$object_phid]; + + $revision_phid = $revision_ref->getPHID(); + + if (!isset($build_map[$revision_phid])) { + $build_map[$revision_phid] = array( + 'revisionRef' => $revision_phid, + 'buildRefs' => array(), + ); + } + + $build_map[$revision_phid]['buildRefs'][] = $build_ref; + } + + $log = $this->getLogEngine(); + + if ($has_failures) { + if ($has_ongoing) { + $message = pht( + '%s revision(s) have build failures or ongoing builds:', + phutil_count($build_map)); + + $query = pht( + 'Land %s revision(s) anyway, despite ongoing and failed builds?', + phutil_count($build_map)); + + } else { + $message = pht( + '%s revision(s) have build failures:', + phutil_count($build_map)); + + $query = pht( + 'Land %s revision(s) anyway, despite failed builds?', + phutil_count($build_map)); + } + + echo tsprintf( + "%!\n%s\n\n", + pht('BUILD FAILURES'), + $message); + + $prompt_key = 'arc.land.failed-builds'; + } else if ($has_ongoing) { + echo tsprintf( + "%!\n%s\n\n", + pht('ONGOING BUILDS'), + pht( + '%s revision(s) have ongoing builds:', + phutil_count($build_map))); + + $query = pht( + 'Land %s revision(s) anyway, despite ongoing builds?', + phutil_count($build_map)); + + $prompt_key = 'arc.land.ongoing-builds'; + } + + echo tsprintf("\n"); + foreach ($build_map as $build_item) { + $revision_ref = $build_item['revisionRef']; + + echo tsprintf('%s', $revision_ref->newDisplayRef()); + + foreach ($build_item['buildRefs'] as $build_ref) { + echo tsprintf('%s', $build_ref->newDisplayRef()); + } + + echo tsprintf("\n"); + } + + echo tsprintf( + "\n%s\n\n", + pht('You can review build details here:')); + + // TODO: Only show buildables with problem builds. + + foreach ($buildable_refs as $buildable) { + $display_ref = $buildable->newDisplayRef(); + + // TODO: Include URI here. + + echo tsprintf('%s', $display_ref); + } + + $this->getWorkflow() + ->getPrompt($prompt_key) + ->setQuery($query) + ->execute(); + } + + final protected function confirmImplicitCommits(array $sets, array $symbols) { + assert_instances_of($sets, 'ArcanistLandCommitSet'); + assert_instances_of($symbols, 'ArcanistLandSymbol'); + + $implicit = array(); + foreach ($sets as $set) { + if ($set->hasImplicitCommits()) { + $implicit[] = $set; + } + } + + if (!$implicit) { + return; + } + + echo tsprintf( + "\n%!\n%W\n", + pht('IMPLICIT COMMITS'), + pht( + 'Some commits reachable from the specified sources (%s) are not '. + 'associated with revisions, and may not have been reviewed. These '. + 'commits will be landed as though they belong to the nearest '. + 'ancestor revision:', + $this->getDisplaySymbols($symbols))); + + foreach ($implicit as $set) { + $this->printCommitSet($set); + } + + $query = pht( + 'Continue with this mapping between commits and revisions?'); + + $this->getWorkflow() + ->getPrompt('arc.land.implicit') + ->setQuery($query) + ->execute(); + } + + final protected function getDisplaySymbols(array $symbols) { + $display = array(); + + foreach ($symbols as $symbol) { + $display[] = sprintf('"%s"', addcslashes($symbol->getSymbol(), '\\"')); + } + + return implode(', ', $display); + } + + final protected function printCommitSet(ArcanistLandCommitSet $set) { + $revision_ref = $set->getRevisionRef(); + + echo tsprintf( + "\n%s", + $revision_ref->newDisplayRef()); + + foreach ($set->getCommits() as $commit) { + $is_implicit = $commit->getIsImplicitCommit(); + + $display_hash = $this->getDisplayHash($commit->getHash()); + $display_summary = $commit->getDisplaySummary(); + + if ($is_implicit) { + echo tsprintf( + " %s %s\n", + $display_hash, + $display_summary); + } else { + echo tsprintf( + " %s %s\n", + $display_hash, + $display_summary); + } + } + } + + final protected function loadRevisionRefs(array $commit_map) { + assert_instances_of($commit_map, 'ArcanistLandCommit'); + $workflow = $this->getWorkflow(); + + $state_refs = array(); + foreach ($commit_map as $commit) { + $hash = $commit->getHash(); + + $commit_ref = id(new ArcanistCommitRef()) + ->setCommitHash($hash); + + $state_ref = id(new ArcanistWorkingCopyStateRef()) + ->setCommitRef($commit_ref); + + $state_refs[$hash] = $state_ref; + } + + $force_symbol_ref = $this->getRevisionSymbolRef(); + $force_ref = null; + if ($force_symbol_ref) { + $workflow->loadHardpoints( + $force_symbol_ref, + ArcanistSymbolRef::HARDPOINT_OBJECT); + + $force_ref = $force_symbol_ref->getObject(); + if (!$force_ref) { + throw new PhutilArgumentUsageException( + pht( + 'Symbol "%s" does not identify a valid revision.', + $force_symbol_ref->getSymbol())); + } + } + + $workflow->loadHardpoints( + $state_refs, + ArcanistWorkingCopyStateRef::HARDPOINT_REVISIONREFS); + + foreach ($commit_map as $commit) { + $hash = $commit->getHash(); + $state_ref = $state_refs[$hash]; + + $revision_refs = $state_ref->getRevisionRefs(); + + // If we have several possible revisions but one of them matches the + // "--revision" argument, just select it. This is relatively safe and + // reasonable and doesn't need a warning. + + if ($force_ref) { + if (count($revision_refs) > 1) { + foreach ($revision_refs as $revision_ref) { + if ($revision_ref->getPHID() === $force_ref->getPHID()) { + $revision_refs = array($revision_ref); + break; + } + } + } + } + + if (count($revision_refs) === 1) { + $revision_ref = head($revision_refs); + $commit->setExplicitRevisionRef($revision_ref); + continue; + } + + if (!$revision_refs) { + continue; + } + + // TODO: If we have several refs but all but one are abandoned or closed + // or authored by someone else, we could guess what you mean. + + $symbols = $commit->getSymbols(); + $raw_symbols = mpull($symbols, 'getSymbol'); + $symbol_list = implode(', ', $raw_symbols); + $display_hash = $this->getDisplayHash($hash); + + // TODO: Include "use 'arc look --type commit abc' to figure out why" + // once that works? + + echo tsprintf( + "\n%!\n%W\n\n", + pht('AMBIGUOUS REVISION'), + pht( + 'The revision associated with commit "%s" (an ancestor of: %s) '. + 'is ambiguous. These %s revision(s) are associated with the commit:', + $display_hash, + implode(', ', $raw_symbols), + phutil_count($revision_refs))); + + foreach ($revision_refs as $revision_ref) { + echo tsprintf( + '%s', + $revision_ref->newDisplayRef()); + } + + echo tsprintf("\n"); + + throw new PhutilArgumentUsageException( + pht( + 'Revision for commit "%s" is ambiguous. Use "--revision" to force '. + 'selection of a particular revision.', + $display_hash)); + } + + // TODO: Some of the revisions we've identified may be mapped to an + // outdated set of commits. We should look in local branches for a better + // set of commits, and try to confirm that the state we're about to land + // is the current state in Differential. + + if ($force_ref) { + $phid_map = array(); + foreach ($commit_map as $commit) { + $explicit_ref = $commit->getExplicitRevisionRef(); + if ($explicit_ref) { + $revision_phid = $explicit_ref->getPHID(); + $phid_map[$revision_phid] = $revision_phid; + } + } + + $force_phid = $force_ref->getPHID(); + + // If none of the commits have a revision, forcing the revision is + // reasonable and we don't need to confirm it. + + // If some of the commits have a revision, but it's the same as the + // revision we're forcing, forcing the revision is also reasonable. + + // Discard the revision we're trying to force, then check if there's + // anything left. If some of the commits have a different revision, + // make sure the user is really doing what they expect. + + unset($phid_map[$force_phid]); + + if ($phid_map) { + // TODO: Make this more clear. + + throw new PhutilArgumentUsageException( + pht( + 'TODO: You are forcing a revision, but commits are associated '. + 'with some other revision. Are you REALLY sure you want to land '. + 'ALL these commits wiht a different unrelated revision???')); + } + + foreach ($commit_map as $commit) { + $commit->setExplicitRevisionRef($force_ref); + } + } + } + + final protected function getDisplayHash($hash) { + // TODO: This should be on the API object. + return substr($hash, 0, 12); + } + + final protected function confirmCommits( + $into_commit, + array $symbols, + array $commit_map) { + + $commit_count = count($commit_map); + + if (!$commit_count) { + $message = pht( + 'There are no commits reachable from the specified sources (%s) '. + 'which are not already present in the state you are merging '. + 'into ("%s"), so nothing can land.', + $this->getDisplaySymbols($symbols), + $this->getDisplayHash($into_commit)); + + echo tsprintf( + "\n%!\n%W\n\n", + pht('NOTHING TO LAND'), + $message); + + throw new PhutilArgumentUsageException( + pht('There are no commits to land.')); + } + + // Reverse the commit list so that it's oldest-first, since this is the + // order we'll use to show revisions. + $commit_map = array_reverse($commit_map, true); + + $warn_limit = $this->getWorkflow()->getLargeWorkingSetLimit(); + $show_limit = 5; + if ($commit_count > $warn_limit) { + if ($into_commit === null) { + $message = pht( + 'There are %s commit(s) reachable from the specified sources (%s). '. + 'You are landing into the empty state, so all of these commits '. + 'will land:', + new PhutilNumber($commit_count), + $this->getDisplaySymbols($symbols)); + } else { + $message = pht( + 'There are %s commit(s) reachable from the specified sources (%s) '. + 'that are not present in the repository state you are merging '. + 'into ("%s"). All of these commits will land:', + new PhutilNumber($commit_count), + $this->getDisplaySymbols($symbols), + $this->getDisplayHash($into_commit)); + } + + echo tsprintf( + "\n%!\n%W\n", + pht('LARGE WORKING SET'), + $message); + + $display_commits = array_merge( + array_slice($commit_map, 0, $show_limit), + array(null), + array_slice($commit_map, -$show_limit)); + + echo tsprintf("\n"); + + foreach ($display_commits as $commit) { + if ($commit === null) { + echo tsprintf( + " %s\n", + pht( + '< ... %s more commits ... >', + new PhutilNumber($commit_count - ($show_limit * 2)))); + } else { + echo tsprintf( + " %s %s\n", + $this->getDisplayHash($commit->getHash()), + $commit->getDisplaySummary()); + } + } + + $query = pht( + 'Land %s commit(s)?', + new PhutilNumber($commit_count)); + + $this->getWorkflow() + ->getPrompt('arc.land.large-working-set') + ->setQuery($query) + ->execute(); + } + + // Build the commit objects into a tree. + foreach ($commit_map as $commit_hash => $commit) { + $parent_map = array(); + foreach ($commit->getParents() as $parent) { + if (isset($commit_map[$parent])) { + $parent_map[$parent] = $commit_map[$parent]; + } + } + $commit->setParentCommits($parent_map); + } + + // Identify the commits which are heads (have no children). + $child_map = array(); + foreach ($commit_map as $commit_hash => $commit) { + foreach ($commit->getParents() as $parent) { + $child_map[$parent][$commit_hash] = $commit; + } + } + + foreach ($commit_map as $commit_hash => $commit) { + if (isset($child_map[$commit_hash])) { + continue; + } + $commit->setIsHeadCommit(true); + } + + return $commit_map; + } + + public function execute() { + $api = $this->getRepositoryAPI(); + $log = $this->getLogEngine(); + + $this->validateArguments(); + + $raw_symbols = $this->getSourceRefs(); + if (!$raw_symbols) { + $raw_symbols = $this->getDefaultSymbols(); + } + + $symbols = array(); + foreach ($raw_symbols as $raw_symbol) { + $symbols[] = id(new ArcanistLandSymbol()) + ->setSymbol($raw_symbol); + } + + $this->resolveSymbols($symbols); + + $onto_remote = $this->selectOntoRemote($symbols); + $this->setOntoRemote($onto_remote); + + $onto_refs = $this->selectOntoRefs($symbols); + $this->confirmOntoRefs($onto_refs); + $this->setOntoRefs($onto_refs); + + $this->selectIntoRemote(); + $this->selectIntoRef(); + + $into_commit = $this->selectIntoCommit(); + $commit_map = $this->selectCommits($into_commit, $symbols); + + $this->loadRevisionRefs($commit_map); + + // TODO: It's possible we have a list of commits which includes disjoint + // groups of commits associated with the same revision, or groups of + // commits which do not form a range. We should test that here, since we + // can't land commit groups which are not a single contiguous range. + + $revision_groups = array(); + foreach ($commit_map as $commit_hash => $commit) { + $revision_ref = $commit->getRevisionRef(); + + if (!$revision_ref) { + echo tsprintf( + "\n%!\n%W\n\n", + pht('UNKNOWN REVISION'), + pht( + 'Unable to determine which revision is associated with commit '. + '"%s". Use "arc diff" to create or update a revision with this '. + 'commit, or "--revision" to force selection of a particular '. + 'revision.', + $this->getDisplayHash($commit_hash))); + + throw new PhutilArgumentUsageException( + pht( + 'Unable to determine revision for commit "%s".', + $this->getDisplayHash($commit_hash))); + } + + $revision_groups[$revision_ref->getPHID()][] = $commit; + } + + $commit_heads = array(); + foreach ($commit_map as $commit) { + if ($commit->getIsHeadCommit()) { + $commit_heads[] = $commit; + } + } + + $revision_order = array(); + foreach ($commit_heads as $head) { + foreach ($head->getAncestorRevisionPHIDs() as $phid) { + $revision_order[$phid] = true; + } + } + + $revision_groups = array_select_keys( + $revision_groups, + array_keys($revision_order)); + + $sets = array(); + foreach ($revision_groups as $revision_phid => $group) { + $revision_ref = head($group)->getRevisionRef(); + + $set = id(new ArcanistLandCommitSet()) + ->setRevisionRef($revision_ref) + ->setCommits($group); + + $sets[$revision_phid] = $set; + } + + if (!$this->getShouldPreview()) { + $this->confirmImplicitCommits($sets, $symbols); + } + + $log->writeStatus( + pht('LANDING'), + pht('These changes will land:')); + + foreach ($sets as $set) { + $this->printCommitSet($set); + } + + if ($this->getShouldPreview()) { + $log->writeStatus( + pht('PREVIEW'), + pht('Completed preview of land operation.')); + return; + } + + $query = pht('Land these changes?'); + $this->getWorkflow() + ->getPrompt('arc.land.confirm') + ->setQuery($query) + ->execute(); + + $this->confirmRevisions($sets); + + $workflow = $this->getWorkflow(); + + $is_incremental = $this->getIsIncremental(); + $is_hold = $this->getShouldHold(); + $is_keep = $this->getShouldKeep(); + + $local_state = $api->newLocalState() + ->setWorkflow($workflow) + ->saveLocalState(); + + $seen_into = array(); + try { + $last_key = last_key($sets); + + $need_cascade = array(); + $need_prune = array(); + + foreach ($sets as $set_key => $set) { + // Add these first, so we don't add them multiple times if we need + // to retry a push. + $need_prune[] = $set; + $need_cascade[] = $set; + + while (true) { + $into_commit = $this->executeMerge($set, $into_commit); + + if ($is_hold) { + $should_push = false; + } else if ($is_incremental) { + $should_push = true; + } else { + $is_last = ($set_key === $last_key); + $should_push = $is_last; + } + + if ($should_push) { + try { + $this->pushChange($into_commit); + } catch (Exception $ex) { + + // TODO: If the push fails, fetch and retry if the remote ref + // has moved ahead of us. + + if ($this->getIntoLocal()) { + $can_retry = false; + } else if ($this->getIntoEmpty()) { + $can_retry = false; + } else if ($this->getIntoRemote() !== $this->getOntoRemote()) { + $can_retry = false; + } else { + $can_retry = false; + } + + if ($can_retry) { + // New commit state here + $into_commit = '..'; + continue; + } + + throw $ex; + } + + if ($need_cascade) { + + // NOTE: We cascade each set we've pushed, but we're going to + // cascade them from most recent to least recent. This way, + // branches which descend from more recent changes only cascade + // once, directly in to the correct state. + + $need_cascade = array_reverse($need_cascade); + foreach ($need_cascade as $cascade_set) { + $this->cascadeState($set, $into_commit); + } + $need_cascade = array(); + } + + if (!$is_keep) { + $this->pruneBranches($need_prune); + $need_prune = array(); + } + } + + break; + } + } + + if ($is_hold) { + $this->didHoldChanges(); + $this->discardLocalState(); + } else { + $this->reconcileLocalState($into_commit, $local_state); + } + + // TODO: Restore this. + // $this->getWorkflow()->askForRepositoryUpdate(); + + $log->writeSuccess( + pht('DONE'), + pht('Landed changes.')); + } catch (Exception $ex) { + $local_state->restoreLocalState(); + throw $ex; + } catch (Throwable $ex) { + $local_state->restoreLocalState(); + throw $ex; + } + } + + + protected function validateArguments() { + $log = $this->getLogEngine(); + + $into_local = $this->getIntoLocalArgument(); + $into_empty = $this->getIntoEmptyArgument(); + $into_remote = $this->getIntoRemoteArgument(); + + $into_count = 0; + if ($into_remote !== null) { + $into_count++; + } + + if ($into_local) { + $into_count++; + } + + if ($into_empty) { + $into_count++; + } + + if ($into_count > 1) { + throw new PhutilArgumentUsageException( + pht( + 'Arguments "--into-local", "--into-remote", and "--into-empty" '. + 'are mutually exclusive.')); + } + + $into = $this->getIntoArgument(); + if ($into && ($into_empty !== null)) { + throw new PhutilArgumentUsageException( + pht( + 'Arguments "--into" and "--into-empty" are mutually exclusive.')); + } + + $strategy = $this->selectMergeStrategy(); + $this->setStrategy($strategy); + + // Build the symbol ref here (which validates the format of the symbol), + // but don't load the object until later on when we're sure we actually + // need it, since loading it requires a relatively expensive Conduit call. + $revision_symbol = $this->getRevisionSymbol(); + if ($revision_symbol) { + $symbol_ref = id(new ArcanistRevisionSymbolRef()) + ->setSymbol($revision_symbol); + $this->setRevisionSymbolRef($symbol_ref); + } + + // NOTE: When a user provides: "--hold" or "--preview"; and "--incremental" + // or various combinations of remote flags, the flags affecting push/remote + // behavior have no effect. + + // These combinations are allowed to support adding "--preview" or "--hold" + // to any command to run the same command with fewer side effects. + } + + abstract protected function getDefaultSymbols(); + abstract protected function resolveSymbols(array $symbols); + abstract protected function selectOntoRemote(array $symbols); + abstract protected function selectOntoRefs(array $symbols); + abstract protected function confirmOntoRefs(array $onto_refs); + abstract protected function selectIntoRemote(); + abstract protected function selectIntoRef(); + abstract protected function selectIntoCommit(); + abstract protected function selectCommits($into_commit, array $symbols); + abstract protected function executeMerge( + ArcanistLandCommitSet $set, + $into_commit); + abstract protected function pushChange($into_commit); + abstract protected function cascadeState( + ArcanistLandCommitSet $set, + $into_commit); + + protected function isSquashStrategy() { + return ($this->getStrategy() === 'squash'); + } + + abstract protected function pruneBranches(array $sets); + + abstract protected function reconcileLocalState( + $into_commit, + ArcanistRepositoryLocalState $state); + + private function selectMergeStrategy() { + $log = $this->getLogEngine(); + + $supported_strategies = array( + 'merge', + 'squash', + ); + $supported_strategies = array_fuse($supported_strategies); + $strategy_list = implode(', ', $supported_strategies); + + $strategy = $this->getStrategyArgument(); + if ($strategy !== null) { + if (!isset($supported_strategies[$strategy])) { + throw new PhutilArgumentUsageException( + pht( + 'Merge strategy "%s" specified with "--strategy" is unknown. '. + 'Supported merge strategies are: %s.', + $strategy, + $strategy_list)); + } + + $log->writeStatus( + pht('STRATEGY'), + pht( + 'Merging with "%s" strategy, selected with "--strategy".', + $strategy)); + + return $strategy; + } + + $strategy_key = 'arc.land.strategy'; + $strategy = $this->getWorkflow()->getConfig($strategy_key); + if ($strategy !== null) { + if (!isset($supported_strategies[$strategy])) { + throw new PhutilArgumentUsageException( + pht( + 'Merge strategy "%s" specified in "%s" configuration is '. + 'unknown. Supported merge strategies are: %s.', + $strategy, + $strategy_list)); + } + + $log->writeStatus( + pht('STRATEGY'), + pht( + 'Merging with "%s" strategy, configured with "%s".', + $strategy, + $strategy_key)); + + return $strategy; + } + + $strategy = 'squash'; + + $log->writeStatus( + pht('STRATEGY'), + pht( + 'Merging with "%s" strategy, the default strategy.', + $strategy)); + + return $strategy; + } + +} diff --git a/src/land/engine/ArcanistMercurialLandEngine.php b/src/land/engine/ArcanistMercurialLandEngine.php new file mode 100644 --- /dev/null +++ b/src/land/engine/ArcanistMercurialLandEngine.php @@ -0,0 +1,603 @@ +getRepositoryAPI(); + $log = $this->getLogEngine(); + + $bookmark = $api->getActiveBookmark(); + if ($bookmark !== null) { + + $log->writeStatus( + pht('SOURCE'), + pht( + 'Landing the active bookmark, "%s".', + $bookmark)); + + return array($bookmark); + } + + $branch = $api->getBranchName(); + if ($branch !== null) { + + $log->writeStatus( + pht('SOURCE'), + pht( + 'Landing the current branch, "%s".', + $branch)); + + return array($branch); + } + + throw new Exception(pht('TODO: Operate on raw revision.')); + } + + protected function resolveSymbols(array $symbols) { + assert_instances_of($symbols, 'ArcanistLandSymbol'); + $api = $this->getRepositoryAPI(); + + foreach ($symbols as $symbol) { + $raw_symbol = $symbol->getSymbol(); + + if ($api->isBookmark($raw_symbol)) { + $hash = $api->getBookmarkCommitHash($raw_symbol); + $symbol->setCommit($hash); + + // TODO: Set that this is a bookmark? + + continue; + } + + if ($api->isBranch($raw_symbol)) { + $hash = $api->getBranchCommitHash($raw_symbol); + $symbol->setCommit($hash); + + // TODO: Set that this is a branch? + + continue; + } + + throw new PhutilArgumentUsageException( + pht( + 'Symbol "%s" is not a bookmark or branch name.', + $raw_symbol)); + } + } + + protected function selectOntoRemote(array $symbols) { + assert_instances_of($symbols, 'ArcanistLandSymbol'); + $remote = $this->newOntoRemote($symbols); + + // TODO: Verify this remote actually exists. + + return $remote; + } + + private function newOntoRemote(array $symbols) { + assert_instances_of($symbols, 'ArcanistLandSymbol'); + $api = $this->getRepositoryAPI(); + $log = $this->getLogEngine(); + + $remote = $this->getOntoRemoteArgument(); + if ($remote !== null) { + + $log->writeStatus( + pht('ONTO REMOTE'), + pht( + 'Remote "%s" was selected with the "--onto-remote" flag.', + $remote)); + + return $remote; + } + + $remote = $this->getOntoRemoteFromConfiguration(); + if ($remote !== null) { + $remote_key = $this->getOntoRemoteConfigurationKey(); + + $log->writeStatus( + pht('ONTO REMOTE'), + pht( + 'Remote "%s" was selected by reading "%s" configuration.', + $remote, + $remote_key)); + + return $remote; + } + + $api = $this->getRepositoryAPI(); + + $default_remote = 'default'; + + $log->writeStatus( + pht('ONTO REMOTE'), + pht( + 'Landing onto remote "%s", the default remote under Mercurial.', + $default_remote)); + + return $default_remote; + } + + protected function selectOntoRefs(array $symbols) { + assert_instances_of($symbols, 'ArcanistLandSymbol'); + $log = $this->getLogEngine(); + + $onto = $this->getOntoArguments(); + if ($onto) { + + $log->writeStatus( + pht('ONTO TARGET'), + pht( + 'Refs were selected with the "--onto" flag: %s.', + implode(', ', $onto))); + + return $onto; + } + + $onto = $this->getOntoFromConfiguration(); + if ($onto) { + $onto_key = $this->getOntoConfigurationKey(); + + $log->writeStatus( + pht('ONTO TARGET'), + pht( + 'Refs were selected by reading "%s" configuration: %s.', + $onto_key, + implode(', ', $onto))); + + return $onto; + } + + $api = $this->getRepositoryAPI(); + + $default_onto = 'default'; + + $log->writeStatus( + pht('ONTO TARGET'), + pht( + 'Landing onto target "%s", the default target under Mercurial.', + $default_onto)); + + return array($default_onto); + } + + protected function confirmOntoRefs(array $onto_refs) { + foreach ($onto_refs as $onto_ref) { + if (!strlen($onto_ref)) { + throw new PhutilArgumentUsageException( + pht( + 'Selected "onto" ref "%s" is invalid: the empty string is not '. + 'a valid ref.', + $onto_ref)); + } + } + } + + protected function selectIntoRemote() { + $api = $this->getRepositoryAPI(); + $log = $this->getLogEngine(); + + if ($this->getIntoEmptyArgument()) { + $this->setIntoEmpty(true); + + $log->writeStatus( + pht('INTO REMOTE'), + pht( + 'Will merge into empty state, selected with the "--into-empty" '. + 'flag.')); + + return; + } + + if ($this->getIntoLocalArgument()) { + $this->setIntoLocal(true); + + $log->writeStatus( + pht('INTO REMOTE'), + pht( + 'Will merge into local state, selected with the "--into-local" '. + 'flag.')); + + return; + } + + $into = $this->getIntoRemoteArgument(); + if ($into !== null) { + + // TODO: Verify that this is a valid path. + // TODO: Allow a raw URI? + + $this->setIntoRemote($into); + + $log->writeStatus( + pht('INTO REMOTE'), + pht( + 'Will merge into remote "%s", selected with the "--into" flag.', + $into)); + + return; + } + + $onto = $this->getOntoRemote(); + $this->setIntoRemote($onto); + + $log->writeStatus( + pht('INTO REMOTE'), + pht( + 'Will merge into remote "%s" by default, because this is the remote '. + 'the change is landing onto.', + $onto)); + } + + protected function selectIntoRef() { + $log = $this->getLogEngine(); + + if ($this->getIntoEmptyArgument()) { + $log->writeStatus( + pht('INTO TARGET'), + pht( + 'Will merge into empty state, selected with the "--into-empty" '. + 'flag.')); + + return; + } + + $into = $this->getIntoArgument(); + if ($into !== null) { + $this->setIntoRef($into); + + $log->writeStatus( + pht('INTO TARGET'), + pht( + 'Will merge into target "%s", selected with the "--into" flag.', + $into)); + + return; + } + + $ontos = $this->getOntoRefs(); + $onto = head($ontos); + + $this->setIntoRef($onto); + if (count($ontos) > 1) { + $log->writeStatus( + pht('INTO TARGET'), + pht( + 'Will merge into target "%s" by default, because this is the first '. + '"onto" target.', + $onto)); + } else { + $log->writeStatus( + pht('INTO TARGET'), + pht( + 'Will merge into target "%s" by default, because this is the "onto" '. + 'target.', + $onto)); + } + } + + protected function selectIntoCommit() { + // Make sure that our "into" target is valid. + $log = $this->getLogEngine(); + + if ($this->getIntoEmpty()) { + // If we're running under "--into-empty", we don't have to do anything. + + $log->writeStatus( + pht('INTO COMMIT'), + pht('Preparing merge into the empty state.')); + + return null; + } + + if ($this->getIntoLocal()) { + // If we're running under "--into-local", just make sure that the + // target identifies some actual commit. + $api = $this->getRepositoryAPI(); + $local_ref = $this->getIntoRef(); + + // TODO: This error handling could probably be cleaner. + + $into_commit = $api->getCanonicalRevisionName($local_ref); + + $log->writeStatus( + pht('INTO COMMIT'), + pht( + 'Preparing merge into local target "%s", at commit "%s".', + $local_ref, + $this->getDisplayHash($into_commit))); + + return $into_commit; + } + + $target = id(new ArcanistLandTarget()) + ->setRemote($this->getIntoRemote()) + ->setRef($this->getIntoRef()); + + $commit = $this->fetchTarget($target); + if ($commit !== null) { + $log->writeStatus( + pht('INTO COMMIT'), + pht( + 'Preparing merge into "%s" from remote "%s", at commit "%s".', + $target->getRef(), + $target->getRemote(), + $this->getDisplayHash($commit))); + return $commit; + } + + // If we have no valid target and the user passed "--into" explicitly, + // treat this as an error. For example, "arc land --into Q --onto Q", + // where "Q" does not exist, is an error. + if ($this->getIntoArgument()) { + throw new PhutilArgumentUsageException( + pht( + 'Ref "%s" does not exist in remote "%s".', + $target->getRef(), + $target->getRemote())); + } + + // Otherwise, treat this as implying "--into-empty". For example, + // "arc land --onto Q", where "Q" does not exist, is equivalent to + // "arc land --into-empty --onto Q". + $this->setIntoEmpty(true); + + $log->writeStatus( + pht('INTO COMMIT'), + pht( + 'Preparing merge into the empty state to create target "%s" '. + 'in remote "%s".', + $target->getRef(), + $target->getRemote())); + + return null; + } + + private function fetchTarget(ArcanistLandTarget $target) { + $api = $this->getRepositoryAPI(); + $log = $this->getLogEngine(); + + // TODO: Support bookmarks. + // TODO: Deal with bookmark save/restore behavior. + // TODO: Format this nicely with passthru. + // TODO: Raise a good error message when the ref does not exist. + + $api->execPassthru( + 'pull -b %s -- %s', + $target->getRef(), + $target->getRemote()); + + // TODO: Deal with multiple branch heads. + + list($stdout) = $api->execxLocal( + 'log --rev %s --template %s --', + hgsprintf( + 'last(ancestors(%s) and !outgoing(%s))', + $target->getRef(), + $target->getRemote()), + '{node}'); + + return trim($stdout); + } + + protected function selectCommits($into_commit, array $symbols) { + assert_instances_of($symbols, 'ArcanistLandSymbol'); + $api = $this->getRepositoryAPI(); + + $commit_map = array(); + foreach ($symbols as $symbol) { + $symbol_commit = $symbol->getCommit(); + $template = '{node}-{parents}-'; + + if ($into_commit === null) { + list($commits) = $api->execxLocal( + 'log --rev %s --template %s --', + hgsprintf('reverse(ancestors(%s))', $into_commit), + $template); + } else { + list($commits) = $api->execxLocal( + 'log --rev %s --template %s --', + hgsprintf( + 'reverse(ancestors(%s) - ancestors(%s))', + $symbol_commit, + $into_commit), + $template); + } + + $commits = phutil_split_lines($commits, false); + foreach ($commits as $line) { + if (!strlen($line)) { + continue; + } + + $parts = explode('-', $line, 3); + if (count($parts) < 3) { + throw new Exception( + pht( + 'Unexpected output from "hg log ...": %s', + $line)); + } + + $hash = $parts[0]; + if (!isset($commit_map[$hash])) { + $parents = $parts[1]; + $parents = trim($parents); + if (strlen($parents)) { + $parents = explode(' ', $parents); + } else { + $parents = array(); + } + + $summary = $parts[2]; + + $commit_map[$hash] = id(new ArcanistLandCommit()) + ->setHash($hash) + ->setParents($parents) + ->setSummary($summary); + } + + $commit = $commit_map[$hash]; + $commit->addSymbol($symbol); + } + } + + return $this->confirmCommits($into_commit, $symbols, $commit_map); + } + + protected function executeMerge(ArcanistLandCommitSet $set, $into_commit) { + $api = $this->getRepositoryAPI(); + + if ($this->getStrategy() !== 'squash') { + throw new Exception(pht('TODO: Support merge strategies')); + } + + // TODO: Add a Mercurial version check requiring 2.1.1 or newer. + + $api->execxLocal( + 'update --rev %s', + hgsprintf('%s', $into_commit)); + + $commits = $set->getCommits(); + + $min_commit = last($commits)->getHash(); + $max_commit = head($commits)->getHash(); + + $revision_ref = $set->getRevisionRef(); + $commit_message = $revision_ref->getCommitMessage(); + + try { + $argv = array(); + $argv[] = '--dest'; + $argv[] = hgsprintf('%s', $into_commit); + + $argv[] = '--rev'; + $argv[] = hgsprintf('%s..%s', $min_commit, $max_commit); + + $argv[] = '--logfile'; + $argv[] = '-'; + + $argv[] = '--keep'; + $argv[] = '--collapse'; + + $future = $api->execFutureLocal('rebase %Ls', $argv); + $future->write($commit_message); + $future->resolvex(); + + } catch (CommandException $ex) { + // TODO + // $api->execManualLocal('rebase --abort'); + throw $ex; + } + + list($stdout) = $api->execxLocal('log --rev tip --template %s', '{node}'); + $new_cursor = trim($stdout); + + return $new_cursor; + } + + protected function pushChange($into_commit) { + $api = $this->getRepositoryAPI(); + + // TODO: This does not respect "--into" or "--onto" properly. + + $api->execxLocal( + 'push --rev %s -- %s', + $into_commit, + $this->getOntoRemote()); + } + + protected function cascadeState(ArcanistLandCommitSet $set, $into_commit) { + $api = $this->getRepositoryAPI(); + $log = $this->getLogEngine(); + + // This has no effect when we're executing a merge strategy. + if (!$this->isSquashStrategy()) { + return; + } + + $old_commit = last($set->getCommits())->getHash(); + $new_commit = $into_commit; + + list($output) = $api->execxLocal( + 'log --rev %s --template %s', + hgsprintf('children(%s)', $old_commit), + '{node}\n'); + $child_hashes = phutil_split_lines($output, false); + + foreach ($child_hashes as $child_hash) { + if (!strlen($child_hash)) { + continue; + } + + // TODO: If the only heads which are descendants of this child will + // be deleted, we can skip this rebase? + + try { + $api->execxLocal( + 'rebase --source %s --dest %s --keep --keepbranches', + $child_hash, + $new_commit); + } catch (CommandException $ex) { + // TODO: Recover state. + throw $ex; + } + } + } + + + protected function pruneBranches(array $sets) { + assert_instances_of($sets, 'ArcanistLandCommitSet'); + $api = $this->getRepositoryAPI(); + $log = $this->getLogEngine(); + + // This has no effect when we're executing a merge strategy. + if (!$this->isSquashStrategy()) { + return; + } + + $strip = array(); + + // We've rebased all descendants already, so we can safely delete all + // of these commits. + + $sets = array_reverse($sets); + foreach ($sets as $set) { + $commits = $set->getCommits(); + + $min_commit = head($commits)->getHash(); + $max_commit = last($commits)->getHash(); + + $strip[] = hgsprintf('%s::%s', $min_commit, $max_commit); + } + + $rev_set = '('.implode(') or (', $strip).')'; + + // See PHI45. If we have "hg evolve", get rid of old commits using + // "hg prune" instead of "hg strip". + + // If we "hg strip" a commit which has an obsolete predecessor, it + // removes the obsolescence marker and revives the predecessor. This is + // not desirable: we want to destroy all predecessors of these commits. + + try { + $api->execxLocal( + '--config extensions.evolve= prune --rev %s', + $rev_set); + } catch (CommandException $ex) { + $api->execxLocal( + '--config extensions.strip= strip --rev %s', + $rev_set); + } + } + + protected function reconcileLocalState( + $into_commit, + ArcanistRepositoryLocalState $state) { + + // TODO: For now, just leave users wherever they ended up. + + $state->discardLocalState(); + } + +} diff --git a/src/query/ArcanistMercurialCommitMessageHardpointQuery.php b/src/query/ArcanistMercurialCommitMessageHardpointQuery.php new file mode 100644 --- /dev/null +++ b/src/query/ArcanistMercurialCommitMessageHardpointQuery.php @@ -0,0 +1,36 @@ +getRepositoryAPI(); + + $hashes = mpull($refs, 'getCommitHash'); + $unique_hashes = array_fuse($hashes); + + // TODO: Batch this properly and make it future oriented. + + $messages = array(); + foreach ($unique_hashes as $unique_hash) { + $messages[$unique_hash] = $api->getCommitMessage($unique_hash); + } + + foreach ($hashes as $ref_key => $hash) { + $hashes[$ref_key] = $messages[$hash]; + } + + yield $this->yieldMap($hashes); + } + +} diff --git a/src/query/ArcanistMercurialWorkingCopyRevisionHardpointQuery.php b/src/query/ArcanistMercurialWorkingCopyRevisionHardpointQuery.php new file mode 100644 --- /dev/null +++ b/src/query/ArcanistMercurialWorkingCopyRevisionHardpointQuery.php @@ -0,0 +1,76 @@ +yieldRequests( + $refs, + array( + ArcanistWorkingCopyStateRef::HARDPOINT_COMMITREF, + )); + + // TODO: This has a lot in common with the Git query in the same role. + + $hashes = array(); + $map = array(); + foreach ($refs as $ref_key => $ref) { + $commit = $ref->getCommitRef(); + + $commit_hashes = array(); + + $commit_hashes[] = array( + 'hgcm', + $commit->getCommitHash(), + ); + + foreach ($commit_hashes as $hash) { + $hashes[] = $hash; + $hash_key = $this->getHashKey($hash); + $map[$hash_key][$ref_key] = $ref; + } + } + + $results = array_fill_keys(array_keys($refs), array()); + if ($hashes) { + $revisions = (yield $this->yieldConduit( + 'differential.query', + array( + 'commitHashes' => $hashes, + ))); + + foreach ($revisions as $dict) { + $revision_hashes = idx($dict, 'hashes'); + if (!$revision_hashes) { + continue; + } + + $revision_ref = ArcanistRevisionRef::newFromConduitQuery($dict); + foreach ($revision_hashes as $revision_hash) { + $hash_key = $this->getHashKey($revision_hash); + $state_refs = idx($map, $hash_key, array()); + foreach ($state_refs as $ref_key => $state_ref) { + $results[$ref_key][] = $revision_ref; + } + } + } + } + + yield $this->yieldMap($results); + } + + private function getHashKey(array $hash) { + return $hash[0].':'.$hash[1]; + } + +} diff --git a/src/query/ArcanistWorkflowMercurialHardpointQuery.php b/src/query/ArcanistWorkflowMercurialHardpointQuery.php new file mode 100644 --- /dev/null +++ b/src/query/ArcanistWorkflowMercurialHardpointQuery.php @@ -0,0 +1,11 @@ +getRepositoryAPI(); + return ($api instanceof ArcanistMercurialAPI); + } + +} diff --git a/src/ref/revision/ArcanistRevisionBuildableHardpointQuery.php b/src/ref/revision/ArcanistRevisionBuildableHardpointQuery.php --- a/src/ref/revision/ArcanistRevisionBuildableHardpointQuery.php +++ b/src/ref/revision/ArcanistRevisionBuildableHardpointQuery.php @@ -29,7 +29,7 @@ $buildables = (yield $this->yieldConduitSearch( 'harbormaster.buildable.search', array( - 'objectPHIDs' => $diff_map, + 'objectPHIDs' => array_values($diff_map), 'manual' => false, ))); diff --git a/src/repository/api/ArcanistGitAPI.php b/src/repository/api/ArcanistGitAPI.php --- a/src/repository/api/ArcanistGitAPI.php +++ b/src/repository/api/ArcanistGitAPI.php @@ -1564,6 +1564,11 @@ return ($uri !== null); } + public function isFetchableRemote($remote_name) { + $uri = $this->getGitRemoteFetchURI($remote_name); + return ($uri !== null); + } + private function getGitRemoteFetchURI($remote_name) { return $this->getGitRemoteURI($remote_name, $for_push = false); } @@ -1739,4 +1744,13 @@ return false; } + protected function newLandEngine() { + return new ArcanistGitLandEngine(); + } + + public function newLocalState() { + return id(new ArcanistGitLocalState()) + ->setRepositoryAPI($this); + } + } diff --git a/src/repository/api/ArcanistMercurialAPI.php b/src/repository/api/ArcanistMercurialAPI.php --- a/src/repository/api/ArcanistMercurialAPI.php +++ b/src/repository/api/ArcanistMercurialAPI.php @@ -534,6 +534,8 @@ } public function getAllBranches() { + // TODO: This is wrong, and returns bookmarks. + list($branch_info) = $this->execxLocal('bookmarks'); if (trim($branch_info) == 'no bookmarks set') { return array(); @@ -548,10 +550,14 @@ $return = array(); foreach ($matches as $match) { - list(, $current, $name) = $match; + list(, $current, $name, $hash) = $match; + + list($id, $hash) = explode(':', $hash); + $return[] = array( 'current' => (bool)$current, 'name' => rtrim($name), + 'hash' => $hash, ); } return $return; @@ -562,9 +568,13 @@ $refs = array(); foreach ($branches as $branch) { + $commit_ref = $this->newCommitRef() + ->setCommitHash($branch['hash']); + $refs[] = $this->newBranchRef() ->setBranchName($branch['name']) - ->setIsCurrentBranch($branch['current']); + ->setIsCurrentBranch($branch['current']) + ->attachCommitRef($commit_ref); } return $refs; @@ -1064,6 +1074,46 @@ return $bookmarks; } + public function getBookmarkCommitHash($name) { + // TODO: Cache this. + + $bookmarks = $this->getBookmarks($name); + $bookmarks = ipull($bookmarks, null, 'name'); + + foreach ($bookmarks as $bookmark) { + if ($bookmark['name'] === $name) { + return $bookmark['revision']; + } + } + + throw new Exception(pht('No bookmark "%s".', $name)); + } + + public function getBranchCommitHash($name) { + // TODO: Cache this. + // TODO: This won't work when there are multiple branch heads with the + // same name. + + $branches = $this->getBranches($name); + + $heads = array(); + foreach ($branches as $branch) { + if ($branch['name'] === $name) { + $heads[] = $branch; + } + } + + if (count($heads) === 1) { + return idx(head($heads), 'revision'); + } + + if (!$heads) { + throw new Exception(pht('No branch "%s".', $name)); + } + + throw new Exception(pht('Too many branch heads for "%s".', $name)); + } + private function splitBranchOrBookmarkLine($line) { // branches and bookmarks are printed in the format: // default 0:a5ead76cdf85 (inactive) @@ -1108,4 +1158,14 @@ return $env; } + protected function newLandEngine() { + return new ArcanistMercurialLandEngine(); + } + + public function newLocalState() { + return id(new ArcanistMercurialLocalState()) + ->setRepositoryAPI($this); + } + + } diff --git a/src/repository/api/ArcanistRepositoryAPI.php b/src/repository/api/ArcanistRepositoryAPI.php --- a/src/repository/api/ArcanistRepositoryAPI.php +++ b/src/repository/api/ArcanistRepositoryAPI.php @@ -734,4 +734,19 @@ final public function newBranchRef() { return new ArcanistBranchRef(); } + + final public function getLandEngine() { + $engine = $this->newLandEngine(); + + if ($engine) { + $engine->setRepositoryAPI($this); + } + + return $engine; + } + + protected function newLandEngine() { + return null; + } + } diff --git a/src/repository/state/ArcanistMercurialLocalState.php b/src/repository/state/ArcanistMercurialLocalState.php new file mode 100644 --- /dev/null +++ b/src/repository/state/ArcanistMercurialLocalState.php @@ -0,0 +1,60 @@ +localRef; + } + + public function getLocalPath() { + return $this->localPath; + } + + protected function executeSaveLocalState() { + $api = $this->getRepositoryAPI(); + // TODO: Fix this. + } + + protected function executeRestoreLocalState() { + $api = $this->getRepositoryAPI(); + // TODO: Fix this. + + // TODO: In Mercurial, we may want to discard commits we've created. + // $repository_api->execxLocal( + // '--config extensions.mq= strip %s', + // $this->onto); + + } + + protected function executeDiscardLocalState() { + // TODO: Fix this. + } + + protected function canStashChanges() { + // Depends on stash extension. + return false; + } + + protected function getIgnoreHints() { + // TODO: Provide this. + return array(); + } + + protected function saveStash() { + return null; + } + + protected function restoreStash($stash_ref) { + return null; + } + + protected function discardStash($stash_ref) { + return null; + } + +} diff --git a/src/repository/state/ArcanistRepositoryLocalState.php b/src/repository/state/ArcanistRepositoryLocalState.php --- a/src/repository/state/ArcanistRepositoryLocalState.php +++ b/src/repository/state/ArcanistRepositoryLocalState.php @@ -151,6 +151,9 @@ $this->executeSaveLocalState(); $this->shouldRestore = true; + // TODO: Detect when we're in the middle of a rebase. + // TODO: Detect when we're in the middle of a cherry-pick. + return $this; } diff --git a/src/workflow/ArcanistLandWorkflow.php b/src/workflow/ArcanistLandWorkflow.php --- a/src/workflow/ArcanistLandWorkflow.php +++ b/src/workflow/ArcanistLandWorkflow.php @@ -3,1608 +3,281 @@ /** * Lands a branch by rebasing, merging and amending it. */ -final class ArcanistLandWorkflow extends ArcanistWorkflow { - - private $isGit; - private $isGitSvn; - private $isHg; - private $isHgSvn; - - private $oldBranch; - private $branch; - private $onto; - private $ontoRemoteBranch; - private $remote; - private $useSquash; - private $keepBranch; - private $branchType; - private $ontoType; - private $preview; - - private $revision; - private $messageFile; - - const REFTYPE_BRANCH = 'branch'; - const REFTYPE_BOOKMARK = 'bookmark'; - - public function getRevisionDict() { - return $this->revision; - } +final class ArcanistLandWorkflow + extends ArcanistArcWorkflow { public function getWorkflowName() { return 'land'; } - public function getCommandSynopses() { - return phutil_console_format(<<newWorkflowInformation() + ->setHelp($help); } - public function getArguments() { + public function getWorkflowArguments() { return array( - 'onto' => array( - 'param' => 'master', - 'help' => pht( - "Land feature branch onto a branch other than the default ". - "('master' in git, 'default' in hg). You can change the default ". - "by setting '%s' with `%s` or for the entire project in %s.", - 'arc.land.onto.default', - 'arc set-config', - '.arcconfig'), - ), - 'hold' => array( - 'help' => pht( - 'Prepare the change to be pushed, but do not actually push it.'), - ), - 'keep-branch' => array( - 'help' => pht( - 'Keep the feature branch after pushing changes to the '. - 'remote (by default, it is deleted).'), - ), - 'remote' => array( - 'param' => 'origin', - 'help' => pht( - 'Push to a remote other than the default.'), - ), - 'merge' => array( - 'help' => pht( - 'Perform a %s merge, not a %s merge. If the project '. - 'is marked as having an immutable history, this is the default '. - 'behavior.', - '--no-ff', - '--squash'), - 'supports' => array( - 'git', - ), - 'nosupport' => array( - 'hg' => pht( - 'Use the %s strategy when landing in mercurial.', - '--squash'), - ), - ), - 'squash' => array( - 'help' => pht( - 'Perform a %s merge, not a %s merge. If the project is '. - 'marked as having a mutable history, this is the default behavior.', - '--squash', - '--no-ff'), - 'conflicts' => array( - 'merge' => pht( - '%s and %s are conflicting merge strategies.', - '--merge', - '--squash'), - ), - ), - 'delete-remote' => array( - 'help' => pht( - 'Delete the feature branch in the remote after landing it.'), - 'conflicts' => array( - 'keep-branch' => true, - ), - 'supports' => array( - 'hg', - ), - ), - 'revision' => array( - 'param' => 'id', - 'help' => pht( - 'Use the message from a specific revision, rather than '. - 'inferring the revision based on branch content.'), - ), - 'preview' => array( - 'help' => pht( - 'Prints the commits that would be landed. Does not '. - 'actually modify or land the commits.'), - ), - '*' => 'branch', + $this->newWorkflowArgument('hold') + ->setHelp( + pht( + 'Prepare the change to be pushed, but do not actually push it.')), + $this->newWorkflowArgument('keep-branches') + ->setHelp( + pht( + 'Keep local branches around after changes are pushed. By '. + 'default, local branches are deleted after they land.')), + $this->newWorkflowArgument('onto-remote') + ->setParameter('remote-name') + ->setHelp(pht('Push to a remote other than the default.')), + + // TODO: Formally allow flags to be bound to related configuration + // for documentation, e.g. "setRelatedConfiguration('arc.land.onto')". + + $this->newWorkflowArgument('onto') + ->setParameter('branch-name') + ->setRepeatable(true) + ->setHelp( + pht( + 'After merging, push changes onto a specified branch. '. + 'Specifying this flag multiple times will push multiple '. + 'branches.')), + $this->newWorkflowArgument('strategy') + ->setParameter('strategy-name') + ->setHelp( + pht( + // TODO: Improve this. + 'Merge using a particular strategy.')), + $this->newWorkflowArgument('revision') + ->setParameter('revision-identifier') + ->setHelp( + pht( + 'Land a specific revision, rather than determining the revisions '. + 'from the commits that are landing.')), + $this->newWorkflowArgument('preview') + ->setHelp( + pht( + 'Shows the changes that will land. Does not modify the working '. + 'copy or the remote.')), + $this->newWorkflowArgument('into') + ->setParameter('commit-ref') + ->setHelp( + pht( + 'Specifies the state to merge into. By default, this is the same '. + 'as the "onto" ref.')), + $this->newWorkflowArgument('into-remote') + ->setParameter('remote-name') + ->setHelp( + pht( + 'Specifies the remote to fetch the "into" ref from. By '. + 'default, this is the same as the "onto" remote.')), + $this->newWorkflowArgument('into-local') + ->setHelp( + pht( + 'Use the local "into" ref state instead of fetching it from '. + 'a remote.')), + $this->newWorkflowArgument('into-empty') + ->setHelp( + pht( + 'Merge into the empty state instead of an existing state. This '. + 'mode is primarily useful when creating a new repository, and '. + 'selected automatically if the "onto" ref does not exist and the '. + '"into" state is not specified.')), + $this->newWorkflowArgument('incremental') + ->setHelp( + pht( + 'When landing multiple revisions at once, push and rebase '. + 'after each operation instead of waiting until all merges '. + 'are completed. This is slower than the default behavior and '. + 'not atomic, but may make it easier to resolve conflicts and '. + 'land complicated changes by letting you make progress one '. + 'step at a time.')), + $this->newWorkflowArgument('ref') + ->setWildcard(true), ); } - public function run() { - $this->readArguments(); - - $engine = null; - if ($this->isGit && !$this->isGitSvn) { - $engine = new ArcanistGitLandEngine(); - } - - if ($engine) { - $should_hold = $this->getArgument('hold'); - $remote_arg = $this->getArgument('remote'); - $onto_arg = $this->getArgument('onto'); - - $engine - ->setWorkflow($this) - ->setRepositoryAPI($this->getRepositoryAPI()) - ->setSourceRef($this->branch) - ->setShouldHold($should_hold) - ->setShouldKeep($this->keepBranch) - ->setShouldSquash($this->useSquash) - ->setShouldPreview($this->preview) - ->setRemoteArgument($remote_arg) - ->setOntoArgument($onto_arg) - ->setBuildMessageCallback(array($this, 'buildEngineMessage')); - - // The goal here is to raise errors with flags early (which is cheap), - // before we test if the working copy is clean (which can be slow). This - // could probably be structured more cleanly. - - $engine->parseArguments(); - - // This must be configured or we fail later inside "buildEngineMessage()". - // This is less than ideal. - $this->ontoRemoteBranch = sprintf( - '%s/%s', - $engine->getTargetRemote(), - $engine->getTargetOnto()); - - $this->requireCleanWorkingCopy(); - $engine->execute(); - - if (!$should_hold && !$this->preview) { - $this->didPush(); - } - - return 0; - } - - $this->validate(); - - try { - $this->pullFromRemote(); - } catch (Exception $ex) { - $this->restoreBranch(); - throw $ex; - } - - $this->printPendingCommits(); - if ($this->preview) { - $this->restoreBranch(); - return 0; - } - - $this->checkoutBranch(); - $this->findRevision(); - - if ($this->useSquash) { - $this->rebase(); - $this->squash(); - } else { - $this->merge(); - } - - $this->push(); - - if (!$this->keepBranch) { - $this->cleanupBranch(); - } - - if ($this->oldBranch != $this->onto) { - // If we were on some branch A and the user ran "arc land B", - // switch back to A. - if ($this->keepBranch || $this->oldBranch != $this->branch) { - $this->restoreBranch(); - } - } - - echo pht('Done.'), "\n"; - - return 0; - } - - private function getUpstreamMatching($branch, $pattern) { - if ($this->isGit) { - $repository_api = $this->getRepositoryAPI(); - list($err, $fullname) = $repository_api->execManualLocal( - 'rev-parse --symbolic-full-name %s@{upstream}', - $branch); - if (!$err) { - $matches = null; - if (preg_match($pattern, $fullname, $matches)) { - return last($matches); - } - } - } - return null; - } - - private function getGitSvnTrunk() { - if (!$this->isGitSvn) { - return null; - } - - // See T13293, this depends on the options passed when cloning. - // On any error we return `trunk`, which was the previous default. - - $repository_api = $this->getRepositoryAPI(); - list($err, $refspec) = $repository_api->execManualLocal( - 'config svn-remote.svn.fetch'); - - if ($err) { - return 'trunk'; - } - - $refspec = rtrim(substr($refspec, strrpos($refspec, ':') + 1)); - - $prefix = 'refs/remotes/'; - if (substr($refspec, 0, strlen($prefix)) !== $prefix) { - return 'trunk'; - } - - $refspec = substr($refspec, strlen($prefix)); - return $refspec; - } - - private function readArguments() { - $repository_api = $this->getRepositoryAPI(); - $this->isGit = $repository_api instanceof ArcanistGitAPI; - $this->isHg = $repository_api instanceof ArcanistMercurialAPI; - - if ($this->isGit) { - $repository = $this->loadProjectRepository(); - $this->isGitSvn = (idx($repository, 'vcs') == 'svn'); - } - - if ($this->isHg) { - $this->isHgSvn = $repository_api->isHgSubversionRepo(); - } - - $branch = $this->getArgument('branch'); - if (empty($branch)) { - $branch = $this->getBranchOrBookmark(); - if ($branch !== null) { - $this->branchType = $this->getBranchType($branch); - - // TODO: This message is misleading when landing a detached head or - // a tag in Git. - - echo pht("Landing current %s '%s'.", $this->branchType, $branch), "\n"; - $branch = array($branch); - } - } - - if (count($branch) !== 1) { - throw new ArcanistUsageException( - pht('Specify exactly one branch or bookmark to land changes from.')); - } - $this->branch = head($branch); - $this->keepBranch = $this->getArgument('keep-branch'); - - $this->preview = $this->getArgument('preview'); - - if (!$this->branchType) { - $this->branchType = $this->getBranchType($this->branch); - } - - $onto_default = $this->isGit ? 'master' : 'default'; - $onto_default = nonempty( - $this->getConfigFromAnySource('arc.land.onto.default'), - $onto_default); - $onto_default = coalesce( - $this->getUpstreamMatching($this->branch, '/^refs\/heads\/(.+)$/'), - $onto_default); - $this->onto = $this->getArgument('onto', $onto_default); - $this->ontoType = $this->getBranchType($this->onto); - - $remote_default = $this->isGit ? 'origin' : ''; - $remote_default = coalesce( - $this->getUpstreamMatching($this->onto, '/^refs\/remotes\/(.+?)\//'), - $remote_default); - $this->remote = $this->getArgument('remote', $remote_default); - - if ($this->getArgument('merge')) { - $this->useSquash = false; - } else if ($this->getArgument('squash')) { - $this->useSquash = true; - } else { - $this->useSquash = !$this->isHistoryImmutable(); - } - - $this->ontoRemoteBranch = $this->onto; - if ($this->isGitSvn) { - $this->ontoRemoteBranch = $this->getGitSvnTrunk(); - } else if ($this->isGit) { - $this->ontoRemoteBranch = $this->remote.'/'.$this->onto; - } - - $this->oldBranch = $this->getBranchOrBookmark(); - } - - private function validate() { - $repository_api = $this->getRepositoryAPI(); - - if ($this->onto == $this->branch) { - $message = pht( - "You can not land a %s onto itself -- you are trying ". - "to land '%s' onto '%s'. For more information on how to push ". - "changes, see 'Pushing and Closing Revisions' in 'Arcanist User ". - "Guide: arc diff' in the documentation.", - $this->branchType, - $this->branch, - $this->onto); - if (!$this->isHistoryImmutable()) { - $message .= ' '.pht("You may be able to '%s' instead.", 'arc amend'); - } - throw new ArcanistUsageException($message); - } - - if ($this->isHg) { - if ($this->useSquash) { - if (!$repository_api->supportsRebase()) { - throw new ArcanistUsageException( - pht( - 'You must enable the rebase extension to use the %s strategy.', - '--squash')); - } - } - - if ($this->branchType != $this->ontoType) { - throw new ArcanistUsageException(pht( - 'Source %s is a %s but destination %s is a %s. When landing a '. - '%s, the destination must also be a %s. Use %s to specify a %s, '. - 'or set %s in %s.', - $this->branch, - $this->branchType, - $this->onto, - $this->ontoType, - $this->branchType, - $this->branchType, - '--onto', - $this->branchType, - 'arc.land.onto.default', - '.arcconfig')); - } - } - - if ($this->isGit) { - list($err) = $repository_api->execManualLocal( - 'rev-parse --verify %s', - $this->branch); - - if ($err) { - throw new ArcanistUsageException( - pht("Branch '%s' does not exist.", $this->branch)); - } - } - - $this->requireCleanWorkingCopy(); - } - - private function checkoutBranch() { - $repository_api = $this->getRepositoryAPI(); - if ($this->getBranchOrBookmark() != $this->branch) { - $repository_api->execxLocal('checkout %s', $this->branch); - } - - switch ($this->branchType) { - case self::REFTYPE_BOOKMARK: - $message = pht( - 'Switched to bookmark **%s**. Identifying and merging...', - $this->branch); - break; - case self::REFTYPE_BRANCH: - default: - $message = pht( - 'Switched to branch **%s**. Identifying and merging...', - $this->branch); - break; - } - - echo phutil_console_format($message."\n"); - } - - private function printPendingCommits() { - $repository_api = $this->getRepositoryAPI(); - - if ($repository_api instanceof ArcanistGitAPI) { - list($out) = $repository_api->execxLocal( - 'log --oneline %s %s --', - $this->branch, - '^'.$this->onto); - } else if ($repository_api instanceof ArcanistMercurialAPI) { - $common_ancestor = $repository_api->getCanonicalRevisionName( - hgsprintf('ancestor(%s,%s)', - $this->onto, - $this->branch)); - - $branch_range = hgsprintf( - 'reverse((%s::%s) - %s)', - $common_ancestor, - $this->branch, - $common_ancestor); - - list($out) = $repository_api->execxLocal( - 'log -r %s --template %s', - $branch_range, - '{node|short} {desc|firstline}\n'); - } - - if (!trim($out)) { - $this->restoreBranch(); - throw new ArcanistUsageException( - pht('No commits to land from %s.', $this->branch)); - } - - echo pht("The following commit(s) will be landed:\n\n%s", $out), "\n"; - } - - private function findRevision() { - $repository_api = $this->getRepositoryAPI(); - - $this->parseBaseCommitArgument(array($this->ontoRemoteBranch)); - - $revision_id = $this->getArgument('revision'); - if ($revision_id) { - $revision_id = $this->normalizeRevisionID($revision_id); - $revisions = $this->getConduit()->callMethodSynchronous( - 'differential.query', - array( - 'ids' => array($revision_id), - )); - if (!$revisions) { - throw new ArcanistUsageException(pht( - "No such revision '%s'!", - "D{$revision_id}")); - } - } else { - $revisions = $repository_api->loadWorkingCopyDifferentialRevisions( - $this->getConduit(), - array()); - } - - if (!count($revisions)) { - throw new ArcanistUsageException(pht( - "arc can not identify which revision exists on %s '%s'. Update the ". - "revision with recent changes to synchronize the %s name and hashes, ". - "or use '%s' to amend the commit message at HEAD, or use ". - "'%s' to select a revision explicitly.", - $this->branchType, - $this->branch, - $this->branchType, - 'arc amend', - '--revision ')); - } else if (count($revisions) > 1) { - switch ($this->branchType) { - case self::REFTYPE_BOOKMARK: - $message = pht( - "There are multiple revisions on feature bookmark '%s' which are ". - "not present on '%s':\n\n". - "%s\n". - 'Separate these revisions onto different bookmarks, or use '. - '--revision to use the commit message from '. - 'and land them all.', - $this->branch, - $this->onto, - $this->renderRevisionList($revisions)); - break; - case self::REFTYPE_BRANCH: - default: - $message = pht( - "There are multiple revisions on feature branch '%s' which are ". - "not present on '%s':\n\n". - "%s\n". - 'Separate these revisions onto different branches, or use '. - '--revision to use the commit message from '. - 'and land them all.', - $this->branch, - $this->onto, - $this->renderRevisionList($revisions)); - break; - } - - throw new ArcanistUsageException($message); - } - - $this->revision = head($revisions); - - $rev_status = $this->revision['status']; - $rev_id = $this->revision['id']; - $rev_title = $this->revision['title']; - $rev_auxiliary = idx($this->revision, 'auxiliary', array()); - - $full_name = pht('D%d: %s', $rev_id, $rev_title); - - if ($this->revision['authorPHID'] != $this->getUserPHID()) { - $other_author = $this->getConduit()->callMethodSynchronous( - 'user.query', - array( - 'phids' => array($this->revision['authorPHID']), - )); - $other_author = ipull($other_author, 'userName', 'phid'); - $other_author = $other_author[$this->revision['authorPHID']]; - $ok = phutil_console_confirm(pht( - "This %s has revision '%s' but you are not the author. Land this ". - "revision by %s?", - $this->branchType, - $full_name, - $other_author)); - if (!$ok) { - throw new ArcanistUserAbortException(); - } - } - - $state_warning = null; - $state_header = null; - if ($rev_status == ArcanistDifferentialRevisionStatus::CHANGES_PLANNED) { - $state_header = pht('REVISION HAS CHANGES PLANNED'); - $state_warning = pht( - 'The revision you are landing ("%s") is currently in the "%s" state, '. - 'indicating that you expect to revise it before moving forward.'. - "\n\n". - 'Normally, you should resubmit it for review and wait until it is '. - '"%s" by reviewers before you continue.'. - "\n\n". - 'To resubmit the revision for review, either: update the revision '. - 'with revised changes; or use "Request Review" from the web interface.', - $full_name, - pht('Changes Planned'), - pht('Accepted')); - } else if ($rev_status != ArcanistDifferentialRevisionStatus::ACCEPTED) { - $state_header = pht('REVISION HAS NOT BEEN ACCEPTED'); - $state_warning = pht( - 'The revision you are landing ("%s") has not been "%s" by reviewers.', - $full_name, - pht('Accepted')); - } - - if ($state_warning !== null) { - $prompt = pht('Land revision in the wrong state?'); - - id(new PhutilConsoleBlock()) - ->addParagraph(tsprintf('** %s **', $state_header)) - ->addParagraph(tsprintf('%B', $state_warning)) - ->draw(); - - $ok = phutil_console_confirm($prompt); - if (!$ok) { - throw new ArcanistUserAbortException(); - } - } - - if ($rev_auxiliary) { - $phids = idx($rev_auxiliary, 'phabricator:depends-on', array()); - if ($phids) { - $dep_on_revs = $this->getConduit()->callMethodSynchronous( - 'differential.query', - array( - 'phids' => $phids, - 'status' => 'status-open', - )); - - $open_dep_revs = array(); - foreach ($dep_on_revs as $dep_on_rev) { - $dep_on_rev_id = $dep_on_rev['id']; - $dep_on_rev_title = $dep_on_rev['title']; - $dep_on_rev_status = $dep_on_rev['status']; - $open_dep_revs[$dep_on_rev_id] = $dep_on_rev_title; - } - - if (!empty($open_dep_revs)) { - $open_revs = array(); - foreach ($open_dep_revs as $id => $title) { - $open_revs[] = ' - D'.$id.': '.$title; - } - $open_revs = implode("\n", $open_revs); - - echo pht( - "Revision '%s' depends on open revisions:\n\n%s", - "D{$rev_id}: {$rev_title}", - $open_revs); - - $ok = phutil_console_confirm(pht('Continue anyway?')); - if (!$ok) { - throw new ArcanistUserAbortException(); - } - } - } - } - - $message = $this->getConduit()->callMethodSynchronous( - 'differential.getcommitmessage', - array( - 'revision_id' => $rev_id, - )); - - $this->messageFile = new TempFile(); - Filesystem::writeFile($this->messageFile, $message); - - echo pht( - "Landing revision '%s'...", - "D{$rev_id}: {$rev_title}")."\n"; - - $diff_phid = idx($this->revision, 'activeDiffPHID'); - if ($diff_phid) { - $this->checkForBuildables($diff_phid); - } - } - - private function pullFromRemote() { - $repository_api = $this->getRepositoryAPI(); - - $local_ahead_of_remote = false; - if ($this->isGit) { - $repository_api->execxLocal('checkout %s', $this->onto); - - echo phutil_console_format(pht( - "Switched to branch **%s**. Updating branch...\n", - $this->onto)); - - try { - $repository_api->execxLocal('pull --ff-only --no-stat'); - } catch (CommandException $ex) { - if (!$this->isGitSvn) { - throw $ex; - } - } - list($out) = $repository_api->execxLocal( - 'log %s..%s', - $this->ontoRemoteBranch, - $this->onto); - if (strlen(trim($out))) { - $local_ahead_of_remote = true; - } else if ($this->isGitSvn) { - $repository_api->execxLocal('svn rebase'); - } - - } else if ($this->isHg) { - echo phutil_console_format(pht('Updating **%s**...', $this->onto)."\n"); - - try { - list($out, $err) = $repository_api->execxLocal('pull'); - - $divergedbookmark = $this->onto.'@'.$repository_api->getBranchName(); - if (strpos($err, $divergedbookmark) !== false) { - throw new ArcanistUsageException(phutil_console_format(pht( - "Local bookmark **%s** has diverged from the server's **%s** ". - "(now labeled **%s**). Please resolve this divergence and run ". - "'%s' again.", - $this->onto, - $this->onto, - $divergedbookmark, - 'arc land'))); - } - } catch (CommandException $ex) { - $err = $ex->getError(); - $stdout = $ex->getStdout(); - - // Copied from: PhabricatorRepositoryPullLocalDaemon.php - // NOTE: Between versions 2.1 and 2.1.1, Mercurial changed the - // behavior of "hg pull" to return 1 in case of a successful pull - // with no changes. This behavior has been reverted, but users who - // updated between Feb 1, 2012 and Mar 1, 2012 will have the - // erroring version. Do a dumb test against stdout to check for this - // possibility. - // See: https://github.com/phacility/phabricator/issues/101/ - - // NOTE: Mercurial has translated versions, which translate this error - // string. In a translated version, the string will be something else, - // like "aucun changement trouve". There didn't seem to be an easy way - // to handle this (there are hard ways but this is not a common - // problem and only creates log spam, not application failures). - // Assume English. - - // TODO: Remove this once we're far enough in the future that - // deployment of 2.1 is exceedingly rare? - if ($err != 1 || !preg_match('/no changes found/', $stdout)) { - throw $ex; - } - } - - // Pull succeeded. Now make sure master is not on an outgoing change - if ($repository_api->supportsPhases()) { - list($out) = $repository_api->execxLocal( - 'log -r %s --template %s', $this->onto, '{phase}'); - if ($out != 'public') { - $local_ahead_of_remote = true; - } - } else { - // execManual instead of execx because outgoing returns - // code 1 when there is nothing outgoing - list($err, $out) = $repository_api->execManualLocal( - 'outgoing -r %s', - $this->onto); - - // $err === 0 means something is outgoing - if ($err === 0) { - $local_ahead_of_remote = true; - } - } - } - - if ($local_ahead_of_remote) { - throw new ArcanistUsageException(pht( - "Local %s '%s' is ahead of remote %s '%s', so landing a feature ". - "%s would push additional changes. Push or reset the changes in '%s' ". - "before running '%s'.", - $this->ontoType, - $this->onto, - $this->ontoType, - $this->ontoRemoteBranch, - $this->ontoType, - $this->onto, - 'arc land')); - } - } - - private function rebase() { - $repository_api = $this->getRepositoryAPI(); - - chdir($repository_api->getPath()); - if ($this->isHg) { - $onto_tip = $repository_api->getCanonicalRevisionName($this->onto); - $common_ancestor = $repository_api->getCanonicalRevisionName( - hgsprintf('ancestor(%s, %s)', $this->onto, $this->branch)); - - // Only rebase if the local branch is not at the tip of the onto branch. - if ($onto_tip != $common_ancestor) { - // keep branch here so later we can decide whether to remove it - $err = $repository_api->execPassthru( - 'rebase -d %s --keepbranches', - $this->onto); - if ($err) { - echo phutil_console_format("%s\n", pht('Aborting rebase')); - $repository_api->execManualLocal('rebase --abort'); - $this->restoreBranch(); - throw new ArcanistUsageException(pht( - "'%s' failed and the rebase was aborted. This is most ". - "likely due to conflicts. Manually rebase %s onto %s, resolve ". - "the conflicts, then run '%s' again.", - sprintf('hg rebase %s', $this->onto), - $this->branch, - $this->onto, - 'arc land')); - } - } - } - - $repository_api->reloadWorkingCopy(); - } - - private function squash() { - $repository_api = $this->getRepositoryAPI(); - - if ($this->isGit) { - $repository_api->execxLocal('checkout %s', $this->onto); - $repository_api->execxLocal( - 'merge --no-stat --squash --ff-only %s', - $this->branch); - } else if ($this->isHg) { - // The hg code is a little more complex than git's because we - // need to handle the case where the landing branch has child branches: - // -a--------b master - // \ - // w--x mybranch - // \--y subbranch1 - // \--z subbranch2 - // - // arc land --branch mybranch --onto master : - // -a--b--wx master - // \--y subbranch1 - // \--z subbranch2 - - $branch_rev_id = $repository_api->getCanonicalRevisionName($this->branch); - - // At this point $this->onto has been pulled from remote and - // $this->branch has been rebased on top of onto(by the rebase() - // function). So we're guaranteed to have onto as an ancestor of branch - // when we use first((onto::branch)-onto) below. - $branch_root = $repository_api->getCanonicalRevisionName( - hgsprintf('first((%s::%s)-%s)', - $this->onto, - $this->branch, - $this->onto)); - - $branch_range = hgsprintf( - '(%s::%s)', - $branch_root, - $this->branch); - - if (!$this->keepBranch) { - $this->handleAlternateBranches($branch_root, $branch_range); - } - - // Collapse just the landing branch onto master. - // Leave its children on the original branch. - $err = $repository_api->execPassthru( - 'rebase --collapse --keep --logfile %s -r %s -d %s', - $this->messageFile, - $branch_range, - $this->onto); - - if ($err) { - $repository_api->execManualLocal('rebase --abort'); - $this->restoreBranch(); - throw new ArcanistUsageException( + protected function newPrompts() { + return array( + $this->newPrompt('arc.land.large-working-set') + ->setDescription( pht( - "Squashing the commits under %s failed. ". - "Manually squash your commits and run '%s' again.", - $this->branch, - 'arc land')); - } - - if ($repository_api->isBookmark($this->branch)) { - // a bug in mercurial means bookmarks end up on the revision prior - // to the collapse when using --collapse with --keep, - // so we manually move them to the correct spots - // see: http://bz.selenic.com/show_bug.cgi?id=3716 - $repository_api->execxLocal( - 'bookmark -f %s', - $this->onto); - - $repository_api->execxLocal( - 'bookmark -f %s -r %s', - $this->branch, - $branch_rev_id); - } - - // check if the branch had children - list($output) = $repository_api->execxLocal( - 'log -r %s --template %s', - hgsprintf('children(%s)', $this->branch), - '{node}\n'); - - $child_branch_roots = phutil_split_lines($output, false); - $child_branch_roots = array_filter($child_branch_roots); - if ($child_branch_roots) { - // move the branch's children onto the collapsed commit - foreach ($child_branch_roots as $child_root) { - $repository_api->execxLocal( - 'rebase -d %s -s %s --keep --keepbranches', - $this->onto, - $child_root); - } - } - - // All the rebases may have moved us to another branch - // so we move back. - $repository_api->execxLocal('checkout %s', $this->onto); - } - } - - /** - * Detect alternate branches and prompt the user for how to handle - * them. An alternate branch is a branch that forks from the landing - * branch prior to the landing branch tip. - * - * In a situation like this: - * -a--------b master - * \ - * w--x landingbranch - * \ \-- g subbranch - * \--y altbranch1 - * \--z altbranch2 - * - * y and z are alternate branches and will get deleted by the squash, - * so we need to detect them and ask the user what they want to do. - * - * @param string The revision id of the landing branch's root commit. - * @param string The revset specifying all the commits in the landing branch. - * @return void - */ - private function handleAlternateBranches($branch_root, $branch_range) { - $repository_api = $this->getRepositoryAPI(); - - // Using the tree in the doccomment, the revset below resolves as follows: - // 1. roots(descendants(w) - descendants(x) - (w::x)) - // 2. roots({x,g,y,z} - {g} - {w,x}) - // 3. roots({y,z}) - // 4. {y,z} - $alt_branch_revset = hgsprintf( - 'roots(descendants(%s)-descendants(%s)-%R)', - $branch_root, - $this->branch, - $branch_range); - list($alt_branches) = $repository_api->execxLocal( - 'log --template %s -r %s', - '{node}\n', - $alt_branch_revset); - - $alt_branches = phutil_split_lines($alt_branches, false); - $alt_branches = array_filter($alt_branches); - - $alt_count = count($alt_branches); - if ($alt_count > 0) { - $input = phutil_console_prompt(pht( - "%s '%s' has %s %s(s) forking off of it that would be deleted ". - "during a squash. Would you like to keep a non-squashed copy, rebase ". - "them on top of '%s', or abort and deal with them yourself? ". - "(k)eep, (r)ebase, (a)bort:", - ucfirst($this->branchType), - $this->branch, - $alt_count, - $this->branchType, - $this->branch)); - - if ($input == 'k' || $input == 'keep') { - $this->keepBranch = true; - } else if ($input == 'r' || $input == 'rebase') { - foreach ($alt_branches as $alt_branch) { - $repository_api->execxLocal( - 'rebase --keep --keepbranches -d %s -s %s', - $this->branch, - $alt_branch); - } - } else if ($input == 'a' || $input == 'abort') { - $branch_string = implode("\n", $alt_branches); - echo - "\n", + 'Confirms landing more than %s commit(s) in a single operation.', + new PhutilNumber($this->getLargeWorkingSetLimit()))), + $this->newPrompt('arc.land.confirm') + ->setDescription( pht( - "Remove the %s starting at these revisions and run %s again:\n%s", - $this->branchType.'s', - $branch_string, - 'arc land'), - "\n\n"; - throw new ArcanistUserAbortException(); - } else { - throw new ArcanistUsageException( - pht('Invalid choice. Aborting arc land.')); - } - } - } - - private function merge() { - $repository_api = $this->getRepositoryAPI(); - - // In immutable histories, do a --no-ff merge to force a merge commit with - // the right message. - $repository_api->execxLocal('checkout %s', $this->onto); - - chdir($repository_api->getPath()); - if ($this->isGit) { - $err = phutil_passthru( - 'git merge --no-stat --no-ff --no-commit %s', - $this->branch); - - if ($err) { - throw new ArcanistUsageException(pht( - "'%s' failed. Your working copy has been left in a partially ". - "merged state. You can: abort with '%s'; or follow the ". - "instructions to complete the merge.", - 'git merge', - 'git merge --abort')); - } - } else if ($this->isHg) { - // HG arc land currently doesn't support --merge. - // When merging a bookmark branch to a master branch that - // hasn't changed since the fork, mercurial fails to merge. - // Instead of only working in some cases, we just disable --merge - // until there is a demand for it. - // The user should never reach this line, since --merge is - // forbidden at the command line argument level. - throw new ArcanistUsageException( - pht('%s is not currently supported for hg repos.', '--merge')); - } - } - - private function push() { - $repository_api = $this->getRepositoryAPI(); - - // These commands can fail legitimately (e.g. commit hooks) - try { - if ($this->isGit) { - $repository_api->execxLocal('commit -F %s', $this->messageFile); - if (phutil_is_windows()) { - // Occasionally on large repositories on Windows, Git can exit with - // an unclean working copy here. This prevents reverts from being - // pushed to the remote when this occurs. - $this->requireCleanWorkingCopy(); - } - } else if ($this->isHg) { - // hg rebase produces a commit earlier as part of rebase - if (!$this->useSquash) { - $repository_api->execxLocal( - 'commit --logfile %s', - $this->messageFile); - } - } - // We dispatch this event so we can run checks on the merged revision, - // right before it gets pushed out. It's easier to do this in arc land - // than to try to hook into git/hg. - $this->didCommitMerge(); - } catch (Exception $ex) { - $this->executeCleanupAfterFailedPush(); - throw $ex; - } - - if ($this->getArgument('hold')) { - echo phutil_console_format(pht( - 'Holding change in **%s**: it has NOT been pushed yet.', - $this->onto)."\n"); - } else { - echo pht('Pushing change...'), "\n\n"; - - chdir($repository_api->getPath()); - - if ($this->isGitSvn) { - $err = phutil_passthru('git svn dcommit'); - $cmd = 'git svn dcommit'; - } else if ($this->isGit) { - $err = phutil_passthru('git push %s %s', $this->remote, $this->onto); - $cmd = 'git push'; - } else if ($this->isHgSvn) { - // hg-svn doesn't support 'push -r', so we do a normal push - // which hg-svn modifies to only push the current branch and - // ancestors. - $err = $repository_api->execPassthru('push %s', $this->remote); - $cmd = 'hg push'; - } else if ($this->isHg) { - if (strlen($this->remote)) { - $err = $repository_api->execPassthru( - 'push -r %s %s', - $this->onto, - $this->remote); - } else { - $err = $repository_api->execPassthru( - 'push -r %s', - $this->onto); - } - $cmd = 'hg push'; - } - - if ($err) { - echo phutil_console_format( - "** %s **\n", - pht('PUSH FAILED!')); - $this->executeCleanupAfterFailedPush(); - if ($this->isGit) { - throw new ArcanistUsageException(pht( - "'%s' failed! Fix the error and run '%s' again.", - $cmd, - 'arc land')); - } - throw new ArcanistUsageException(pht( - "'%s' failed! Fix the error and push this change manually.", - $cmd)); - } - - $this->didPush(); - - echo "\n"; - } - } - - private function executeCleanupAfterFailedPush() { - $repository_api = $this->getRepositoryAPI(); - if ($this->isGit) { - $repository_api->execxLocal('reset --hard HEAD^'); - $this->restoreBranch(); - } else if ($this->isHg) { - $repository_api->execxLocal( - '--config extensions.mq= strip %s', - $this->onto); - $this->restoreBranch(); - } - } - - private function cleanupBranch() { - $repository_api = $this->getRepositoryAPI(); - - echo pht('Cleaning up feature %s...', $this->branchType), "\n"; - if ($this->isGit) { - list($ref) = $repository_api->execxLocal( - 'rev-parse --verify %s', - $this->branch); - $ref = trim($ref); - $recovery_command = csprintf( - 'git checkout -b %s %s', - $this->branch, - $ref); - echo pht('(Use `%s` if you want it back.)', $recovery_command), "\n"; - $repository_api->execxLocal('branch -D %s', $this->branch); - } else if ($this->isHg) { - $common_ancestor = $repository_api->getCanonicalRevisionName( - hgsprintf('ancestor(%s,%s)', $this->onto, $this->branch)); - - $branch_root = $repository_api->getCanonicalRevisionName( - hgsprintf('first((%s::%s)-%s)', - $common_ancestor, - $this->branch, - $common_ancestor)); - - $repository_api->execxLocal( - '--config extensions.mq= strip -r %s', - $branch_root); - - if ($repository_api->isBookmark($this->branch)) { - $repository_api->execxLocal('bookmark -d %s', $this->branch); - } - } - - if ($this->getArgument('delete-remote')) { - if ($this->isHg) { - // named branches were closed as part of the earlier commit - // so only worry about bookmarks - if ($repository_api->isBookmark($this->branch)) { - $repository_api->execxLocal( - 'push -B %s %s', - $this->branch, - $this->remote); - } - } - } - } - - public function getSupportedRevisionControlSystems() { - return array('git', 'hg'); - } - - private function getBranchOrBookmark() { - $repository_api = $this->getRepositoryAPI(); - if ($this->isGit) { - $branch = $repository_api->getBranchName(); - - // If we don't have a branch name, just use whatever's at HEAD. - if (!strlen($branch) && !$this->isGitSvn) { - $branch = $repository_api->getWorkingCopyRevision(); - } - } else if ($this->isHg) { - $branch = $repository_api->getActiveBookmark(); - if (!$branch) { - $branch = $repository_api->getBranchName(); - } - } - - return $branch; - } - - private function getBranchType($branch) { - $repository_api = $this->getRepositoryAPI(); - if ($this->isHg && $repository_api->isBookmark($branch)) { - return 'bookmark'; - } - return 'branch'; - } - - /** - * Restore the original branch, e.g. after a successful land or a failed - * pull. - */ - private function restoreBranch() { - $repository_api = $this->getRepositoryAPI(); - $repository_api->execxLocal('checkout %s', $this->oldBranch); - if ($this->isGit) { - $repository_api->execxLocal('submodule update --init --recursive'); - } - echo pht( - "Switched back to %s %s.\n", - $this->branchType, - phutil_console_format('**%s**', $this->oldBranch)); + 'Confirms that the correct changes have been selected.')), + $this->newPrompt('arc.land.implicit') + ->setDescription( + pht( + 'Confirms that local commits which are not associated with '. + 'a revision should land.')), + $this->newPrompt('arc.land.unauthored') + ->setDescription( + pht( + 'Confirms that revisions you did not author should land.')), + $this->newPrompt('arc.land.changes-planned') + ->setDescription( + pht( + 'Confirms that revisions with changes planned should land.')), + $this->newPrompt('arc.land.closed') + ->setDescription( + pht( + 'Confirms that revisions that are already closed should land.')), + $this->newPrompt('arc.land.not-accepted') + ->setDescription( + pht( + 'Confirms that revisions that are not accepted should land.')), + $this->newPrompt('arc.land.open-parents') + ->setDescription( + pht( + 'Confirms that revisions with open parent revisions should '. + 'land.')), + $this->newPrompt('arc.land.failed-builds') + ->setDescription( + pht( + 'Confirms that revisions with failed builds.')), + $this->newPrompt('arc.land.ongoing-builds') + ->setDescription( + pht( + 'Confirms that revisions with ongoing builds.')), + ); } - - /** - * Check if a diff has a running or failed buildable, and prompt the user - * before landing if it does. - */ - private function checkForBuildables($diff_phid) { - // Try to use the more modern check which respects the "Warn on Land" - // behavioral flag on build plans if we can. This newer check won't work - // unless the server is running code from March 2019 or newer since the - // API methods we need won't exist yet. We'll fall back to the older check - // if this one doesn't work out. - try { - $this->checkForBuildablesWithPlanBehaviors($diff_phid); - return; - } catch (ArcanistUserAbortException $abort_ex) { - throw $abort_ex; - } catch (Exception $ex) { - // Continue with the older approach, below. - } - - // NOTE: Since Harbormaster is still beta and this stuff all got added - // recently, just bail if we can't find a buildable. This is just an - // advisory check intended to prevent human error. - - try { - $buildables = $this->getConduit()->callMethodSynchronous( - 'harbormaster.querybuildables', - array( - 'buildablePHIDs' => array($diff_phid), - 'manualBuildables' => false, - )); - } catch (ConduitClientException $ex) { - return; - } - - if (!$buildables['data']) { - // If there's no corresponding buildable, we're done. - return; - } - - $console = PhutilConsole::getConsole(); - - $buildable = head($buildables['data']); - - if ($buildable['buildableStatus'] == 'passed') { - $console->writeOut( - "** %s ** %s\n", - pht('BUILDS PASSED'), - pht('Harbormaster builds for the active diff completed successfully.')); - return; - } - - switch ($buildable['buildableStatus']) { - case 'building': - $message = pht( - 'Harbormaster is still building the active diff for this revision.'); - $prompt = pht('Land revision anyway, despite ongoing build?'); - break; - case 'failed': - $message = pht( - 'Harbormaster failed to build the active diff for this revision.'); - $prompt = pht('Land revision anyway, despite build failures?'); - break; - default: - // If we don't recognize the status, just bail. - return; - } - - $builds = $this->queryBuilds( - array( - 'buildablePHIDs' => array($buildable['phid']), - )); - - $console->writeOut($message."\n\n"); - - $builds = msortv($builds, 'getStatusSortVector'); - foreach ($builds as $build) { - $ansi_color = $build->getStatusANSIColor(); - $status_name = $build->getStatusName(); - $object_name = $build->getObjectName(); - $build_name = $build->getName(); - - echo tsprintf( - " ** %s ** %s: %s\n", - $status_name, - $object_name, - $build_name); - } - - $console->writeOut( - "\n%s\n\n **%s**: __%s__", - pht('You can review build details here:'), - pht('Harbormaster URI'), - $buildable['uri']); - - if (!phutil_console_confirm($prompt)) { - throw new ArcanistUserAbortException(); - } + public function getLargeWorkingSetLimit() { + return 50; } - private function checkForBuildablesWithPlanBehaviors($diff_phid) { - // TODO: These queries should page through all results instead of fetching - // only the first page, but we don't have good primitives to support that - // in "master" yet. - - $this->writeInfo( - pht('BUILDS'), - pht('Checking build status...')); - - $raw_buildables = $this->getConduit()->callMethodSynchronous( - 'harbormaster.buildable.search', - array( - 'constraints' => array( - 'objectPHIDs' => array( - $diff_phid, - ), - 'manual' => false, - ), - )); - - if (!$raw_buildables['data']) { - return; - } - - $buildables = $raw_buildables['data']; - $buildable_phids = ipull($buildables, 'phid'); - - $raw_builds = $this->getConduit()->callMethodSynchronous( - 'harbormaster.build.search', - array( - 'constraints' => array( - 'buildables' => $buildable_phids, - ), - )); - - if (!$raw_builds['data']) { - return; - } - - $builds = array(); - foreach ($raw_builds['data'] as $raw_build) { - $build_ref = ArcanistBuildRef::newFromConduit($raw_build); - $build_phid = $build_ref->getPHID(); - $builds[$build_phid] = $build_ref; - } - - $plan_phids = mpull($builds, 'getBuildPlanPHID'); - $plan_phids = array_values($plan_phids); - - $raw_plans = $this->getConduit()->callMethodSynchronous( - 'harbormaster.buildplan.search', - array( - 'constraints' => array( - 'phids' => $plan_phids, - ), - )); - - $plans = array(); - foreach ($raw_plans['data'] as $raw_plan) { - $plan_ref = ArcanistBuildPlanRef::newFromConduit($raw_plan); - $plan_phid = $plan_ref->getPHID(); - $plans[$plan_phid] = $plan_ref; - } - - $ongoing_builds = array(); - $failed_builds = array(); - - $builds = msortv($builds, 'getStatusSortVector'); - foreach ($builds as $build_ref) { - $plan = idx($plans, $build_ref->getBuildPlanPHID()); - if (!$plan) { - continue; - } - - $plan_behavior = $plan->getBehavior('arc-land', 'always'); - $if_building = ($plan_behavior == 'building'); - $if_complete = ($plan_behavior == 'complete'); - $if_never = ($plan_behavior == 'never'); - - // If the build plan "Never" warns when landing, skip it. - if ($if_never) { - continue; - } - - // If the build plan warns when landing "If Complete" but the build is - // not complete, skip it. - if ($if_complete && !$build_ref->isComplete()) { - continue; - } - - // If the build plan warns when landing "If Building" but the build is - // complete, skip it. - if ($if_building && $build_ref->isComplete()) { - continue; - } - - // Ignore passing builds. - if ($build_ref->isPassed()) { - continue; - } - - if (!$build_ref->isComplete()) { - $ongoing_builds[] = $build_ref; - } else { - $failed_builds[] = $build_ref; - } - } + public function runWorkflow() { + $working_copy = $this->getWorkingCopy(); + $repository_api = $working_copy->getRepositoryAPI(); - if (!$ongoing_builds && !$failed_builds) { - return; - } - - if ($failed_builds) { - $this->writeWarn( - pht('BUILD FAILURES'), - pht( - 'Harbormaster failed to build the active diff for this revision:')); - $prompt = pht('Land revision anyway, despite build failures?'); - } else if ($ongoing_builds) { - $this->writeWarn( - pht('ONGOING BUILDS'), + $land_engine = $repository_api->getLandEngine(); + if (!$land_engine) { + throw new PhutilArgumentUsageException( pht( - 'Harbormaster is still building the active diff for this revision:')); - $prompt = pht('Land revision anyway, despite ongoing build?'); - } - - $show_builds = array_merge($failed_builds, $ongoing_builds); - echo "\n"; - foreach ($show_builds as $build_ref) { - $ansi_color = $build_ref->getStatusANSIColor(); - $status_name = $build_ref->getStatusName(); - $object_name = $build_ref->getObjectName(); - $build_name = $build_ref->getName(); - - echo tsprintf( - " ** %s ** %s: %s\n", - $status_name, - $object_name, - $build_name); - } - - echo tsprintf( - "\n%s\n\n", - pht('You can review build details here:')); - - foreach ($buildables as $buildable) { - $buildable_uri = id(new PhutilURI($this->getConduitURI())) - ->setPath(sprintf('/B%d', $buildable['id'])); - - echo tsprintf( - " **%s**: __%s__\n", - pht('Buildable %d', $buildable['id']), - $buildable_uri); - } - - if (!phutil_console_confirm($prompt)) { - throw new ArcanistUserAbortException(); - } - } - - public function buildEngineMessage(ArcanistLandEngine $engine) { - // TODO: This is oh-so-gross. - $this->findRevision(); - $engine->setCommitMessageFile($this->messageFile); - } - - public function didCommitMerge() { - $this->dispatchEvent( - ArcanistEventType::TYPE_LAND_WILLPUSHREVISION, - array()); - } - - public function didPush() { - $this->askForRepositoryUpdate(); - - $mark_workflow = $this->buildChildWorkflow( - 'close-revision', - array( - '--finalize', - '--quiet', - $this->revision['id'], - )); - $mark_workflow->run(); + '"arc land" must be run in a Git or Mercurial working copy.')); + } + + $is_incremental = $this->getArgument('incremental'); + $source_refs = $this->getArgument('ref'); + + $onto_remote_arg = $this->getArgument('onto-remote'); + $onto_args = $this->getArgument('onto'); + + $into_remote = $this->getArgument('into-remote'); + $into_empty = $this->getArgument('into-empty'); + $into_local = $this->getArgument('into-local'); + $into = $this->getArgument('into'); + + $is_preview = $this->getArgument('preview'); + $should_hold = $this->getArgument('hold'); + $should_keep = $this->getArgument('keep-branches'); + + $revision = $this->getArgument('revision'); + $strategy = $this->getArgument('strategy'); + + $land_engine + ->setViewer($this->getViewer()) + ->setWorkflow($this) + ->setLogEngine($this->getLogEngine()) + ->setSourceRefs($source_refs) + ->setShouldHold($should_hold) + ->setShouldKeep($should_keep) + ->setStrategyArgument($strategy) + ->setShouldPreview($is_preview) + ->setOntoRemoteArgument($onto_remote_arg) + ->setOntoArguments($onto_args) + ->setIntoRemoteArgument($into_remote) + ->setIntoEmptyArgument($into_empty) + ->setIntoLocalArgument($into_local) + ->setIntoArgument($into) + ->setIsIncremental($is_incremental) + ->setRevisionSymbol($revision); + + $land_engine->execute(); } - private function queryBuilds(array $constraints) { - $conduit = $this->getConduit(); - - // NOTE: This method only loads the 100 most recent builds. It's rare for - // a revision to have more builds than that and there's currently no paging - // wrapper for "*.search" Conduit API calls available in Arcanist. - - try { - $raw_result = $conduit->callMethodSynchronous( - 'harbormaster.build.search', - array( - 'constraints' => $constraints, - )); - } catch (Exception $ex) { - // If the server doesn't have "harbormaster.build.search" yet (Aug 2016), - // try the older "harbormaster.querybuilds" instead. - $raw_result = $conduit->callMethodSynchronous( - 'harbormaster.querybuilds', - $constraints); - } - - $refs = array(); - foreach ($raw_result['data'] as $raw_data) { - $refs[] = ArcanistBuildRef::newFromConduit($raw_data); - } - - return $refs; - } - - } diff --git a/src/workflow/ArcanistWorkflow.php b/src/workflow/ArcanistWorkflow.php --- a/src/workflow/ArcanistWorkflow.php +++ b/src/workflow/ArcanistWorkflow.php @@ -244,7 +244,7 @@ return $err; } - final protected function getLogEngine() { + final public function getLogEngine() { return $this->getRuntime()->getLogEngine(); } @@ -2357,6 +2357,15 @@ $prompts = $this->newPrompts(); assert_instances_of($prompts, 'ArcanistPrompt'); + $prompts[] = $this->newPrompt('arc.state.stash') + ->setDescription( + pht( + 'Prompts the user to stash changes and continue when the '. + 'working copy has untracked, uncommitted. or unstaged '. + 'changes.')); + + // TODO: Swap to ArrayCheck? + $map = array(); foreach ($prompts as $prompt) { $key = $prompt->getKey(); @@ -2380,7 +2389,7 @@ return $this->promptMap; } - protected function getPrompt($key) { + final public function getPrompt($key) { $map = $this->getPromptMap(); $prompt = idx($map, $key);