Page MenuHomePhabricator

D21315.diff
No OneTemporary

D21315.diff

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 @@
-<?php
-
-final class ArcanistGitLandEngine
- extends ArcanistLandEngine {
-
- private $localRef;
- private $localCommit;
- private $sourceCommit;
- private $mergedRef;
- private $restoreWhenDestroyed;
- private $isGitPerforce;
-
- private function setIsGitPerforce($is_git_perforce) {
- $this->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 @@
+<?php
+
+final class ArcanistLandCommit
+ extends Phobject {
+
+ private $hash;
+ private $summary;
+ private $displaySummary;
+ private $parents;
+ private $symbols = array();
+ private $explicitRevisionRef;
+ private $revisionRef = false;
+ private $parentCommits;
+ private $isHeadCommit;
+ private $isImplicitCommit;
+
+ public function setHash($hash) {
+ $this->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 @@
+<?php
+
+final class ArcanistLandCommitSet
+ extends Phobject {
+
+ private $revisionRef;
+ private $commits;
+
+ public function setRevisionRef(ArcanistRevisionRef $revision_ref) {
+ $this->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 @@
-<?php
-
-abstract class ArcanistLandEngine extends Phobject {
-
- private $workflow;
- private $repositoryAPI;
- private $targetRemote;
- private $targetOnto;
- private $sourceRef;
- private $commitMessageFile;
- private $shouldHold;
- private $shouldKeep;
- private $shouldSquash;
- private $shouldDeleteRemote;
- private $shouldPreview;
- private $remoteArgument;
- private $ontoArgument;
-
- // TODO: This is really grotesque.
- private $buildMessageCallback;
-
- final public function setWorkflow(ArcanistWorkflow $workflow) {
- $this->workflow = $workflow;
- return $this;
- }
-
- final public function getWorkflow() {
- return $this->workflow;
- }
-
- final public function setRepositoryAPI(
- ArcanistRepositoryAPI $repository_api) {
- $this->repositoryAPI = $repository_api;
- return $this;
- }
-
- final public function getRepositoryAPI() {
- return $this->repositoryAPI;
- }
-
- final public function 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 @@
+<?php
+
+final class ArcanistLandSymbol
+ extends Phobject {
+
+ private $symbol;
+ private $commit;
+
+ public function setSymbol($symbol) {
+ $this->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 @@
+<?php
+
+final class ArcanistLandTarget
+ extends Phobject {
+
+ private $remote;
+ private $ref;
+ private $commit;
+
+ public function setRemote($remote) {
+ $this->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 @@
+<?php
+
+final class ArcanistGitLandEngine
+ extends ArcanistLandEngine {
+
+ private $isGitPerforce;
+ private $landTargetCommitMap = array();
+ private $deletedBranches = array();
+
+ private function setIsGitPerforce($is_git_perforce) {
+ $this->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 @@
+<?php
+
+abstract class ArcanistLandEngine extends Phobject {
+
+ private $workflow;
+ private $viewer;
+ private $logEngine;
+ private $repositoryAPI;
+
+ private $sourceRefs;
+ private $shouldHold;
+ private $shouldKeep;
+ private $shouldPreview;
+ private $isIncremental;
+ private $ontoRemoteArgument;
+ private $ontoArguments;
+ private $intoEmptyArgument;
+ private $intoLocalArgument;
+ private $intoRemoteArgument;
+ private $intoArgument;
+ private $strategyArgument;
+ private $strategy;
+
+ private $revisionSymbol;
+ private $revisionSymbolRef;
+
+ private $ontoRemote;
+ private $ontoRefs;
+ private $intoRemote;
+ private $intoRef;
+ private $intoEmpty;
+ private $intoLocal;
+
+ final public function setViewer($viewer) {
+ $this->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(
+ " <bg:yellow> %s </bg> %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 @@
+<?php
+
+final class ArcanistMercurialLandEngine
+ extends ArcanistLandEngine {
+
+ protected function getDefaultSymbols() {
+ $api = $this->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 @@
+<?php
+
+final class ArcanistMercurialCommitMessageHardpointQuery
+ extends ArcanistWorkflowMercurialHardpointQuery {
+
+ public function getHardpoints() {
+ return array(
+ ArcanistCommitRef::HARDPOINT_MESSAGE,
+ );
+ }
+
+ protected function canLoadRef(ArcanistRef $ref) {
+ return ($ref instanceof ArcanistCommitRef);
+ }
+
+ public function loadHardpoint(array $refs, $hardpoint) {
+ $api = $this->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 @@
+<?php
+
+final class ArcanistMercurialWorkingCopyRevisionHardpointQuery
+ extends ArcanistWorkflowMercurialHardpointQuery {
+
+ public function getHardpoints() {
+ return array(
+ ArcanistWorkingCopyStateRef::HARDPOINT_REVISIONREFS,
+ );
+ }
+
+ protected function canLoadRef(ArcanistRef $ref) {
+ return ($ref instanceof ArcanistWorkingCopyStateRef);
+ }
+
+ public function loadHardpoint(array $refs, $hardpoint) {
+ yield $this->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 @@
+<?php
+
+abstract class ArcanistWorkflowMercurialHardpointQuery
+ extends ArcanistRuntimeHardpointQuery {
+
+ final protected function canLoadHardpoint() {
+ $api = $this->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 @@
+<?php
+
+final class ArcanistMercurialLocalState
+ extends ArcanistRepositoryLocalState {
+
+ private $localCommit;
+ private $localRef;
+ private $localPath;
+
+ public function getLocalRef() {
+ return $this->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(<<<EOTEXT
- **land** [__options__] [__ref__]
-EOTEXT
- );
- }
-
- public function getCommandHelp() {
- return phutil_console_format(<<<EOTEXT
- Supports: git, git/p4, hg
+ public function getWorkflowInformation() {
+ $help = pht(<<<EOTEXT
+Supports: git, git/p4, hg
- Publish an accepted revision after review. This command is the last
- step in the standard Differential code review workflow.
+Publish an accepted revision after review. This command is the last
+step in the standard Differential code review workflow.
- This command merges and pushes changes associated with an accepted
- revision that are currently sitting in __ref__, which is usually the
- name of a local branch. Without __ref__, the current working copy
- state will be used.
+This command merges and pushes changes associated with an accepted
+revision that are currently sitting in __ref__, which is usually the
+name of a local branch. Without __ref__, the current working copy
+state will be used.
- Under Git: branches, tags, and arbitrary commits (detached HEADs)
- may be landed.
+Under Git: branches, tags, and arbitrary commits (detached HEADs)
+may be landed.
- Under Git/Perforce: branches, tags, and arbitrary commits may
- be submitted.
+Under Git/Perforce: branches, tags, and arbitrary commits may
+be submitted.
- Under Mercurial: branches and bookmarks may be landed, but only
- onto a target of the same type. See T3855.
+Under Mercurial: branches and bookmarks may be landed, but only
+onto a target of the same type. See T3855.
- The workflow selects a target branch to land onto and a remote where
- the change will be pushed to.
+The workflow selects a target branch to land onto and a remote where
+the change will be pushed to.
- A target branch is selected by examining these sources in order:
+A target branch is selected by examining these sources in order:
- - the **--onto** flag;
- - the upstream of the branch targeted by the land operation,
- recursively (Git only);
- - the __arc.land.onto.default__ configuration setting;
- - or by falling back to a standard default:
- - "master" in Git;
- - "default" in Mercurial.
+ - the **--onto** flag;
+ - the upstream of the branch targeted by the land operation,
+ recursively (Git only);
+ - the __arc.land.onto.default__ configuration setting;
+ - or by falling back to a standard default:
+ - "master" in Git;
+ - "default" in Mercurial.
- A remote is selected by examining these sources in order:
+A remote is selected by examining these sources in order:
- - the **--remote** flag;
- - the upstream of the current branch, recursively (Git only);
- - the special "p4" remote which indicates a repository has
- been synchronized with Perforce (Git only);
- - or by falling back to a standard default:
- - "origin" in Git;
- - the default remote in Mercurial.
+ - the **--remote** flag;
+ - the upstream of the current branch, recursively (Git only);
+ - the special "p4" remote which indicates a repository has
+ been synchronized with Perforce (Git only);
+ - or by falling back to a standard default:
+ - "origin" in Git;
+ - the default remote in Mercurial.
- After selecting a target branch and a remote, the commits which will
- be landed are printed.
+After selecting a target branch and a remote, the commits which will
+be landed are printed.
- With **--preview**, execution stops here, before the change is
- merged.
+With **--preview**, execution stops here, before the change is
+merged.
- The change is merged with the changes in the target branch,
- following these rules:
+The change is merged with the changes in the target branch,
+following these rules:
- In repositories with mutable history or with **--squash**, this will
- perform a squash merge (the entire branch will be represented as one
- commit after the merge).
+In repositories with mutable history or with **--squash**, this will
+perform a squash merge (the entire branch will be represented as one
+commit after the merge).
- In repositories with immutable history or with **--merge**, this will
- perform a strict merge (a merge commit will always be created, and
- local commits will be preserved).
+In repositories with immutable history or with **--merge**, this will
+perform a strict merge (a merge commit will always be created, and
+local commits will be preserved).
- The resulting commit will be given an up-to-date commit message
- describing the final state of the revision in Differential.
+The resulting commit will be given an up-to-date commit message
+describing the final state of the revision in Differential.
- In Git, the merge occurs in a detached HEAD. The local branch
- reference (if one exists) is not updated yet.
+In Git, the merge occurs in a detached HEAD. The local branch
+reference (if one exists) is not updated yet.
- With **--hold**, execution stops here, before the change is pushed.
+With **--hold**, execution stops here, before the change is pushed.
- The change is pushed into the remote.
+The change is pushed into the remote.
- Consulting mystical sources of power, the workflow makes a guess
- about what state you wanted to end up in after the process finishes
- and the working copy is put into that state.
-
- The branch which was landed is deleted, unless the **--keep-branch**
- flag was passed or the landing branch is the same as the target
- branch.
+Consulting mystical sources of power, the workflow makes a guess
+about what state you wanted to end up in after the process finishes
+and the working copy is put into that state.
+The branch which was landed is deleted, unless the **--keep-branch**
+flag was passed or the landing branch is the same as the target
+branch.
EOTEXT
);
- }
-
- public function requiresWorkingCopy() {
- return true;
- }
-
- public function requiresConduit() {
- return true;
- }
- public function requiresAuthentication() {
- return true;
- }
+ // TODO: Add command synopses.
- public function requiresRepositoryAPI() {
- return true;
+ return $this->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 <id>'));
- } 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 <id> to use the commit message from <id> '.
- '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 <id> to use the commit message from <id> '.
- '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('<bg:yellow>** %s **</bg>', $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(
- "<bg:red>** %s **</bg>\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(
- "**<bg:green> %s </bg>** %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(
- " **<bg:".$ansi_color."> %s </bg>** %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(
- " **<bg:".$ansi_color."> %s </bg>** %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);

File Metadata

Mime Type
text/plain
Expires
Wed, Feb 12, 11:56 PM (1 h, 59 m)
Storage Engine
blob
Storage Format
Encrypted (AES-256-CBC)
Storage Handle
7125606
Default Alt Text
D21315.diff (212 KB)

Event Timeline